From 52107db3d76594abd7ff092f26b9b88a8159deb4 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sat, 11 Apr 2026 23:51:07 +0800 Subject: [PATCH 1/3] =?UTF-8?q?pref(tools):=E4=BC=98=E5=8C=96tools?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BB=A5=E5=8F=8A?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/prompt.go | 7 ++- internal/context/prompt_test.go | 22 ++++++++- internal/runtime/runtime.go | 2 +- internal/runtime/runtime_test.go | 21 ++++++-- internal/tools/format.go | 85 +++++++++++++++++++++++++++++++- internal/tools/format_test.go | 63 +++++++++++++++++++++++ 6 files changed, 191 insertions(+), 9 deletions(-) diff --git a/internal/context/prompt.go b/internal/context/prompt.go index dc32245b..f0539221 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -15,15 +15,20 @@ var defaultPromptSections = []promptSection{ }, { title: "Tool Usage", - content: "- Use tools when they reduce uncertainty or are required to complete the task safely.\n" + + content: "- Use the minimum set of tools needed to make progress or verify a result safely.\n" + "- For risky operations, call the relevant tool first and let the runtime permission layer decide ask/allow/deny.\n" + "- Do not self-reject a user-requested operation before attempting the proper tool call and permission flow.\n" + + "- Read tool results carefully before acting. Treat `status`, `truncated`, `tool_call_id`, `meta.*`, and `content` as the authoritative outcome of that call.\n" + + "- Do not repeat the same tool call with identical arguments unless the workspace changed or the prior result was errored, truncated, or clearly incomplete.\n" + + "- After a successful write or edit, do at most one focused verification call; if that verifies the change, stop calling tools and respond.\n" + + "- If a successful tool result already answers the question or confirms completion, stop using tools and give the user the result.\n" + "- Stay within the current workspace unless the user clearly asks for something else.\n" + "- Do not claim work is done unless the needed files, commands, or verification actually succeeded.", }, { title: "Failure Recovery", content: "- If blocked, identify the concrete blocker and try the next reasonable path before giving up.\n" + + "- When retrying, change something concrete: use different arguments, a different tool, or explain why further tool calls would not help.\n" + "- Surface risky assumptions, partial progress, or missing verification instead of hiding them.\n" + "- When constraints prevent completion, return the best safe result and explain what remains.", }, diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index 1eb0b729..c9455c99 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -94,15 +94,18 @@ func TestComposeSystemPromptSkipsEmptySections(t *testing.T) { } } -func TestDefaultToolUsagePromptEncouragesAskFlow(t *testing.T) { +func TestDefaultToolUsagePromptIncludesPermissionAndAntiLoopGuidance(t *testing.T) { t.Parallel() sections := defaultSystemPromptSections() var toolUsage string + var failureRecovery string for _, section := range sections { if section.title == "Tool Usage" { toolUsage = section.content - break + } + if section.title == "Failure Recovery" { + failureRecovery = section.content } } if toolUsage == "" { @@ -114,4 +117,19 @@ func TestDefaultToolUsagePromptEncouragesAskFlow(t *testing.T) { if !strings.Contains(toolUsage, "Do not self-reject") { t.Fatalf("expected Tool Usage to discourage self-reject, got %q", toolUsage) } + if !strings.Contains(toolUsage, "Do not repeat the same tool call with identical arguments") { + t.Fatalf("expected Tool Usage to include anti-loop guidance, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "focused verification call") { + t.Fatalf("expected Tool Usage to limit write verification retries, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "stop using tools and give the user the result") { + t.Fatalf("expected Tool Usage to tell the agent when to stop, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "`status`, `truncated`, `tool_call_id`, `meta.*`, and `content`") { + t.Fatalf("expected Tool Usage to explain structured tool results, got %q", toolUsage) + } + if !strings.Contains(failureRecovery, "change something concrete") { + t.Fatalf("expected Failure Recovery to discourage identical retries, got %q", failureRecovery) + } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 7b3ff365..420787ce 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -366,7 +366,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { toolMessage := providertypes.Message{ Role: providertypes.RoleTool, - Content: result.Content, + Content: tools.FormatToolResultForModel(result), ToolCallID: call.ID, IsError: result.IsError, } diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 2cf84928..ee3feb17 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -380,6 +380,7 @@ func TestServiceRun(t *testing.T) { name: "filesystem_edit", content: "tool output", }, + contextBuilder: &stubContextBuilder{}, expectProviderCalls: 2, expectToolCalls: 1, expectMessageRoles: []string{"user", "assistant", "tool", "assistant"}, @@ -401,7 +402,12 @@ func TestServiceRun(t *testing.T) { second := scripted.requests[1] foundToolResult := false for _, message := range second.Messages { - if message.Role == "tool" && message.ToolCallID == "call-1" && message.Content == "tool output" { + if message.Role == "tool" && + message.ToolCallID == "call-1" && + strings.Contains(message.Content, "tool result") && + strings.Contains(message.Content, "tool: filesystem_edit") && + strings.Contains(message.Content, "status: ok") && + strings.Contains(message.Content, "content:\ntool output") { foundToolResult = true break } @@ -901,7 +907,7 @@ func TestServiceRunDefaultBuilderUsesGenericToolManagerMicroCompactPolicies(t *t }}, } - service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) if err := service.Run(context.Background(), UserInput{ SessionID: session.ID, RunID: "run-preserve-history-generic-manager", @@ -986,6 +992,9 @@ func TestServiceRunUsesToolManager(t *testing.T) { result: tools.ToolResult{ Name: "filesystem_edit", Content: "tool manager output", + Metadata: map[string]any{ + "path": "main.go", + }, }, } @@ -999,7 +1008,7 @@ func TestServiceRunUsesToolManager(t *testing.T) { }, } - service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, nil) + service := NewWithFactory(manager, toolManager, store, &scriptedProviderFactory{provider: scripted}, &stubContextBuilder{}) if err := service.Run(context.Background(), UserInput{RunID: "run-tool-manager", Content: "edit file"}); err != nil { t.Fatalf("Run() error = %v", err) } @@ -1020,7 +1029,11 @@ func TestServiceRunUsesToolManager(t *testing.T) { session := onlySession(t, store) foundToolMessage := false for _, message := range session.Messages { - if message.Role == providertypes.RoleTool && message.Content == "tool manager output" { + if message.Role == providertypes.RoleTool && + strings.Contains(message.Content, "tool result") && + strings.Contains(message.Content, "tool: filesystem_edit") && + strings.Contains(message.Content, "meta.path: main.go") && + strings.Contains(message.Content, "content:\ntool manager output") { foundToolMessage = true break } diff --git a/internal/tools/format.go b/internal/tools/format.go index ae404b7e..891c0b1b 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -1,6 +1,11 @@ package tools -import "strings" +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) const ( // DefaultOutputLimitBytes is the default max size of tool output content. @@ -27,6 +32,34 @@ func ApplyOutputLimit(result ToolResult, limit int) ToolResult { return result } +// FormatToolResultForModel 将工具执行结果渲染为稳定的结构化文本,便于模型准确消费回灌信息。 +func FormatToolResultForModel(result ToolResult) string { + lines := []string{"tool result"} + + if toolName := strings.TrimSpace(result.Name); toolName != "" { + lines = append(lines, "tool: "+toolName) + } + + status := "ok" + if result.IsError { + status = "error" + } + lines = append(lines, "status: "+status) + + if toolCallID := strings.TrimSpace(result.ToolCallID); toolCallID != "" { + lines = append(lines, "tool_call_id: "+toolCallID) + } + + lines = append(lines, fmt.Sprintf("truncated: %t", toolResultTruncated(result.Metadata))) + lines = append(lines, formatToolResultMetadataLines(result.Metadata)...) + + if strings.TrimSpace(result.Content) != "" { + lines = append(lines, "", "content:", result.Content) + } + + return strings.Join(lines, "\n") +} + // FormatError builds a consistent error payload for tool failures. func FormatError(toolName string, reason string, details string) string { toolName = strings.TrimSpace(toolName) @@ -74,3 +107,53 @@ func NewErrorResult(toolName string, reason string, details string, metadata map Metadata: metadata, } } + +// toolResultTruncated 从 metadata 中提取统一的截断标记,缺省时返回 false。 +func toolResultTruncated(metadata map[string]any) bool { + if metadata == nil { + return false + } + truncated, _ := metadata["truncated"].(bool) + return truncated +} + +// formatToolResultMetadataLines 以稳定顺序输出供模型消费的 metadata 行,并跳过已提升为顶层字段的键。 +func formatToolResultMetadataLines(metadata map[string]any) []string { + if len(metadata) == 0 { + return nil + } + + keys := make([]string, 0, len(metadata)) + for key := range metadata { + key = strings.TrimSpace(key) + if key == "" || key == "truncated" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, "meta."+key+": "+formatToolResultValue(metadata[key])) + } + return lines +} + +// formatToolResultValue 将 metadata 值收敛为单行文本,避免破坏工具结果包络格式。 +func formatToolResultValue(value any) string { + switch v := value.(type) { + case nil: + return "null" + case string: + return strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\n").Replace(v) + case fmt.Stringer: + return strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\n").Replace(v.String()) + } + + payload, err := json.Marshal(value) + if err == nil { + return string(payload) + } + return fmt.Sprint(value) +} diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index 06f7428e..c463941c 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -186,3 +186,66 @@ func TestFormatHelpers(t *testing.T) { }) } } + +func TestFormatToolResultForModel(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result ToolResult + want []string + }{ + { + name: "formats success result with stable metadata and content", + result: ToolResult{ + ToolCallID: "call-1", + Name: "filesystem_edit", + Content: "ok", + Metadata: map[string]any{ + "path": "internal/context/prompt.go", + "replacement": "line 1\nline 2", + "search_length": 12, + "truncated": true, + }, + }, + want: []string{ + "tool result", + "tool: filesystem_edit", + "status: ok", + "tool_call_id: call-1", + "truncated: true", + "meta.path: internal/context/prompt.go", + "meta.replacement: line 1\\nline 2", + "meta.search_length: 12", + "\ncontent:\nok", + }, + }, + { + name: "formats error result without content block when empty", + result: ToolResult{ + Name: "bash", + IsError: true, + }, + want: []string{ + "tool result", + "tool: bash", + "status: error", + "truncated: false", + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := FormatToolResultForModel(tt.result) + for _, fragment := range tt.want { + if !strings.Contains(got, fragment) { + t.Fatalf("expected formatted result to contain %q, got %q", fragment, got) + } + } + }) + } +} From e16845e9e87ad72f80306f78054792a49eb5d736 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 00:11:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?pref(tools):=E4=BC=98=E5=8C=96tools?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/prompt.go | 4 ++++ internal/context/prompt_test.go | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/internal/context/prompt.go b/internal/context/prompt.go index f0539221..46004d58 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -16,6 +16,10 @@ var defaultPromptSections = []promptSection{ { title: "Tool Usage", content: "- Use the minimum set of tools needed to make progress or verify a result safely.\n" + + "- Only call tools that are actually exposed in the current tool schema. Do not invent tool names.\n" + + "- Prefer structured workspace tools over `bash` whenever possible: use `filesystem_read_file`, `filesystem_grep`, and `filesystem_glob` for reading/search, `filesystem_edit` for precise edits, and `filesystem_write_file` only for new files or full rewrites.\n" + + "- Do not use `bash` to edit files when the filesystem tools can make the change safely.\n" + + "- When using `bash`, avoid interactive or blocking commands and pass non-interactive flags when they are available.\n" + "- For risky operations, call the relevant tool first and let the runtime permission layer decide ask/allow/deny.\n" + "- Do not self-reject a user-requested operation before attempting the proper tool call and permission flow.\n" + "- Read tool results carefully before acting. Treat `status`, `truncated`, `tool_call_id`, `meta.*`, and `content` as the authoritative outcome of that call.\n" + diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index c9455c99..f5d5d210 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -114,6 +114,21 @@ func TestDefaultToolUsagePromptIncludesPermissionAndAntiLoopGuidance(t *testing. if !strings.Contains(toolUsage, "permission layer") { t.Fatalf("expected Tool Usage to mention permission layer, got %q", toolUsage) } + if !strings.Contains(toolUsage, "Do not invent tool names") { + t.Fatalf("expected Tool Usage to forbid invented tool names, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "`filesystem_read_file`, `filesystem_grep`, and `filesystem_glob`") { + t.Fatalf("expected Tool Usage to prefer structured read/search tools, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "`filesystem_edit` for precise edits") { + t.Fatalf("expected Tool Usage to describe edit tool preference, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "Do not use `bash` to edit files") { + t.Fatalf("expected Tool Usage to discourage bash file edits, got %q", toolUsage) + } + if !strings.Contains(toolUsage, "avoid interactive or blocking commands") { + t.Fatalf("expected Tool Usage to constrain bash usage, got %q", toolUsage) + } if !strings.Contains(toolUsage, "Do not self-reject") { t.Fatalf("expected Tool Usage to discourage self-reject, got %q", toolUsage) } From 7b5c229d28791be6a1dfaffcba3e10d309088603 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Sun, 12 Apr 2026 00:21:43 +0800 Subject: [PATCH 3/3] =?UTF-8?q?pref(tools):=E4=BF=AE=E6=94=B9tool=20?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=9B=B4=E6=8E=A5=E5=8C=85=E8=A3=85=E5=90=8E?= =?UTF-8?q?=E5=AD=98=E8=BF=9B=E4=BC=9A=E8=AF=9D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 29 +++++- internal/context/builder_test.go | 35 +++++++ internal/context/compact/helpers.go | 6 ++ internal/context/microcompact.go | 6 ++ internal/provider/types/message.go | 11 +- internal/provider/types/types_test.go | 13 +-- internal/runtime/runtime.go | 9 +- internal/runtime/runtime_test.go | 39 ++++++- internal/session/store_test.go | 18 +++- internal/tools/format.go | 140 +++++++++++++++++++++----- internal/tools/format_test.go | 124 ++++++++++++++++------- 11 files changed, 341 insertions(+), 89 deletions(-) diff --git a/internal/context/builder.go b/internal/context/builder.go index 8a17ba36..868c64f1 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -2,8 +2,10 @@ package context import ( "context" + "strings" providertypes "neo-code/internal/provider/types" + "neo-code/internal/tools" ) // DefaultBuilder preserves the current runtime context-building behavior. @@ -64,8 +66,31 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu // applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。 func applyReadTimeContextProjection(messages []providertypes.Message, options CompactOptions, policies MicroCompactPolicySource) []providertypes.Message { + var projected []providertypes.Message if options.DisableMicroCompact { - return cloneContextMessages(messages) + projected = cloneContextMessages(messages) + } else { + projected = microCompactMessagesWithPolicies(messages, policies) } - return microCompactMessagesWithPolicies(messages, policies) + return projectToolMessagesForModel(projected) +} + +// projectToolMessagesForModel 仅在 provider 读取路径上格式化 tool 消息,避免污染持久化会话内容。 +func projectToolMessagesForModel(messages []providertypes.Message) []providertypes.Message { + for i := range messages { + message := messages[i] + if message.Role != providertypes.RoleTool { + continue + } + if len(message.ToolMetadata) == 0 { + continue + } + content := strings.TrimSpace(message.Content) + if content == "" || content == microCompactClearedMessage { + continue + } + messages[i].Content = tools.FormatToolMessageForModel(message) + messages[i].ToolMetadata = nil + } + return messages } diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index 23dfa8c6..9b92ee5d 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -630,3 +630,38 @@ func TestBuildShouldAutoCompactAboveThreshold(t *testing.T) { t.Fatalf("expected ShouldAutoCompact true when tokens above threshold") } } + +func TestApplyReadTimeContextProjectionFormatsToolMessagesWithoutMutatingSessionState(t *testing.T) { + t.Parallel() + + original := []providertypes.Message{ + {Role: providertypes.RoleUser, Content: "edit this"}, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "ok", + ToolMetadata: map[string]string{ + "tool_name": "filesystem_edit", + "path": "main.go", + "search_length": "12", + }, + }, + } + + got := applyReadTimeContextProjection(original, CompactOptions{}, nil) + if len(got) != len(original) { + t.Fatalf("expected projected messages to keep length, got %d", len(got)) + } + if !strings.Contains(got[1].Content, "tool result") || !strings.Contains(got[1].Content, "tool: filesystem_edit") { + t.Fatalf("expected tool message to be projected for model, got %q", got[1].Content) + } + if got[1].ToolMetadata != nil { + t.Fatalf("expected projected provider message to clear tool metadata, got %+v", got[1].ToolMetadata) + } + if original[1].Content != "ok" { + t.Fatalf("expected original session message to keep raw content, got %q", original[1].Content) + } + if original[1].ToolMetadata["path"] != "main.go" { + t.Fatalf("expected original tool metadata to remain intact, got %+v", original[1].ToolMetadata) + } +} diff --git a/internal/context/compact/helpers.go b/internal/context/compact/helpers.go index a66ce845..12e6d506 100644 --- a/internal/context/compact/helpers.go +++ b/internal/context/compact/helpers.go @@ -15,6 +15,12 @@ func cloneMessages(messages []providertypes.Message) []providertypes.Message { for _, message := range messages { next := message next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) + if len(message.ToolMetadata) > 0 { + next.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) + for key, value := range message.ToolMetadata { + next.ToolMetadata[key] = value + } + } out = append(out, next) } return out diff --git a/internal/context/microcompact.go b/internal/context/microcompact.go index a881a8b9..ab171d19 100644 --- a/internal/context/microcompact.go +++ b/internal/context/microcompact.go @@ -72,6 +72,12 @@ func cloneContextMessages(messages []providertypes.Message) []providertypes.Mess for _, message := range messages { next := message next.ToolCalls = append([]providertypes.ToolCall(nil), message.ToolCalls...) + if len(message.ToolMetadata) > 0 { + next.ToolMetadata = make(map[string]string, len(message.ToolMetadata)) + for key, value := range message.ToolMetadata { + next.ToolMetadata[key] = value + } + } cloned = append(cloned, next) } return cloned diff --git a/internal/provider/types/message.go b/internal/provider/types/message.go index 3758c99f..0cfa4526 100644 --- a/internal/provider/types/message.go +++ b/internal/provider/types/message.go @@ -14,11 +14,12 @@ const RoleTool = "tool" // Message 表示对话中的单条消息。 type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` - IsError bool `json:"is_error,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` + IsError bool `json:"is_error,omitempty"` + ToolMetadata map[string]string `json:"tool_metadata,omitempty"` } // ToolCall 表示模型发起的工具调用请求。 diff --git a/internal/provider/types/types_test.go b/internal/provider/types/types_test.go index a30d41da..98292736 100644 --- a/internal/provider/types/types_test.go +++ b/internal/provider/types/types_test.go @@ -165,14 +165,15 @@ func TestMessageStructFields(t *testing.T) { t.Parallel() msg := Message{ - Role: RoleUser, - Content: "hello", - ToolCalls: []ToolCall{{ID: "t1"}}, - ToolCallID: "tc_1", - IsError: true, + Role: RoleUser, + Content: "hello", + ToolCalls: []ToolCall{{ID: "t1"}}, + ToolCallID: "tc_1", + IsError: true, + ToolMetadata: map[string]string{"path": "main.go"}, } if msg.Role != RoleUser || msg.Content != "hello" || len(msg.ToolCalls) != 1 || - msg.ToolCallID != "tc_1" || !msg.IsError { + msg.ToolCallID != "tc_1" || !msg.IsError || msg.ToolMetadata["path"] != "main.go" { t.Fatalf("message fields not as expected: %+v", msg) } } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 420787ce..bdbdf14a 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -365,10 +365,11 @@ func (s *Service) Run(ctx context.Context, input UserInput) error { } toolMessage := providertypes.Message{ - Role: providertypes.RoleTool, - Content: tools.FormatToolResultForModel(result), - ToolCallID: call.ID, - IsError: result.IsError, + Role: providertypes.RoleTool, + Content: result.Content, + ToolCallID: call.ID, + IsError: result.IsError, + ToolMetadata: tools.SanitizeToolMetadata(result.Name, result.Metadata), } session.Messages = append(session.Messages, toolMessage) session.UpdatedAt = time.Now() diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index ee3feb17..591b9afa 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -380,7 +380,14 @@ func TestServiceRun(t *testing.T) { name: "filesystem_edit", content: "tool output", }, - contextBuilder: &stubContextBuilder{}, + contextBuilder: &stubContextBuilder{ + buildFn: func(ctx context.Context, input agentcontext.BuildInput) (agentcontext.BuildResult, error) { + return agentcontext.BuildResult{ + SystemPrompt: "stub system prompt", + Messages: projectToolMessagesForProviderTest(input.Messages), + }, nil + }, + }, expectProviderCalls: 2, expectToolCalls: 1, expectMessageRoles: []string{"user", "assistant", "tool", "assistant"}, @@ -415,6 +422,14 @@ func TestServiceRun(t *testing.T) { if !foundToolResult { t.Fatalf("expected tool result message in second provider request: %+v", second.Messages) } + + session := onlySession(t, store) + if session.Messages[2].Role != providertypes.RoleTool || session.Messages[2].Content != "tool output" { + t.Fatalf("expected persisted tool message to keep raw content, got %+v", session.Messages[2]) + } + if session.Messages[2].ToolMetadata["tool_name"] != "filesystem_edit" { + t.Fatalf("expected persisted tool metadata to keep tool name, got %+v", session.Messages[2].ToolMetadata) + } }, }, } @@ -1030,10 +1045,9 @@ func TestServiceRunUsesToolManager(t *testing.T) { foundToolMessage := false for _, message := range session.Messages { if message.Role == providertypes.RoleTool && - strings.Contains(message.Content, "tool result") && - strings.Contains(message.Content, "tool: filesystem_edit") && - strings.Contains(message.Content, "meta.path: main.go") && - strings.Contains(message.Content, "content:\ntool manager output") { + message.Content == "tool manager output" && + message.ToolMetadata["tool_name"] == "filesystem_edit" && + message.ToolMetadata["path"] == "main.go" { foundToolMessage = true break } @@ -2708,6 +2722,21 @@ func cloneBuildInput(input agentcontext.BuildInput) agentcontext.BuildInput { return cloned } +// projectToolMessagesForProviderTest 模拟 context 层在 provider 请求前对 tool 消息做的只读投影。 +func projectToolMessagesForProviderTest(messages []providertypes.Message) []providertypes.Message { + projected := append([]providertypes.Message(nil), messages...) + for i, message := range projected { + if message.Role != providertypes.RoleTool || len(message.ToolMetadata) == 0 { + continue + } + next := message + next.Content = tools.FormatToolMessageForModel(message) + next.ToolMetadata = nil + projected[i] = next + } + return projected +} + func containsError(err error, target string) bool { return err != nil && strings.Contains(err.Error(), target) } diff --git a/internal/session/store_test.go b/internal/session/store_test.go index 4e08448f..d2b93131 100644 --- a/internal/session/store_test.go +++ b/internal/session/store_test.go @@ -535,7 +535,15 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { {ID: "call-1", Name: "webfetch", Arguments: `{"url":"https://example.com"}`}, }, }, - {Role: providertypes.RoleTool, ToolCallID: "call-1", Content: "ok"}, + { + Role: providertypes.RoleTool, + ToolCallID: "call-1", + Content: "ok", + ToolMetadata: map[string]string{ + "tool_name": "webfetch", + "http_status": "200", + }, + }, }, } @@ -566,6 +574,14 @@ func TestJSONStoreSavePersistsProviderModelAndMessages(t *testing.T) { if decoded["workdir"] != session.Workdir { t.Fatalf("expected workdir persisted as %q, got %+v", session.Workdir, decoded["workdir"]) } + + loaded, err := store.Load(context.Background(), session.ID) + if err != nil { + t.Fatalf("load saved session: %v", err) + } + if loaded.Messages[2].ToolMetadata["tool_name"] != "webfetch" || loaded.Messages[2].ToolMetadata["http_status"] != "200" { + t.Fatalf("expected tool metadata round-trip, got %+v", loaded.Messages[2].ToolMetadata) + } } func mustWriteSessionFile(t *testing.T, path string, content string) { diff --git a/internal/tools/format.go b/internal/tools/format.go index 891c0b1b..020a57fe 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -1,18 +1,46 @@ package tools import ( - "encoding/json" "fmt" "sort" + "strconv" "strings" + + providertypes "neo-code/internal/provider/types" ) const ( // DefaultOutputLimitBytes is the default max size of tool output content. DefaultOutputLimitBytes = 64 * 1024 truncatedSuffix = "\n...[truncated]" + + maxProjectedToolMetadataKeys = 6 + maxProjectedToolMetadataValueLen = 160 ) +var projectedToolMetadataAllowlist = map[string]struct{}{ + "bytes": {}, + "count": {}, + "emitted_bytes": {}, + "filtered_count": {}, + "http_status": {}, + "matched_count": {}, + "matched_files": {}, + "matched_lines": {}, + "mcp_server_id": {}, + "mcp_tool_name": {}, + "path": {}, + "relative_path": {}, + "replacement_length": {}, + "returned_count": {}, + "root": {}, + "search_length": {}, + "status_code": {}, + "tool_name": {}, + "truncated": {}, + "workdir": {}, +} + // ApplyOutputLimit truncates tool output content and adds a truncated metadata flag. func ApplyOutputLimit(result ToolResult, limit int) ToolResult { if limit <= 0 { @@ -32,29 +60,70 @@ func ApplyOutputLimit(result ToolResult, limit int) ToolResult { return result } -// FormatToolResultForModel 将工具执行结果渲染为稳定的结构化文本,便于模型准确消费回灌信息。 -func FormatToolResultForModel(result ToolResult) string { +// SanitizeToolMetadata 过滤并裁剪写入会话的工具 metadata,避免把内部或大字段永久带入对话状态。 +func SanitizeToolMetadata(toolName string, metadata map[string]any) map[string]string { + sanitized := make(map[string]string) + if name := strings.TrimSpace(toolName); name != "" { + sanitized["tool_name"] = name + } + + if len(metadata) == 0 { + if len(sanitized) == 0 { + return nil + } + return sanitized + } + + keys := make([]string, 0, len(metadata)) + for key := range metadata { + key = strings.TrimSpace(key) + if _, ok := projectedToolMetadataAllowlist[key]; !ok || key == "tool_name" { + continue + } + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + if len(sanitized) >= maxProjectedToolMetadataKeys { + break + } + value, ok := sanitizeToolMetadataValue(metadata[key]) + if !ok { + continue + } + sanitized[key] = value + } + + if len(sanitized) == 0 { + return nil + } + return sanitized +} + +// FormatToolMessageForModel 将持久化的 tool 消息投影为仅供模型消费的结构化文本。 +func FormatToolMessageForModel(message providertypes.Message) string { lines := []string{"tool result"} - if toolName := strings.TrimSpace(result.Name); toolName != "" { + if toolName := strings.TrimSpace(message.ToolMetadata["tool_name"]); toolName != "" { lines = append(lines, "tool: "+toolName) } status := "ok" - if result.IsError { + if message.IsError { status = "error" } lines = append(lines, "status: "+status) - if toolCallID := strings.TrimSpace(result.ToolCallID); toolCallID != "" { + if toolCallID := strings.TrimSpace(message.ToolCallID); toolCallID != "" { lines = append(lines, "tool_call_id: "+toolCallID) } - lines = append(lines, fmt.Sprintf("truncated: %t", toolResultTruncated(result.Metadata))) - lines = append(lines, formatToolResultMetadataLines(result.Metadata)...) + lines = append(lines, fmt.Sprintf("truncated: %t", toolMessageTruncated(message.ToolMetadata))) + lines = append(lines, formatToolMetadataLines(message.ToolMetadata)...) - if strings.TrimSpace(result.Content) != "" { - lines = append(lines, "", "content:", result.Content) + if strings.TrimSpace(message.Content) != "" { + lines = append(lines, "", "content:", message.Content) } return strings.Join(lines, "\n") @@ -108,17 +177,17 @@ func NewErrorResult(toolName string, reason string, details string, metadata map } } -// toolResultTruncated 从 metadata 中提取统一的截断标记,缺省时返回 false。 -func toolResultTruncated(metadata map[string]any) bool { +// toolMessageTruncated 从持久化的轻量 metadata 中提取截断标记,缺省时返回 false。 +func toolMessageTruncated(metadata map[string]string) bool { if metadata == nil { return false } - truncated, _ := metadata["truncated"].(bool) + truncated, _ := strconv.ParseBool(strings.TrimSpace(metadata["truncated"])) return truncated } -// formatToolResultMetadataLines 以稳定顺序输出供模型消费的 metadata 行,并跳过已提升为顶层字段的键。 -func formatToolResultMetadataLines(metadata map[string]any) []string { +// formatToolMetadataLines 以稳定顺序输出供模型消费的 metadata 行,并跳过已提升为顶层字段的键。 +func formatToolMetadataLines(metadata map[string]string) []string { if len(metadata) == 0 { return nil } @@ -126,7 +195,7 @@ func formatToolResultMetadataLines(metadata map[string]any) []string { keys := make([]string, 0, len(metadata)) for key := range metadata { key = strings.TrimSpace(key) - if key == "" || key == "truncated" { + if key == "" || key == "tool_name" || key == "truncated" { continue } keys = append(keys, key) @@ -135,25 +204,42 @@ func formatToolResultMetadataLines(metadata map[string]any) []string { lines := make([]string, 0, len(keys)) for _, key := range keys { - lines = append(lines, "meta."+key+": "+formatToolResultValue(metadata[key])) + lines = append(lines, "meta."+key+": "+sanitizeToolMetadataString(metadata[key])) } return lines } -// formatToolResultValue 将 metadata 值收敛为单行文本,避免破坏工具结果包络格式。 -func formatToolResultValue(value any) string { +// sanitizeToolMetadataValue 将原始 metadata 值收敛为短小的单行文本,不接受复杂结构。 +func sanitizeToolMetadataValue(value any) (string, bool) { switch v := value.(type) { case nil: - return "null" + return "", false case string: - return strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\n").Replace(v) - case fmt.Stringer: - return strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\n").Replace(v.String()) + text := sanitizeToolMetadataString(v) + return text, text != "" + case bool: + return strconv.FormatBool(v), true + case int: + return strconv.Itoa(v), true + case int8, int16, int32, int64: + return fmt.Sprint(v), true + case uint, uint8, uint16, uint32, uint64: + return fmt.Sprint(v), true + case float32, float64: + return fmt.Sprint(v), true + default: + return "", false } +} - payload, err := json.Marshal(value) - if err == nil { - return string(payload) +// sanitizeToolMetadataString 将 metadata 字符串裁剪为单行短文本,避免提示词膨胀。 +func sanitizeToolMetadataString(value string) string { + text := strings.NewReplacer("\r\n", "\\n", "\n", "\\n", "\r", "\\n").Replace(strings.TrimSpace(value)) + if text == "" { + return "" + } + if len(text) > maxProjectedToolMetadataValueLen { + return text[:maxProjectedToolMetadataValueLen] + "..." } - return fmt.Sprint(value) + return text } diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index c463941c..e916c0b6 100644 --- a/internal/tools/format_test.go +++ b/internal/tools/format_test.go @@ -4,6 +4,8 @@ import ( "errors" "strings" "testing" + + providertypes "neo-code/internal/provider/types" ) func TestApplyOutputLimit(t *testing.T) { @@ -187,50 +189,65 @@ func TestFormatHelpers(t *testing.T) { } } -func TestFormatToolResultForModel(t *testing.T) { +func TestSanitizeToolMetadata(t *testing.T) { t.Parallel() tests := []struct { name string - result ToolResult - want []string + tool string + input map[string]any + assert func(t *testing.T, got map[string]string) }{ { - name: "formats success result with stable metadata and content", - result: ToolResult{ - ToolCallID: "call-1", - Name: "filesystem_edit", - Content: "ok", - Metadata: map[string]any{ - "path": "internal/context/prompt.go", - "replacement": "line 1\nline 2", - "search_length": 12, - "truncated": true, - }, + name: "keeps allowlisted scalar keys and tool name", + tool: "filesystem_edit", + input: map[string]any{ + "path": "internal/context/prompt.go", + "search_length": 12, + "raw_result": strings.Repeat("x", 200), + "complex": map[string]any{"a": 1}, + "truncated": true, }, - want: []string{ - "tool result", - "tool: filesystem_edit", - "status: ok", - "tool_call_id: call-1", - "truncated: true", - "meta.path: internal/context/prompt.go", - "meta.replacement: line 1\\nline 2", - "meta.search_length: 12", - "\ncontent:\nok", + assert: func(t *testing.T, got map[string]string) { + t.Helper() + if got["tool_name"] != "filesystem_edit" { + t.Fatalf("expected tool_name to be kept, got %#v", got) + } + if got["path"] != "internal/context/prompt.go" || got["search_length"] != "12" { + t.Fatalf("expected allowlisted fields to be preserved, got %#v", got) + } + if got["raw_result"] != "" { + t.Fatalf("expected raw_result to be dropped, got %#v", got) + } + if got["complex"] != "" { + t.Fatalf("expected complex values to be dropped, got %#v", got) + } + if got["truncated"] != "true" { + t.Fatalf("expected truncated to be preserved, got %#v", got) + } }, }, { - name: "formats error result without content block when empty", - result: ToolResult{ - Name: "bash", - IsError: true, + name: "caps retained keys and truncates long values", + tool: "bash", + input: map[string]any{ + "path": strings.Repeat("a", 300), + "relative_path": "rel", + "workdir": "work", + "root": "root", + "bytes": 1, + "emitted_bytes": 2, + "matched_files": 3, + "replacement_length": 4, }, - want: []string{ - "tool result", - "tool: bash", - "status: error", - "truncated: false", + assert: func(t *testing.T, got map[string]string) { + t.Helper() + if len(got) > maxProjectedToolMetadataKeys { + t.Fatalf("expected metadata keys to be capped, got %#v", got) + } + if len(got["path"]) <= maxProjectedToolMetadataValueLen { + t.Fatalf("expected long value to be truncated, got %q", got["path"]) + } }, }, } @@ -240,12 +257,41 @@ func TestFormatToolResultForModel(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got := FormatToolResultForModel(tt.result) - for _, fragment := range tt.want { - if !strings.Contains(got, fragment) { - t.Fatalf("expected formatted result to contain %q, got %q", fragment, got) - } - } + got := SanitizeToolMetadata(tt.tool, tt.input) + tt.assert(t, got) }) } } + +func TestFormatToolMessageForModel(t *testing.T) { + t.Parallel() + + message := providertypes.Message{ + Role: providertypes.RoleTool, + Content: "ok", + ToolCallID: "call-1", + ToolMetadata: map[string]string{ + "tool_name": "filesystem_edit", + "path": "internal/context/prompt.go", + "search_length": "12", + "truncated": "true", + }, + } + + got := FormatToolMessageForModel(message) + fragments := []string{ + "tool result", + "tool: filesystem_edit", + "status: ok", + "tool_call_id: call-1", + "truncated: true", + "meta.path: internal/context/prompt.go", + "meta.search_length: 12", + "\ncontent:\nok", + } + for _, fragment := range fragments { + if !strings.Contains(got, fragment) { + t.Fatalf("expected formatted result to contain %q, got %q", fragment, got) + } + } +}