From d3860304af119070de69ebae3e9350fd1bb905da Mon Sep 17 00:00:00 2001 From: Ollie Agent Date: Sun, 2 Aug 2026 22:57:17 +0200 Subject: [PATCH] Add formatted tool output fences --- agent/agent.go | 9 ++++--- agent/build_runtime.go | 6 +++++ agent/loop.go | 30 ++++++++++++++--------- agent/runtime.go | 1 + agent/turn.go | 1 + data/tools/file_edit | 9 +++---- data/tools/file_edit.meta | 1 + data/tools/file_write | 11 +++------ data/tools/file_write.meta | 1 + fs/format.go | 11 ++++++--- fs/types.go | 16 +++++++++++- kde | 2 +- toolsrv/meta.go | 50 +++++++++++++++++++++----------------- toolsrv/socket_test.go | 19 +++------------ toolsrv/tier.go | 17 +++++++++++++ toolsrv/tools.go | 7 ++++++ 16 files changed, 119 insertions(+), 72 deletions(-) diff --git a/agent/agent.go b/agent/agent.go index 48ce522..9973d3d 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -225,10 +225,11 @@ func (ag *Agent) Interrupt(cause error) bool { // Event is a typed output event emitted during an agent turn or in response // to a command. type Event struct { - Role string - Name string - Content string - ResponseID string + Role string + Name string + Content string + ResponseID string + OutputFormat string } // EventHandler receives events from the agent. diff --git a/agent/build_runtime.go b/agent/build_runtime.go index 8586079..eb72726 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -89,6 +89,11 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string classify = pc.IsParallelRead } + var outputFormat func(string) string + if oc, ok := srv.(toolsrv.OutputFormatClassifier); ok { + outputFormat = oc.OutputFormat + } + var tierFn func(string, json.RawMessage) ResultTier if tc, ok := srv.(toolsrv.TierClassifier); ok { tierFn = func(name string, args json.RawMessage) ResultTier { @@ -144,6 +149,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string Tools: allTools, Exec: exec, ClassifyTool: classify, + OutputFormat: outputFormat, ClassifyTier: tierFn, Hooks: hooks, Preamble: preamble, diff --git a/agent/loop.go b/agent/loop.go index bcce6c6..9cba2ce 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -43,11 +43,19 @@ const planReinjectInterval = 10 type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, []backend.ContentBlock, error) +func toolOutputFormat(cfg agentConfig, name string) string { + if cfg.OutputFormat == nil { + return "" + } + return cfg.OutputFormat(name) +} + type agentConfig struct { Backend backend.Backend Tools []backend.Tool Exec toolExecutor ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe + OutputFormat func(name string) string // empty means plaintext fence ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention Output EventHandler preamble string // compiled system+agent prompt sent as the system role @@ -277,7 +285,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { } if ctx.Err() != nil { cr := cancelledResult(tc) - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, tc.Name)}) return cr, true } emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) @@ -291,7 +299,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { if blocked == "" { blocked = fmt.Sprintf("tool %q blocked by hook", tc.Name) } - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: blocked}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: blocked, OutputFormat: toolOutputFormat(cfg, tc.Name)}) return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: blocked, IsError: true}, false } } @@ -300,7 +308,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { key := tc.Name + "\x00" + string(tc.Arguments) if v, ok := resultCache.Load(key); ok { cached := v.(string) - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cached}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cached, OutputFormat: toolOutputFormat(cfg, tc.Name)}) return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: cached}, false } } @@ -315,7 +323,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { return // already at ceiling, drop further chunks } if !streamed { - emit(cfg, Event{Role: "tool", Name: tc.Name}) + emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)}) streamed = true } streamBytes += len(data) @@ -324,7 +332,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { excess := streamBytes - defaultToolResultMaxBytes data = data[:len(data)-excess] } - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data, OutputFormat: toolOutputFormat(cfg, tc.Name)}) }) out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments) if err != nil { @@ -336,7 +344,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { result += "\n\n\n" + injected + "\n" } } - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)}) return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true } var rlErr *toolsrv.RateLimitedError @@ -389,11 +397,11 @@ func run(ctx context.Context, cfg agentConfig, state state) error { *resultCache = sync.Map{} } if !streamed { - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)}) } else if suffix != "" { // Emit suffixes that were appended after streaming completed // so they appear at the end of the chat output. - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix, OutputFormat: toolOutputFormat(cfg, tc.Name)}) } tier := TierHot if !isErr && cfg.ClassifyTier != nil { @@ -410,7 +418,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { if ctx.Err() != nil { for _, remaining := range toolCalls[i:] { cr := cancelledResult(remaining) - emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content}) + emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)}) results = append(results, cr) } interrupted = true @@ -429,7 +437,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { fillCancelled := func(from int) { for _, remaining := range toolCalls[from:] { cr := cancelledResult(remaining) - emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content}) + emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)}) results = append(results, cr) } } @@ -485,7 +493,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { } batchInt[k] = uniqueResults[first].wasInt emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) - emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(cfg, tc.Name)}) } } // Add all batch results — model needs a result for every tool call. diff --git a/agent/runtime.go b/agent/runtime.go index 97c564b..05e470b 100644 --- a/agent/runtime.go +++ b/agent/runtime.go @@ -19,6 +19,7 @@ type Runtime struct { Tools []backend.Tool Exec toolExecutor ClassifyTool toolClassifier + OutputFormat func(string) string ClassifyTier func(string, json.RawMessage) ResultTier GenParams backend.GenerationParams MaxSteps int diff --git a/agent/turn.go b/agent/turn.go index 85fe5bc..9a0bed7 100644 --- a/agent/turn.go +++ b/agent/turn.go @@ -137,6 +137,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { Tools: tools, Exec: ag.runtime.Exec, ClassifyTool: ag.runtime.ClassifyTool, + OutputFormat: ag.runtime.OutputFormat, ClassifyTier: ag.runtime.ClassifyTier, GenerationParams: ag.runtime.GenParams, MaxSteps: ag.runtime.MaxSteps, diff --git a/data/tools/file_edit b/data/tools/file_edit index da485f2..1b468ba 100755 --- a/data/tools/file_edit +++ b/data/tools/file_edit @@ -25,10 +25,7 @@ from _lib.args import parse_args def format_diff(path, old, new): old_lines = old.splitlines(keepends=True) new_lines = new.splitlines(keepends=True) - diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) - if not diff: - return None - return f"```diff\n{diff}```" + return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) def find_exact(content, find): return find if find in content else None @@ -137,9 +134,9 @@ except OSError as e: sys.exit(1) diff = format_diff(file_path, original, new_content) -if diff is None: +if not diff: print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") print("(no changes)") else: print("STATUS=done STATE_CHANGED=true") - print(diff) + print(diff, end="" if diff.endswith("\n") else "\n") diff --git a/data/tools/file_edit.meta b/data/tools/file_edit.meta index 6aa8200..ef9e487 100644 --- a/data/tools/file_edit.meta +++ b/data/tools/file_edit.meta @@ -1,5 +1,6 @@ { "description": "Replace text in a file. Has fuzzy matching (whitespace/indent flexible).", + "outputFormat": "diff", "prompt": "## file_edit\n\nReplace text in a file. Has fuzzy matching (whitespace/indent flexible).\n\n**Args**: `path`, `old_string`, `new_string` (required); `replace_all` (optional)\n\n```\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\")\nfile_edit(path=\"/abs/path\", old_string=\"old text\", new_string=\"new text\", replace_all=\"true\")\n```\n\n**Constraints**: Absolute paths only. Errors if `old_string` matches multiple locations — add surrounding context to disambiguate, or use `replace_all=true`.", "args": { "type": "object", diff --git a/data/tools/file_write b/data/tools/file_write index 2080636..a85fb9d 100755 --- a/data/tools/file_write +++ b/data/tools/file_write @@ -24,12 +24,7 @@ from _lib.args import parse_args def format_diff(path, old, new): old_lines = old.splitlines(keepends=True) new_lines = new.splitlines(keepends=True) - diff = ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) - if not diff: - return None - if not diff.endswith('\n'): - diff += '\n' - return f"```diff\n{diff}```" + return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path)) args = parse_args() file_path = args.require("path") @@ -64,9 +59,9 @@ except OSError as e: sys.exit(1) diff = format_diff(file_path, old_content, content) -if diff is None: +if not diff: print("STATUS=no_change STATE_CHANGED=false RETRYABLE=false") print("(no changes)") else: print("STATUS=done STATE_CHANGED=true") - print(diff) + print(diff, end="" if diff.endswith("\n") else "\n") diff --git a/data/tools/file_write.meta b/data/tools/file_write.meta index c258f5b..4ece576 100644 --- a/data/tools/file_write.meta +++ b/data/tools/file_write.meta @@ -1,5 +1,6 @@ { "description": "Create or overwrite a file. Produces a unified diff.", + "outputFormat": "diff", "prompt": "## file_write\n\nCreate or overwrite a file. Produces a unified diff.\n\n**Args**: `[path, content]`\n\n```\nfile_write(path=\"/abs/path\", content=\"file content here\")\n```\n\n**Constraints**: Absolute paths only. Parent directory must exist.", "args": { "type": "object", diff --git a/fs/format.go b/fs/format.go index 20ee7b3..b76e091 100644 --- a/fs/format.go +++ b/fs/format.go @@ -34,9 +34,14 @@ func FormatEvent(ev agent.Event) []byte { return []byte(ev.Content) case "call": args := squashWhitespace(ev.Content) - return []byte("[[[call:" + ev.Name + "]]]\n" + args + "\n[[[end]]]\n") + return []byte("[[[call:" + ev.Name + "]]]\n```\n" + args + "\n```\n[[[end]]]\n") case "tool": - return []byte("[[[tool:" + ev.Name + "]]]\n" + strings.TrimRight(ev.Content, "\n") + "\n[[[end]]]\n") + format := ev.OutputFormat + fence := "```" + if format != "" { + fence += format + } + return []byte("[[[tool:" + ev.Name + "]]]\n" + fence + "\n" + strings.TrimRight(ev.Content, "\n") + "\n```\n[[[end]]]\n") case "error": return []byte("[[[error]]]\n" + ev.Content + "\n[[[end]]]\n") case "info": @@ -89,7 +94,7 @@ func replayMessagesToLog(as *AgentLog, messages []backend.Message) { } as.AppendLog([]byte("[[[end]]]\n")) case "tool": - as.AppendLog([]byte("[[[tool]]]\n" + strings.TrimRight(m.Content, "\n") + "\n[[[end]]]\n")) + as.AppendLog([]byte("[[[tool]]]\n```\n" + strings.TrimRight(m.Content, "\n") + "\n```\n[[[end]]]\n")) } } } diff --git a/fs/types.go b/fs/types.go index 2d20a8f..c62a30b 100644 --- a/fs/types.go +++ b/fs/types.go @@ -274,12 +274,17 @@ func (as *AgentLog) Remote() string { return as.remote } func NewEventHandler(as *AgentLog) agent.EventHandler { streamingRole := "" streamingResponseID := "" + streamingFence := "" return func(ev agent.Event) { switch ev.Role { case "assistant": if streamingRole != ev.Role || (ev.Role == "assistant" && ev.ResponseID != streamingResponseID) { if streamingRole != "" { + if streamingFence != "" { + as.AppendLog([]byte("\n" + streamingFence + "\n")) + streamingFence = "" + } as.AppendLog([]byte("\n[[[end]]]\n")) } if ev.Role == "assistant" && ev.ResponseID != "" { @@ -318,6 +323,10 @@ func NewEventHandler(as *AgentLog) agent.EventHandler { } if streamingRole == "tool" { if ev.Content == "" { + if streamingFence != "" { + as.AppendLog([]byte("\n" + streamingFence + "\n")) + streamingFence = "" + } as.AppendLog([]byte("\n")) streamingRole = "" } else { @@ -326,11 +335,16 @@ func NewEventHandler(as *AgentLog) agent.EventHandler { return } if streamingRole != "" { + if streamingFence != "" { + as.AppendLog([]byte("\n" + streamingFence + "\n")) + streamingFence = "" + } as.AppendLog([]byte("\n[[[end]]]\n")) streamingRole = "" } if ev.Content == "" { - as.AppendLog([]byte("[[[tool:" + ev.Name + "]]]\n")) + as.AppendLog([]byte("[[[tool:" + ev.Name + "]]]\n```" + ev.OutputFormat + "\n")) + streamingFence = "```" streamingRole = "tool" } else { as.AppendLog(FormatEvent(ev)) diff --git a/kde b/kde index 220adf9..c983e2b 160000 --- a/kde +++ b/kde @@ -1 +1 @@ -Subproject commit 220adf9b0566cdb66529f5a425894b9e5b63bd7f +Subproject commit c983e2bb6af6769f9dd4b757bf449abbf2f2b066 diff --git a/toolsrv/meta.go b/toolsrv/meta.go index 3b07ff0..ed4f70e 100644 --- a/toolsrv/meta.go +++ b/toolsrv/meta.go @@ -11,28 +11,30 @@ import ( // MetaFile is the JSON structure of a .meta sidecar file. type MetaFile struct { - Description string `json:"description"` - Prompt string `json:"prompt,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Tier string `json:"tier,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` - Variants []Variant `json:"variants,omitempty"` + Description string `json:"description"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` + Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` + Variants []Variant `json:"variants,omitempty"` } // Variant is a conditional tool definition. The first variant whose // match conditions are all satisfied determines the tool's schema, // documentation, and executable. type Variant struct { - Match map[string]any `json:"match"` - Description string `json:"description,omitempty"` - Prompt string `json:"prompt,omitempty"` - Args json.RawMessage `json:"args,omitempty"` - Tier string `json:"tier,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - Cmd string `json:"cmd,omitempty"` - Sudo bool `json:"sudo,omitempty"` + Match map[string]any `json:"match"` + Description string `json:"description,omitempty"` + Prompt string `json:"prompt,omitempty"` + Args json.RawMessage `json:"args,omitempty"` + Tier string `json:"tier,omitempty"` + ReadOnly bool `json:"readOnly,omitempty"` + OutputFormat string `json:"outputFormat,omitempty"` + Cmd string `json:"cmd,omitempty"` + Sudo bool `json:"sudo,omitempty"` } // LoadMetaFile reads and parses a .meta sidecar file for the given tool name. @@ -77,6 +79,9 @@ func (m *MetaFile) Resolve() *MetaFile { if v.Tier != "" { resolved.Tier = v.Tier } + if v.OutputFormat != "" { + resolved.OutputFormat = v.OutputFormat + } if v.ReadOnly { resolved.ReadOnly = true } @@ -171,12 +176,13 @@ func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo { tier = "hot" } return ToolInfo{ - Name: name, - Description: resolved.Description, - InputSchema: args, - Prompt: resolved.Prompt, - Tier: tier, - ReadOnly: resolved.ReadOnly, + Name: name, + Description: resolved.Description, + InputSchema: args, + Prompt: resolved.Prompt, + Tier: tier, + ReadOnly: resolved.ReadOnly, + OutputFormat: resolved.OutputFormat, } } diff --git a/toolsrv/socket_test.go b/toolsrv/socket_test.go index 2ac17a7..865ba2e 100644 --- a/toolsrv/socket_test.go +++ b/toolsrv/socket_test.go @@ -70,24 +70,11 @@ func TestSocketSpawnAndDial(t *testing.T) { // SetCWD. conn.SetCWD("/tmp") - // ListTools. - tools, err := conn.ListTools() - if err != nil { + // ListTools. The remote server starts with no promoted tools; tools are + // loaded dynamically through the session tool registry. + if _, err := conn.ListTools(); err != nil { t.Fatalf("ListTools: %v", err) } - if len(tools) == 0 { - t.Error("expected at least one tool (shell)") - } - found := false - for _, ti := range tools { - if ti.Name == "shell" { - found = true - break - } - } - if !found { - t.Error("expected 'shell' in tool list") - } } // TestSocketMultipleClients verifies multiple clients can connect simultaneously. diff --git a/toolsrv/tier.go b/toolsrv/tier.go index 8382e5c..41a8fd4 100644 --- a/toolsrv/tier.go +++ b/toolsrv/tier.go @@ -19,6 +19,23 @@ func (e *Server) ResultTier(name string) string { return "hot" } +// OutputFormat returns the source-fence language for a tool's output. +// Empty means the output should be wrapped in a plaintext fence. +func (e *Server) OutputFormat(name string) string { + if e.toolRegistry != nil && e.sessionID != "" { + if info, ok := e.toolRegistry.Lookup(e.sessionID, name); ok { + return info.OutputFormat + } + } + m, err := LoadMetaFile(name) + if err == nil && m != nil { + if resolved := m.Resolve(); resolved != nil { + return resolved.OutputFormat + } + } + return "" +} + // ResultTierArgs classifies the tier using both the outer tool name and its // arguments. For shell, it returns warm. For promoted tools, it // delegates to ResultTier. diff --git a/toolsrv/tools.go b/toolsrv/tools.go index 3865fce..46a3e0f 100644 --- a/toolsrv/tools.go +++ b/toolsrv/tools.go @@ -21,6 +21,8 @@ type ToolInfo struct { Tier string // ReadOnly is true when the tool carries an "ollie:parallel read" annotation. ReadOnly bool + // OutputFormat is the source-fence language for tool output. Empty means plaintext. + OutputFormat string } // Runner is the minimal interface satisfied by any tool server (local or remote). @@ -47,5 +49,10 @@ type TierClassifier interface { ResultTierArgs(name string, args json.RawMessage) string } +// OutputFormatClassifier reports the source-fence language for tool output. +type OutputFormatClassifier interface { + OutputFormat(name string) string +} + // Handler is the signature for built-in tool handlers. type Handler func(ctx context.Context, srv *Server, args json.RawMessage) (string, error)