Add formatted tool output fences

This commit is contained in:
Ollie Agent 2026-08-02 22:57:17 +02:00
parent 2d621d26b9
commit d3860304af
16 changed files with 119 additions and 72 deletions

View File

@ -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.

View File

@ -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,

View File

@ -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<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
}
}
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.

View File

@ -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

View File

@ -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,

View File

@ -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")

View File

@ -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",

View File

@ -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")

View File

@ -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",

View File

@ -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"))
}
}
}

View File

@ -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))

2
kde

@ -1 +1 @@
Subproject commit 220adf9b0566cdb66529f5a425894b9e5b63bd7f
Subproject commit c983e2bb6af6769f9dd4b757bf449abbf2f2b066

View File

@ -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,
}
}

View File

@ -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.

View File

@ -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.

View File

@ -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)