Files
ollama/tools/tools.go
Devon Rifkin e51dead636 preserve tool definition and call JSON ordering (#13525)
* preserve tool definition and call JSON ordering

This is another iteration of
<https://github.com/ollama/ollama/pull/12518>, but this time we've
simplified things by relaxing the competing requirements of being
compatible AND order-preserving with templates (vs. renderers). We
maintain backwards compatibility at the cost of not guaranteeing order
for templates. We plan on moving more and more models to renderers,
which have been updated to use these new data types, and additionally
we could add an opt-in way of templates getting an order-preserved list
(e.g., via sibling template vars)

* orderedmap_test: remove testify
2026-01-05 18:03:36 -08:00

382 lines
8.0 KiB
Go

package tools
import (
"bytes"
"encoding/json"
"strings"
"text/template"
"github.com/ollama/ollama/api"
)
type toolsState int
const (
toolsState_LookingForTag toolsState = iota
toolsState_ToolCalling
toolsState_Done
)
type Parser struct {
tag string
tools []api.Tool
state toolsState
buffer []byte
n int
}
func (p *Parser) GetBuffer() []byte {
return p.buffer
}
// NewParser creates a new tool call parser from a model's chat
// template and a list of provided tools.
func NewParser(tmpl *template.Template, tools []api.Tool) *Parser {
return NewParserWithTag(tools, parseTag(tmpl))
}
func NewParserWithTag(tools []api.Tool, tag string) *Parser {
return &Parser{
tag: tag,
tools: tools,
}
}
// Add processes a string input to parse tool calls and content that
// should be sent back to the user.
func (p *Parser) Add(s string) (calls []api.ToolCall, content string) {
if p.state == toolsState_Done {
return nil, s
}
p.buffer = append(p.buffer, s...)
if p.state == toolsState_LookingForTag {
i, found := p.findTag()
if i == -1 {
content = string(p.buffer)
p.buffer = []byte{}
} else {
content = string(p.buffer[:i])
p.buffer = p.buffer[i:]
}
// for models where { or [ are used as tool calling
// tags, we only support parsing tools if the first non-
// whitespace character is { or [
if p.tag == "{" || p.tag == "[" {
if strings.TrimSpace(content) != "" {
p.state = toolsState_Done
return nil, content + string(p.buffer)
}
}
if !found {
return nil, content
}
p.state = toolsState_ToolCalling
}
for {
call := p.parseToolCall()
if call == nil {
break
}
calls = append(calls, *call)
}
if p.done() {
p.state = toolsState_Done
content = string(p.buffer)
p.buffer = []byte{}
}
return calls, content
}
// findTag searches the buffer to find and handle a tool calling tag
// returning true if the tag was found and false otherwise, and
// a string content signaling any content that should be sent back to the user
func (p *Parser) findTag() (int, bool) {
// First check for complete substring anywhere in s
if i := bytes.Index(p.buffer, []byte(p.tag)); i > -1 {
return i, true
}
// Then check for partial suffix overlap
max := min(len(p.buffer), len(p.tag))
for i := max; i > 0; i-- {
if bytes.HasSuffix(p.buffer, []byte(p.tag[:i])) {
return len(p.buffer) - i, false
}
}
return -1, false
}
// parseToolCall finds the next complete tool call in the buffer
// incrementing n and advancing the buffer.
func (p *Parser) parseToolCall() *api.ToolCall {
tool, end := findTool(p.tools, p.buffer)
if tool == nil {
return nil
}
var argsMap map[string]any
if found, i := findArguments(tool, p.buffer); found == nil {
return nil
} else {
argsMap = found
if i > end {
end = i
}
}
args := api.NewToolCallFunctionArguments()
for k, v := range argsMap {
args.Set(k, v)
}
tc := &api.ToolCall{
Function: api.ToolCallFunction{
Name: tool.Function.Name,
Arguments: args,
Index: p.n,
},
}
p.n++
p.buffer = p.buffer[end:]
return tc
}
// findTool finds the first tool name in the list that matches the
// beginning of the buffer, returning nil if no tool is found
// or if the buffer ends with a partial tool name since we need
// to wait for more data to disambiguate.
// The second return value is the end position of the tool name
// if one is found, otherwise 0.
func findTool(tools []api.Tool, buf []byte) (*api.Tool, int) {
if len(buf) == 0 {
return nil, 0
}
// check if buffer ends with a partial tool name
// this prevents matching "get" when seeing "get_weather"
var longest string
for _, t := range tools {
if len(t.Function.Name) > len(longest) {
longest = t.Function.Name
}
}
// Only check up to longest characters from the end
for i := 1; i <= min(len(buf), len(longest)); i++ {
tail := buf[len(buf)-i:]
for _, t := range tools {
name := []byte(t.Function.Name)
if len(tail) < len(name) && bytes.HasPrefix(name, tail) {
return nil, 0
}
}
}
// find first occurrence of the longest tool name
var found *api.Tool
start := -1
end := -1
for i := range tools {
name := []byte(tools[i].Function.Name)
pos := bytes.Index(buf, name)
if pos == -1 {
continue
}
// Skip if we have a better match already
if start != -1 {
if pos > start {
continue
}
if pos == start && len(name) <= len(found.Function.Name) {
continue
}
}
found = &tools[i]
start = pos
end = pos + len(name)
}
if found != nil {
return found, end
}
return nil, 0
}
// findArguments returns the first object that appears to be
// arguments for the provided tool in the provided buffer,
// returning nil if no arguments are found and the end position
// TODO (jmorganca): this does not support parsing omitted arguments
// objects for functions that have all-optional parameters
// e.g. `{"name": "get_conditions", "arguments": {}}` will work but
// `{"name": "get_conditions"}` will not currently work
func findArguments(tool *api.Tool, buffer []byte) (map[string]any, int) {
if len(buffer) == 0 {
return nil, 0
}
start := -1
var braces int
var inString, escaped bool
for i := range buffer {
c := buffer[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = !inString
continue
}
if inString {
continue
}
if c == '{' {
if braces == 0 {
start = i
}
braces++
} else if c == '}' {
braces--
if braces == 0 && start != -1 {
object := buffer[start : i+1]
var data map[string]any
if err := json.Unmarshal(object, &data); err != nil {
// not a valid object, keep looking
start = -1
continue
}
var findObject func(obj map[string]any) (map[string]any, bool)
findObject = func(obj map[string]any) (map[string]any, bool) {
findMap := func(name string, obj map[string]any) (map[string]any, bool) {
if args, ok := obj[name].(map[string]any); ok {
return args, true
}
if argsStr, ok := obj[name].(string); ok {
var argsData map[string]interface{}
if err := json.Unmarshal([]byte(argsStr), &argsData); err == nil {
return argsData, ok
}
}
return nil, false
}
if _, hasName := obj["name"]; hasName {
if args, ok := findMap("arguments", obj); ok {
return args, true
}
if args, ok := findMap("parameters", obj); ok {
return args, true
}
return nil, true
}
if args, ok := findMap(tool.Function.Name, obj); ok {
return args, true
}
for _, v := range obj {
switch child := v.(type) {
case map[string]any:
if result, found := findObject(child); found {
return result, true
}
case []any:
for _, item := range child {
if childObj, ok := item.(map[string]any); ok {
if result, found := findObject(childObj); found {
return result, true
}
}
}
}
}
return nil, false
}
if args, found := findObject(data); found {
return args, i
}
return data, i
}
if braces < 0 {
braces = 0
}
}
}
return nil, 0
}
// done checks if the parser is done parsing by looking
// for closing tag. currently only } and ] are supported
// for closing tags as {} or [] pairs may not always
// represent tool calls and we need to send the content back
func (p *Parser) done() bool {
var open, close rune
switch p.tag {
case "{":
open, close = '{', '}'
case "[":
open, close = '[', ']'
default:
return false
}
var count int
for _, c := range p.buffer {
if c == byte(open) {
count++
} else if c == byte(close) {
count--
if count == 0 {
return true
}
}
}
return false
}
// Content returns any remaining content that
// should be sent to the user. This should be the empty string
// string unless the tag is { or [ and a tool call was not found
func (p *Parser) Content() string {
if p.n > 0 {
return ""
}
if p.tag == "{" || p.tag == "[" {
return string(p.buffer)
}
return ""
}