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/context/prompt.go b/internal/context/prompt.go index dc32245b..46004d58 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -15,15 +15,24 @@ 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" + + "- 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" + + "- 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..f5d5d210 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 == "" { @@ -111,7 +114,37 @@ func TestDefaultToolUsagePromptEncouragesAskFlow(t *testing.T) { 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) } + 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/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 7b3ff365..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: result.Content, - 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 2cf84928..591b9afa 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -380,6 +380,14 @@ func TestServiceRun(t *testing.T) { name: "filesystem_edit", content: "tool output", }, + 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"}, @@ -401,7 +409,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 } @@ -409,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) + } }, }, } @@ -901,7 +922,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 +1007,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 +1023,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 +1044,10 @@ 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 && + message.Content == "tool manager output" && + message.ToolMetadata["tool_name"] == "filesystem_edit" && + message.ToolMetadata["path"] == "main.go" { foundToolMessage = true break } @@ -2695,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 ae404b7e..020a57fe 100644 --- a/internal/tools/format.go +++ b/internal/tools/format.go @@ -1,13 +1,46 @@ package tools -import "strings" +import ( + "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 { @@ -27,6 +60,75 @@ func ApplyOutputLimit(result ToolResult, limit int) ToolResult { return result } +// 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(message.ToolMetadata["tool_name"]); toolName != "" { + lines = append(lines, "tool: "+toolName) + } + + status := "ok" + if message.IsError { + status = "error" + } + lines = append(lines, "status: "+status) + + if toolCallID := strings.TrimSpace(message.ToolCallID); toolCallID != "" { + lines = append(lines, "tool_call_id: "+toolCallID) + } + + lines = append(lines, fmt.Sprintf("truncated: %t", toolMessageTruncated(message.ToolMetadata))) + lines = append(lines, formatToolMetadataLines(message.ToolMetadata)...) + + if strings.TrimSpace(message.Content) != "" { + lines = append(lines, "", "content:", message.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 +176,70 @@ func NewErrorResult(toolName string, reason string, details string, metadata map Metadata: metadata, } } + +// toolMessageTruncated 从持久化的轻量 metadata 中提取截断标记,缺省时返回 false。 +func toolMessageTruncated(metadata map[string]string) bool { + if metadata == nil { + return false + } + truncated, _ := strconv.ParseBool(strings.TrimSpace(metadata["truncated"])) + return truncated +} + +// formatToolMetadataLines 以稳定顺序输出供模型消费的 metadata 行,并跳过已提升为顶层字段的键。 +func formatToolMetadataLines(metadata map[string]string) []string { + if len(metadata) == 0 { + return nil + } + + keys := make([]string, 0, len(metadata)) + for key := range metadata { + key = strings.TrimSpace(key) + if key == "" || key == "tool_name" || 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+": "+sanitizeToolMetadataString(metadata[key])) + } + return lines +} + +// sanitizeToolMetadataValue 将原始 metadata 值收敛为短小的单行文本,不接受复杂结构。 +func sanitizeToolMetadataValue(value any) (string, bool) { + switch v := value.(type) { + case nil: + return "", false + case 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 + } +} + +// 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 text +} diff --git a/internal/tools/format_test.go b/internal/tools/format_test.go index 06f7428e..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) { @@ -186,3 +188,110 @@ func TestFormatHelpers(t *testing.T) { }) } } + +func TestSanitizeToolMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tool string + input map[string]any + assert func(t *testing.T, got map[string]string) + }{ + { + 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, + }, + 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: "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, + }, + 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"]) + } + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + 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) + } + } +}