diff --git a/cagent-schema.json b/cagent-schema.json index fd9774115..49bcd95e9 100644 --- a/cagent-schema.json +++ b/cagent-schema.json @@ -332,6 +332,10 @@ "type": "string", "description": "Additional instruction on how to use this toolset" }, + "toon": { + "type": "string", + "description": "A comma-delimited list of regular expressions of tools to toonify" + }, "ref": { "type": "string", "description": "Reference to external tool (e.g., docker:context7)", diff --git a/examples/github-toon.yaml b/examples/github-toon.yaml new file mode 100644 index 000000000..4a8958806 --- /dev/null +++ b/examples/github-toon.yaml @@ -0,0 +1,12 @@ +#!/usr/bin/env cagent run +version: "2" + +agents: + root: + model: anthropic/claude-sonnet-4-0 + description: GitHub Agent - Help with GitHub using MCP tools + instruction: You are a helpful assistant that can help with all things GitHub + toolsets: + - type: mcp + ref: docker:github-official + toon: ".*" # toonify all the tools diff --git a/go.mod b/go.mod index e7044f9ee..36d7f78bd 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,7 @@ require ( cloud.google.com/go/auth v0.16.5 // indirect cloud.google.com/go/compute/metadata v0.8.0 // indirect github.com/JohannesKaufmann/dom v0.2.0 // indirect + github.com/alpkeskin/gotoon v0.1.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect diff --git a/go.sum b/go.sum index dc6d2e6e3..ac35055e4 100644 --- a/go.sum +++ b/go.sum @@ -22,6 +22,8 @@ github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NT github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA= github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg= github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alpkeskin/gotoon v0.1.0 h1:qtKx9kqpTYycxEolgHctyOmu2L5CUNwEeXfMizscO/k= +github.com/alpkeskin/gotoon v0.1.0/go.mod h1:eCkjhBz/wmCoXAWKERuhPSb3+jW7ajluruYIgsfbriU= github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= diff --git a/pkg/config/v2/types.go b/pkg/config/v2/types.go index 6028e9217..bd377e0dd 100644 --- a/pkg/config/v2/types.go +++ b/pkg/config/v2/types.go @@ -98,6 +98,7 @@ type Toolset struct { Type string `json:"type,omitempty"` Tools []string `json:"tools,omitempty"` Instruction string `json:"instruction,omitempty"` + Toon string `json:"toon,omitempty"` // For the `mcp` tool Command string `json:"command,omitempty"` diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index fe55a6381..627f08c16 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -438,6 +438,7 @@ func getToolsForAgent(ctx context.Context, a *latest.AgentConfig, parentDir stri wrapped := WithToolsFilter(tool, toolset.Tools...) wrapped = WithInstructions(wrapped, toolset.Instruction) + wrapped = WithToon(wrapped, toolset.Toon) toolSets = append(toolSets, wrapped) } diff --git a/pkg/teamloader/toon.go b/pkg/teamloader/toon.go new file mode 100644 index 000000000..3cb333f89 --- /dev/null +++ b/pkg/teamloader/toon.go @@ -0,0 +1,73 @@ +package teamloader + +import ( + "context" + "encoding/json" + "regexp" + "strings" + + "github.com/alpkeskin/gotoon" + + "github.com/docker/cagent/pkg/tools" +) + +type toonTools struct { + tools.ToolSet + toolRegexps []*regexp.Regexp +} + +func (f *toonTools) Tools(ctx context.Context) ([]tools.Tool, error) { + allTools, err := f.ToolSet.Tools(ctx) + if err != nil { + return nil, err + } + + for i, tool := range allTools { + for _, regex := range f.toolRegexps { + if !regex.MatchString(tool.Name) { + continue + } + + handler := tool.Handler + tool.Handler = func(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { + res, err := handler(ctx, toolCall) + if err != nil { + return res, err + } + + var o map[string]any + err = json.Unmarshal([]byte(res.Output), &o) + if err != nil { + return res, nil + } + + tooned, err := gotoon.Encode(o) + if err != nil { + return res, err + } + + res.Output = tooned + return res, nil + } + allTools[i] = tool + } + } + + return allTools, nil +} + +func WithToon(inner tools.ToolSet, toon string) tools.ToolSet { + if toon == "" { + return inner + } + + var toolRegexps []*regexp.Regexp + + for toolName := range strings.SplitSeq(toon, ",") { + toolRegexps = append(toolRegexps, regexp.MustCompile(strings.TrimSpace(toolName))) + } + return &toonTools{ + ToolSet: inner, + toolRegexps: toolRegexps, + } +} diff --git a/pkg/teamloader/toon_test.go b/pkg/teamloader/toon_test.go new file mode 100644 index 000000000..56768d10c --- /dev/null +++ b/pkg/teamloader/toon_test.go @@ -0,0 +1,74 @@ +package teamloader + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/tools" +) + +func mockHandler(output string) tools.ToolHandler { + return func(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { + return &tools.ToolCallResult{ + Output: output, + }, nil + } +} + +func TestToon(t *testing.T) { + testcases := []struct { + name string + toolResult string + expected string + filter string + }{ + { + name: "should return a toon representation of a json response", + toolResult: `{"key": "value", "number": 42}`, + expected: "key: value\nnumber: 42", + }, + { + name: "should return originial if not a json", + toolResult: "plain text output", + expected: "plain text output", + }, + { + name: "should return original if not toon-ed", + toolResult: `{"key": "value", "number": 42}`, + expected: `{"key": "value", "number": 42}`, + filter: "other_tool", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + inner := &mockToolSet{ + toolsFunc: func(ctx context.Context) ([]tools.Tool, error) { + return []tools.Tool{ + { + Name: "test_tool", + Handler: mockHandler(tc.toolResult), + }, + }, nil + }, + } + toolFilter := "test_tool" + if tc.filter != "" { + toolFilter = tc.filter + } + wrapped := WithToon(inner, toolFilter) + + resultTools, err := wrapped.Tools(t.Context()) + require.NoError(t, err) + require.Len(t, resultTools, 1) + + result, err := resultTools[0].Handler(t.Context(), tools.ToolCall{}) + require.NoError(t, err) + assert.Equal(t, tc.expected, result.Output) + }) + } +} diff --git a/pkg/tui/components/tool/tool.go b/pkg/tui/components/tool/tool.go index f9717501c..1b502bcd0 100644 --- a/pkg/tui/components/tool/tool.go +++ b/pkg/tui/components/tool/tool.go @@ -3,7 +3,6 @@ package tool import ( "encoding/json" "fmt" - "log/slog" "strings" "github.com/charmbracelet/bubbles/v2/spinner" @@ -96,8 +95,6 @@ func (mv *toolModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (mv *toolModel) View() string { msg := mv.message - slog.Debug("Rendering tool message", "status", msg.ToolStatus, "content", msg.Content, "args", msg.ToolCall.Function.Arguments) - slog.Debug("Tool definition", "name", msg.ToolDefinition.Name, "title", msg.ToolDefinition.Annotations.Title) displayName := msg.ToolDefinition.DisplayName() content := fmt.Sprintf("%s %s", icon(msg.ToolStatus), styles.HighlightStyle.Render(displayName))