Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,15 +133,43 @@ func (t *ToolDefinition[T]) Execute(args map[string]any) (string, any, error) {

type Tools []ToolDefinitionInterface

// Find returns the tool whose function name matches name. It first tries an
// exact match (fast path, unchanged behavior). If none is found, it falls back
// to a lenient match: local models frequently emit namespaced/case/separator
// variants (e.g. "functions.foo", "tools/foo", "Foo", "foo-bar" for "foo_bar").
// The lenient pass never overrides an exact match, so existing resolutions are
// unaffected.
func (t Tools) Find(name string) ToolDefinitionInterface {
for _, tool := range t {
if tool.Tool().Function.Name == name {
return tool
}
}
norm := normalizeToolName(name)
if norm == "" {
return nil
}
for _, tool := range t {
if normalizeToolName(tool.Tool().Function.Name) == norm {
return tool
}
}
return nil
}

// normalizeToolName reduces a tool name to a comparison key: it drops a leading
// namespace segment ("functions.", "tools/", "namespace::"), lower-cases, trims
// spaces, and treats '-' and '_' as equivalent. Used only as a lenient fallback
// in Find; it never affects exact-match resolution.
func normalizeToolName(s string) string {
if i := strings.LastIndexAny(s, "./:"); i >= 0 {
s = s[i+1:] // segment after the last separator (may be empty → empty key)
}
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "-", "_")
return s
}

func (t Tools) ToOpenAI() []openai.Tool {
openaiTools := []openai.Tool{}
for _, tool := range t {
Expand Down Expand Up @@ -821,7 +849,7 @@ func pickTool(ctx context.Context, llm LLM, fragment Fragment, tools Tools, opts
chosenTool := tools.Find(intentionResponse.Tool)
if chosenTool == nil {
xlog.Debug("[pickTool] Chosen tool not found", "tool", intentionResponse.Tool)
return nil, fmt.Errorf("chosen tool not found")
return nil, fmt.Errorf("chosen tool %q not found", intentionResponse.Tool)
}

toolChoices = append(toolChoices, &ToolChoice{
Expand Down
61 changes: 61 additions & 0 deletions tools_lenient_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package cogito

import "testing"

func TestNormalizeToolName(t *testing.T) {
cases := []struct{ in, want string }{
{"foo", "foo"},
{"Foo", "foo"},
{"functions.foo", "foo"},
{"tools/foo", "foo"},
{"namespace::foo", "foo"},
{"foo-bar", "foo_bar"},
{"Foo-Bar", "foo_bar"},
{"functions.Coach_Regeln", "coach_regeln"},
{" foo ", "foo"},
{"", ""},
{"functions.", ""}, // nothing after the separator → empty key
}
for _, c := range cases {
if got := normalizeToolName(c.in); got != c.want {
t.Errorf("normalizeToolName(%q) = %q, want %q", c.in, got, c.want)
}
}
}

func TestFindLenient(t *testing.T) {
tools := Tools{newNamedTool("coach_regeln")}

// exact + lenient variants local models routinely emit
for _, n := range []string{
"coach_regeln", // exact
"functions.coach_regeln", // namespaced
"tools/coach_regeln",
"Coach_Regeln", // case
"coach-regeln", // separator
} {
if tools.Find(n) == nil {
t.Errorf("Find(%q) = nil, want match", n)
}
}

for _, n := range []string{"other_tool", "", "functions."} {
if tools.Find(n) != nil {
t.Errorf("Find(%q) matched, want nil", n)
}
}
}

// An exact match must always win, even when a lenient candidate appears earlier
// in the slice — the lenient pass is a fallback, never an override.
func TestFindExactWinsOverLenient(t *testing.T) {
tools := Tools{newNamedTool("functions.foo"), newNamedTool("foo")}
got := tools.Find("foo")
if got == nil || got.Tool().Function.Name != "foo" {
name := "<nil>"
if got != nil {
name = got.Tool().Function.Name
}
t.Errorf(`Find("foo") = %q, want exact "foo"`, name)
}
}