From 346fd5d3dfdde2c7616cd9947343e4f521f2d9cb Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:54:01 +0900 Subject: [PATCH 1/3] feat(task1): implement memory and performance optimizations --- CLAUDE.md | 2 +- pkg/agent/loop.go | 23 +- pkg/agent/memory.go | 294 +++++++++++++++------ pkg/providers/anthropic/provider.go | 10 +- pkg/providers/anthropic/provider_test.go | 5 +- pkg/providers/antigravity_provider.go | 15 +- pkg/providers/antigravity_provider_test.go | 2 +- pkg/providers/claude_cli_provider.go | 3 +- pkg/providers/claude_cli_provider_test.go | 10 +- pkg/providers/codex_cli_provider.go | 3 +- pkg/providers/codex_cli_provider_test.go | 8 +- pkg/providers/codex_provider.go | 30 ++- pkg/providers/codex_provider_test.go | 14 +- pkg/providers/openai_compat/provider.go | 91 ++++++- pkg/providers/protocoltypes/types.go | 118 ++++++++- pkg/providers/tool_call_extract.go | 9 +- pkg/providers/toolcall_utils.go | 35 +-- pkg/providers/types.go | 5 + pkg/session/legacy_adapter_test.go | 2 +- pkg/session/sqlite_test.go | 6 +- pkg/tools/registry.go | 10 +- pkg/tools/registry_test.go | 2 +- pkg/tools/toolloop.go | 3 +- pkg/tools/web.go | 96 ++++--- todo/TASKS-1.md | 3 +- 25 files changed, 579 insertions(+), 220 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1e08ac3a3..d89a8baac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,7 +59,7 @@ Lint: `golangci-lint run` | ファイル | 概要 | |---|---| -| [`todo/TASKS-1.md`](todo/TASKS-1.md) | **Memory & Performance Optimization** — MemoryStore キャッシュ、FunctionCall/ToolDefinition 型整理、stats フラッシュ最適化 | +| [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済み(MemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) | | [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode | | [`todo/TASKS-3.md`](todo/TASKS-3.md) | **Session DAG (SQLite Store)** — セッション管理の SQLite 移行、Turn ベース線形+セッション間 DAG、Fork/Report フロー | | [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f2cc4f485..9adbd5ad5 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -106,7 +106,7 @@ type AgentLoop struct { onHeartbeatThreadUpdate func(int) orchBroadcaster *orch.Broadcaster // nil when --orchestration not set orchReporter orch.AgentReporter // always non-nil (Noop when disabled) - done chan struct{} // closed by Close() to stop background goroutines + done chan struct{} // closed by Close() to stop background goroutines } // processOptions configures how a message is processed @@ -2839,7 +2839,6 @@ func (al *AgentLoop) runLLMIteration( ReasoningContent: response.ReasoningContent, } for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 extraContent := tc.ExtraContent thoughtSignature := "" @@ -2848,12 +2847,13 @@ func (al *AgentLoop) runLLMIteration( } assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - Type: "function", - Name: tc.Name, + ID: tc.ID, + Type: "function", + Name: tc.Name, + Arguments: tc.Arguments, Function: &providers.FunctionCall{ Name: tc.Name, - Arguments: string(argumentsJSON), + Arguments: tc.Arguments, ThoughtSignature: thoughtSignature, }, ExtraContent: extraContent, @@ -3367,8 +3367,13 @@ func formatMessagesForLog(messages []providers.Message) string { sb.WriteString(" ToolCalls:\n") for _, tc := range msg.ToolCalls { fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) - if tc.Function != nil { - fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) + args := tc.Arguments + if len(args) == 0 && tc.Function != nil { + args = tc.Function.Arguments + } + if len(args) > 0 { + argsJSON, _ := json.Marshal(args) + fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 200)) } } } @@ -3397,7 +3402,7 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) if len(tool.Function.Parameters) > 0 { - fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(string(tool.Function.Parameters), 200)) } } sb.WriteString("]") diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index d3e0db234..34e77eff1 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -13,6 +13,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/sipeed/picoclaw/pkg/fileutil" @@ -25,6 +26,35 @@ type MemoryStore struct { workspace string memoryDir string memoryFile string + + cacheMu sync.RWMutex + longTermCache longTermFileCache + parsedPlanCache parsedPlanStateCache +} + +type longTermFileCache struct { + loaded bool + exists bool + modTime time.Time + size int64 + content string +} + +type parsedPlanStateCache struct { + loaded bool + sourceContent string + state parsedPlanState +} + +type parsedPlanState struct { + content string + hasActivePlan bool + status string + currentPhase int + totalPhases int + workDir string + taskName string + phases []PlanPhase } // NewMemoryStore creates a new MemoryStore with the given workspace path. @@ -51,20 +81,165 @@ func (ms *MemoryStore) getTodayFile() string { return filePath } +// InvalidateCache clears all in-memory caches for MEMORY.md content and parsed plan state. +func (ms *MemoryStore) InvalidateCache() { + ms.cacheMu.Lock() + defer ms.cacheMu.Unlock() + + ms.longTermCache = longTermFileCache{} + ms.parsedPlanCache = parsedPlanStateCache{} +} + +func (ms *MemoryStore) readLongTermCached() string { + info, err := os.Stat(ms.memoryFile) + if err != nil { + if !os.IsNotExist(err) { + return "" + } + + ms.cacheMu.RLock() + cachedMissing := ms.longTermCache.loaded && !ms.longTermCache.exists + ms.cacheMu.RUnlock() + if cachedMissing { + return "" + } + + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{loaded: true, exists: false} + ms.parsedPlanCache = parsedPlanStateCache{} + ms.cacheMu.Unlock() + return "" + } + + modTime := info.ModTime() + size := info.Size() + + ms.cacheMu.RLock() + if ms.longTermCache.loaded && + ms.longTermCache.exists && + ms.longTermCache.modTime.Equal(modTime) && + ms.longTermCache.size == size { + content := ms.longTermCache.content + ms.cacheMu.RUnlock() + return content + } + ms.cacheMu.RUnlock() + + data, err := os.ReadFile(ms.memoryFile) + if err != nil { + if os.IsNotExist(err) { + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{loaded: true, exists: false} + ms.parsedPlanCache = parsedPlanStateCache{} + ms.cacheMu.Unlock() + } + return "" + } + content := string(data) + + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{ + loaded: true, + exists: true, + modTime: modTime, + size: size, + content: content, + } + if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent != content { + ms.parsedPlanCache = parsedPlanStateCache{} + } + ms.cacheMu.Unlock() + + return content +} + +func (ms *MemoryStore) getParsedPlanState() parsedPlanState { + content := ms.ReadLongTerm() + + ms.cacheMu.RLock() + if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent == content { + state := ms.parsedPlanCache.state + ms.cacheMu.RUnlock() + return state + } + ms.cacheMu.RUnlock() + + state := ms.parsePlanState(content) + + ms.cacheMu.Lock() + if !ms.parsedPlanCache.loaded || ms.parsedPlanCache.sourceContent != content { + ms.parsedPlanCache = parsedPlanStateCache{ + loaded: true, + sourceContent: content, + state: state, + } + } else { + state = ms.parsedPlanCache.state + } + ms.cacheMu.Unlock() + + return state +} + +func (ms *MemoryStore) parsePlanState(content string) parsedPlanState { + state := parsedPlanState{content: content} + if content == "" || !reActivePlan.MatchString(content) { + return state + } + + state.hasActivePlan = true + if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { + state.status = strings.TrimSpace(m[1]) + } + if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { + state.currentPhase, _ = strconv.Atoi(m[1]) + } + state.totalPhases = maxPhaseNumber(content) + if m := reWorkDir.FindStringSubmatch(content); len(m) >= 2 { + state.workDir = strings.TrimSpace(m[1]) + } + if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { + state.taskName = strings.TrimSpace(m[1]) + } + state.phases = ms.getPlanPhasesFrom(content) + + return state +} + +func clonePlanPhases(phases []PlanPhase) []PlanPhase { + if len(phases) == 0 { + return nil + } + + result := make([]PlanPhase, 0, len(phases)) + for _, p := range phases { + phase := PlanPhase{ + Number: p.Number, + Title: p.Title, + } + if len(p.Steps) > 0 { + phase.Steps = append([]PlanStep(nil), p.Steps...) + } + result = append(result, phase) + } + return result +} + // ReadLongTerm reads the long-term memory (MEMORY.md). // Returns empty string if the file doesn't exist. func (ms *MemoryStore) ReadLongTerm() string { - if data, err := os.ReadFile(ms.memoryFile); err == nil { - return string(data) - } - return "" + return ms.readLongTermCached() } // WriteLongTerm writes content to the long-term memory file (MEMORY.md). func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) + if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil { + return err + } + ms.InvalidateCache() + return nil } // ClearLongTerm removes the long-term memory file. @@ -72,6 +247,7 @@ func (ms *MemoryStore) ClearLongTerm() error { if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) { return err } + ms.InvalidateCache() return nil } @@ -151,50 +327,27 @@ var ( // HasActivePlan returns true if MEMORY.md contains an active plan. func (ms *MemoryStore) HasActivePlan() bool { - content := ms.ReadLongTerm() - return reActivePlan.MatchString(content) + return ms.getParsedPlanState().hasActivePlan } // GetPlanStatus returns the plan status: "interviewing", "executing", or "". func (ms *MemoryStore) GetPlanStatus() string { - content := ms.ReadLongTerm() - m := reStatus.FindStringSubmatch(content) - if len(m) < 2 { - return "" - } - return strings.TrimSpace(m[1]) + return ms.getParsedPlanState().status } // GetCurrentPhase returns the current phase number from "> Phase: N". func (ms *MemoryStore) GetCurrentPhase() int { - content := ms.ReadLongTerm() - m := rePhase.FindStringSubmatch(content) - if len(m) < 2 { - return 0 - } - n, _ := strconv.Atoi(m[1]) - return n + return ms.getParsedPlanState().currentPhase } // GetTotalPhases returns the total number of phases (max ## Phase N). func (ms *MemoryStore) GetTotalPhases() int { - content := ms.ReadLongTerm() - matches := rePhaseHeader.FindAllStringSubmatch(content, -1) - maxN := 0 - for _, m := range matches { - if len(m) >= 2 { - n, _ := strconv.Atoi(m[1]) - if n > maxN { - maxN = n - } - } - } - return maxN + return ms.getParsedPlanState().totalPhases } // IsPlanComplete returns true if all steps in all phases are [x]. func (ms *MemoryStore) IsPlanComplete() bool { - phases := ms.GetPlanPhases() + phases := ms.getParsedPlanState().phases if len(phases) == 0 { return false } @@ -212,13 +365,12 @@ func (ms *MemoryStore) IsPlanComplete() bool { // IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. func (ms *MemoryStore) IsCurrentPhaseComplete() bool { - current := ms.GetCurrentPhase() - if current == 0 { + state := ms.getParsedPlanState() + if state.currentPhase == 0 { return false } - phases := ms.GetPlanPhases() - for _, p := range phases { - if p.Number == current { + for _, p := range state.phases { + if p.Number == state.currentPhase { if len(p.Steps) == 0 { return false } @@ -273,7 +425,7 @@ type PlanStep struct { // GetPlanPhases parses MEMORY.md and returns all phases with their steps. func (ms *MemoryStore) GetPlanPhases() []PlanPhase { - return ms.getPlanPhasesFrom(ms.ReadLongTerm()) + return clonePlanPhases(ms.getParsedPlanState().phases) } func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase { @@ -446,7 +598,7 @@ func (ms *MemoryStore) ValidatePlanStructure() error { } // 3. At least one phase header (## Phase N: title) - phases := ms.GetPlanPhases() + phases := ms.getPlanPhasesFrom(content) if len(phases) == 0 { return fmt.Errorf("no '## Phase N:' sections found") } @@ -465,12 +617,7 @@ func (ms *MemoryStore) ValidatePlanStructure() error { // GetPlanWorkDir returns the WorkDir from the plan metadata, or "". func (ms *MemoryStore) GetPlanWorkDir() string { - content := ms.ReadLongTerm() - m := reWorkDir.FindStringSubmatch(content) - if len(m) < 2 { - return "" - } - return strings.TrimSpace(m[1]) + return ms.getParsedPlanState().workDir } // reTaskLine extracts the task name from "> Task: ". @@ -478,12 +625,7 @@ var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`) // GetPlanTaskName returns the task description from the plan metadata, or "". func (ms *MemoryStore) GetPlanTaskName() string { - content := ms.ReadLongTerm() - m := reTaskLine.FindStringSubmatch(content) - if len(m) < 2 { - return "" - } - return strings.TrimSpace(m[1]) + return ms.getParsedPlanState().taskName } // interviewSeed is the initial content written to MEMORY.md when /plan starts. @@ -704,35 +846,21 @@ func (ms *MemoryStore) extractCommandsSection(content string) string { // FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators. func (ms *MemoryStore) FormatPlanDisplay() string { - content := ms.ReadLongTerm() - if !reActivePlan.MatchString(content) { + state := ms.getParsedPlanState() + if !state.hasActivePlan { return "No active plan." } - taskLine := "" - if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { - taskLine = strings.TrimSpace(m[1]) - } - var status string - if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { - status = strings.TrimSpace(m[1]) - } - var currentPhase int - if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { - currentPhase, _ = strconv.Atoi(m[1]) - } - phases := ms.getPlanPhasesFrom(content) - var sb strings.Builder - sb.WriteString(fmt.Sprintf("Plan: %s\n", taskLine)) - sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, len(phases))) + sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName)) + sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", state.status, state.currentPhase, len(state.phases))) - for _, p := range phases { + for _, p := range state.phases { // Determine phase emoji var emoji string - if p.Number < currentPhase { + if p.Number < state.currentPhase { emoji = "\u2705" // checkmark - } else if p.Number == currentPhase { + } else if p.Number == state.currentPhase { emoji = "\u25B6\uFE0F" // play button } else { emoji = "\u23F3" // hourglass @@ -741,7 +869,7 @@ func (ms *MemoryStore) FormatPlanDisplay() string { sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title)) // Show steps for current and completed phases - if p.Number <= currentPhase { + if p.Number <= state.currentPhase { for _, s := range p.Steps { if s.Done { sb.WriteString(" \u2611 " + s.Description + "\n") @@ -752,7 +880,7 @@ func (ms *MemoryStore) FormatPlanDisplay() string { } } - commandsContent := ms.extractCommandsSection(content) + commandsContent := ms.extractCommandsSection(state.content) if commandsContent != "" { sb.WriteString("\nCommands:\n") for _, line := range strings.Split(commandsContent, "\n") { @@ -763,7 +891,7 @@ func (ms *MemoryStore) FormatPlanDisplay() string { } } - contextContent := ms.extractContextSection(content) + contextContent := ms.extractContextSection(state.content) if contextContent != "" { sb.WriteString("\nContext: " + contextContent + "\n") } @@ -781,16 +909,12 @@ func (ms *MemoryStore) FormatPlanDisplay() string { func (ms *MemoryStore) GetMemoryContext() string { var parts []string - longTerm := ms.ReadLongTerm() - hasActivePlan := longTerm != "" && reActivePlan.MatchString(longTerm) + state := ms.getParsedPlanState() + longTerm := state.content if longTerm != "" { - if hasActivePlan { - var status string - if m := reStatus.FindStringSubmatch(longTerm); len(m) >= 2 { - status = strings.TrimSpace(m[1]) - } - switch status { + if state.hasActivePlan { + switch state.status { case "interviewing": parts = append(parts, ms.getInterviewContextFrom(longTerm)) case "review": @@ -804,7 +928,7 @@ func (ms *MemoryStore) GetMemoryContext() string { } // Suppress daily notes when a plan is active to save context - if !hasActivePlan { + if !state.hasActivePlan { recentNotes := ms.GetRecentDailyNotes(3) if recentNotes != "" { parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 1bb15f771..3b79519e7 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -188,16 +188,19 @@ func buildParams( func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { result := make([]anthropic.ToolUnionParam, 0, len(tools)) for _, t := range tools { + params := t.Function.ParametersMap() tool := anthropic.ToolParam{ Name: t.Function.Name, InputSchema: anthropic.ToolInputSchemaParam{ - Properties: t.Function.Parameters["properties"], + Properties: params["properties"], }, } if desc := t.Function.Description; desc != "" { tool.Description = anthropic.String(desc) } - if req, ok := t.Function.Parameters["required"].([]any); ok { + + switch req := params["required"].(type) { + case []any: required := make([]string, 0, len(req)) for _, r := range req { if s, ok := r.(string); ok { @@ -205,7 +208,10 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { } } tool.InputSchema.Required = required + case []string: + tool.InputSchema.Required = append([]string(nil), req...) } + result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) } return result diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 3d21c1d0b..ff1e1ac26 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -9,6 +9,7 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) func TestBuildParams_BasicMessage(t *testing.T) { @@ -84,13 +85,13 @@ func TestBuildParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a city", - Parameters: map[string]any{ + Parameters: protocoltypes.MustMarshalParameters(map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, "required": []any{"city"}, - }, + }), }, }, } diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index d4ee528b7..05c6e4763 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest( if t.Type != "function" { continue } - params := sanitizeSchemaForGemini(t.Function.Parameters) + params := sanitizeSchemaForGemini(t.Function.ParametersMap()) funcDecls = append(funcDecls, antigravityFuncDecl{ Name: t.Function.Name, Description: t.Function.Description, @@ -340,17 +340,13 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { thoughtSignature = tc.Function.ThoughtSignature } + if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 { + args = cloneToolArgs(tc.Function.Arguments) + } if args == nil { args = map[string]any{} } - if len(args) == 0 && tc.Function != nil && tc.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(tc.Function.Arguments), &parsed); err == nil && parsed != nil { - args = parsed - } - } - return name, args, thoughtSignature } @@ -436,14 +432,13 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error contentParts = append(contentParts, part.Text) } if part.FunctionCall != nil { - argumentsJSON, _ := json.Marshal(part.FunctionCall.Args) toolCalls = append(toolCalls, ToolCall{ ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), Name: part.FunctionCall.Name, Arguments: part.FunctionCall.Args, Function: &FunctionCall{ Name: part.FunctionCall.Name, - Arguments: string(argumentsJSON), + Arguments: cloneToolArgs(part.FunctionCall.Args), ThoughtSignature: extractPartThoughtSignature( part.ThoughtSignature, part.ThoughtSignatureSnake, diff --git a/pkg/providers/antigravity_provider_test.go b/pkg/providers/antigravity_provider_test.go index 238765321..a7eebbedc 100644 --- a/pkg/providers/antigravity_provider_test.go +++ b/pkg/providers/antigravity_provider_test.go @@ -12,7 +12,7 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) { ID: "call_read_file_123", Function: &FunctionCall{ Name: "read_file", - Arguments: `{"path":"README.md"}`, + Arguments: map[string]any{"path": "README.md"}, }, }}, }, diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index 6074a8ee1..dcfec73b9 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -129,9 +129,8 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) } if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) sb.WriteString("Parameters:\n```json\n") - sb.Write(paramsJSON) + sb.Write(tool.Function.Parameters) sb.WriteString("\n```\n") } sb.WriteString("\n") diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 83a3397e1..d7b30bae5 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -619,12 +619,12 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a location", - Parameters: map[string]any{ + Parameters: MustMarshalParameters(map[string]any{ "type": "object", "properties": map[string]any{ "location": map[string]any{"type": "string"}, }, - }, + }), }, }, } @@ -920,9 +920,9 @@ func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { if got[0].Arguments["name"] != "test" { t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) } - // Verify raw arguments string is preserved in FunctionCall - if got[0].Function.Arguments == "" { - t.Error("Function.Arguments should contain raw JSON string") + // Verify parsed arguments are also set on FunctionCall + if len(got[0].Function.Arguments) == 0 { + t.Error("Function.Arguments should contain parsed JSON arguments") } } diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index 9803fbdee..c9b7e9fa2 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -151,9 +151,8 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) } if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) sb.WriteString("Parameters:\n```json\n") - sb.Write(paramsJSON) + sb.Write(tool.Function.Parameters) sb.WriteString("\n```\n") } sb.WriteString("\n") diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go index 414e0844d..e537d7e31 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/codex_cli_provider_test.go @@ -76,8 +76,8 @@ func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { if resp.ToolCalls[0].ID != "call_1" { t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") } - if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` { - t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) + if resp.ToolCalls[0].Function.Arguments["path"] != "/tmp/test.txt" { + t.Errorf("ToolCalls[0].Function.Arguments[path] = %v", resp.ToolCalls[0].Function.Arguments["path"]) } // Content should have the tool call JSON stripped if strings.Contains(resp.Content, "tool_calls") { @@ -292,12 +292,12 @@ func TestBuildPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get current weather", - Parameters: map[string]any{ + Parameters: MustMarshalParameters(map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, - }, + }), }, }, } diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 47618300a..c8df94b8d 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -317,19 +317,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return "", "", false } - if len(tc.Arguments) > 0 { - argsJSON, err := json.Marshal(tc.Arguments) - if err != nil { - return "", "", false - } - return name, string(argsJSON), true + args := tc.Arguments + if len(args) == 0 && tc.Function != nil { + args = tc.Function.Arguments + } + if len(args) == 0 { + return name, "{}", true } - if tc.Function != nil && tc.Function.Arguments != "" { - return name, tc.Function.Arguments, true + argsJSON, err := json.Marshal(args) + if err != nil { + return "", "", false } - - return name, "{}", true + return name, string(argsJSON), true } func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { @@ -345,9 +345,13 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { continue } + params := t.Function.ParametersMap() + if params == nil { + params = map[string]any{} + } ft := responses.FunctionToolParam{ Name: t.Function.Name, - Parameters: t.Function.Parameters, + Parameters: params, Strict: openai.Opt(false), } if t.Function.Description != "" { @@ -382,6 +386,10 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse { ID: item.CallID, Name: item.Name, Arguments: args, + Function: &FunctionCall{ + Name: item.Name, + Arguments: cloneToolArgs(args), + }, }) } } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 4157e53e9..c0db1c381 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -79,7 +79,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { Type: "function", Function: &FunctionCall{ Name: "read_file", - Arguments: `{"path":"README.md"}`, + Arguments: map[string]any{"path": "README.md"}, }, }, }, @@ -114,12 +114,12 @@ func TestBuildCodexParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather", - Parameters: map[string]any{ + Parameters: MustMarshalParameters(map[string]any{ "type": "object", "properties": map[string]any{ "city": map[string]any{"type": "string"}, }, - }, + }), }, }, } @@ -166,9 +166,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "web_search", Description: "local web search", - Parameters: map[string]any{ + Parameters: MustMarshalParameters(map[string]any{ "type": "object", - }, + }), }, }, { @@ -176,9 +176,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "read_file", Description: "read file", - Parameters: map[string]any{ + Parameters: MustMarshalParameters(map[string]any{ "type": "object", - }, + }), }, }, } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index ef900af95..8759c7ea5 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -462,6 +462,10 @@ func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error) ID: tc.ID, Name: tc.Name, Arguments: arguments, + Function: &FunctionCall{ + Name: tc.Name, + Arguments: cloneOpenAIToolArgs(arguments), + }, }) } @@ -534,6 +538,11 @@ func parseResponse(body []byte) (*LLMResponse, error) { Name: name, Arguments: arguments, ThoughtSignature: thoughtSignature, + Function: &FunctionCall{ + Name: name, + Arguments: cloneOpenAIToolArgs(arguments), + ThoughtSignature: thoughtSignature, + }, } if thoughtSignature != "" { @@ -562,10 +571,21 @@ func parseResponse(body []byte) (*LLMResponse, error) { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type openaiToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *openaiFunctionCall `json:"function,omitempty"` +} + +type openaiFunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` } // stripSystemParts converts []Message to []openaiMessage, dropping the @@ -577,13 +597,74 @@ func stripSystemParts(messages []Message) []openaiMessage { out[i] = openaiMessage{ Role: m.Role, Content: m.Content, - ToolCalls: m.ToolCalls, + ToolCalls: toOpenAIWireToolCalls(m.ToolCalls), ToolCallID: m.ToolCallID, } } return out } +func toOpenAIWireToolCalls(toolCalls []ToolCall) []openaiToolCall { + if len(toolCalls) == 0 { + return nil + } + + out := make([]openaiToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + name, args := normalizeOpenAIWireToolCall(tc) + if name == "" { + continue + } + + argsJSON, err := json.Marshal(args) + if err != nil { + argsJSON = []byte(`{}`) + } + + wire := openaiToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: &openaiFunctionCall{ + Name: name, + Arguments: string(argsJSON), + }, + } + out = append(out, wire) + } + + if len(out) == 0 { + return nil + } + return out +} + +func normalizeOpenAIWireToolCall(tc ToolCall) (name string, args map[string]any) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + + args = tc.Arguments + if len(args) == 0 && tc.Function != nil { + args = tc.Function.Arguments + } + if args == nil { + args = map[string]any{} + } + return name, args +} + +func cloneOpenAIToolArgs(src map[string]any) map[string]any { + if len(src) == 0 { + return map[string]any{} + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + func normalizeModel(model, apiBase string) string { before, after, ok := strings.Cut(model, "/") if !ok { diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 867078185..00c5b202d 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -1,5 +1,10 @@ package protocoltypes +import ( + "encoding/json" + "strings" +) + type ToolCall struct { ID string `json:"id"` Type string `json:"type,omitempty"` @@ -19,9 +24,75 @@ type GoogleExtra struct { } type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - ThoughtSignature string `json:"thought_signature,omitempty"` + Name string `json:"name"` + Arguments map[string]any `json:"-"` + ThoughtSignature string `json:"thought_signature,omitempty"` +} + +func (f *FunctionCall) UnmarshalJSON(data []byte) error { + var wire struct { + Name string `json:"name"` + Arguments any `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + + f.Name = wire.Name + f.ThoughtSignature = wire.ThoughtSignature + f.Arguments = decodeFunctionArguments(wire.Arguments) + return nil +} + +func (f FunctionCall) MarshalJSON() ([]byte, error) { + args := "{}" + if len(f.Arguments) > 0 { + payload, err := json.Marshal(f.Arguments) + if err != nil { + return nil, err + } + args = string(payload) + } + + wire := struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + ThoughtSignature string `json:"thought_signature,omitempty"` + }{ + Name: f.Name, + Arguments: args, + ThoughtSignature: f.ThoughtSignature, + } + return json.Marshal(wire) +} + +func decodeFunctionArguments(raw any) map[string]any { + switch v := raw.(type) { + case nil: + return map[string]any{} + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return map[string]any{} + } + var parsed map[string]any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil || parsed == nil { + return map[string]any{"raw": v} + } + return parsed + case map[string]any: + if v == nil { + return map[string]any{} + } + return v + default: + payload, err := json.Marshal(v) + if err != nil { + return map[string]any{} + } + return map[string]any{"raw": string(payload)} + } } type LLMResponse struct { @@ -77,9 +148,44 @@ type ToolDefinition struct { } type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]any `json:"parameters"` + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` +} + +func (t ToolFunctionDefinition) ParametersMap() map[string]any { + if len(t.Parameters) == 0 { + return nil + } + var params map[string]any + if err := json.Unmarshal(t.Parameters, ¶ms); err != nil { + return nil + } + return params +} + +func (t *ToolFunctionDefinition) SetParametersMap(params map[string]any) error { + if len(params) == 0 { + t.Parameters = json.RawMessage(`{}`) + return nil + } + payload, err := json.Marshal(params) + if err != nil { + return err + } + t.Parameters = json.RawMessage(payload) + return nil +} + +func MustMarshalParameters(params map[string]any) json.RawMessage { + if len(params) == 0 { + return json.RawMessage(`{}`) + } + payload, err := json.Marshal(params) + if err != nil { + return json.RawMessage(`{}`) + } + return json.RawMessage(payload) } // StreamEvent represents a single chunk from an SSE streaming response. diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 44f2403cb..689107319 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -41,7 +41,9 @@ func extractToolCallsFromText(text string) []ToolCall { var result []ToolCall for _, tc := range wrapper.ToolCalls { var args map[string]any - json.Unmarshal([]byte(tc.Function.Arguments), &args) + if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil || args == nil { + args = map[string]any{} + } result = append(result, ToolCall{ ID: tc.ID, @@ -50,7 +52,7 @@ func extractToolCallsFromText(text string) []ToolCall { Arguments: args, Function: &FunctionCall{ Name: tc.Function.Name, - Arguments: tc.Function.Arguments, + Arguments: cloneToolArgs(args), }, }) } @@ -278,7 +280,6 @@ func parseInvokeElements(text string, callIdx *int) []ToolCall { paramRemaining = paramRemaining[valueStart+valueEnd+len(""):] } - argsJSON, _ := json.Marshal(args) *callIdx++ result = append(result, ToolCall{ ID: fmt.Sprintf("xmltc_%d", *callIdx), @@ -287,7 +288,7 @@ func parseInvokeElements(text string, callIdx *int) []ToolCall { Arguments: args, Function: &FunctionCall{ Name: toolName, - Arguments: string(argsJSON), + Arguments: cloneToolArgs(args), }, }) } diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index 49218b1b1..8085f815b 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -5,38 +5,32 @@ package providers -import "encoding/json" - // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) // and ensures both are populated consistently. func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc - // Ensure Name is populated from Function if not set + // Ensure Name is populated from Function if not set. if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name } - // Ensure Arguments is not nil + // Ensure Arguments is not nil. if normalized.Arguments == nil { normalized.Arguments = map[string]any{} } - // Parse Arguments from Function.Arguments if not already set - if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { - var parsed map[string]any - if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil { - normalized.Arguments = parsed - } + // Populate top-level arguments from Function arguments when needed. + if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 { + normalized.Arguments = cloneToolArgs(normalized.Function.Arguments) } - // Ensure Function is populated with consistent values - argsJSON, _ := json.Marshal(normalized.Arguments) + // Ensure Function is populated with consistent values. if normalized.Function == nil { normalized.Function = &FunctionCall{ Name: normalized.Name, - Arguments: string(argsJSON), + Arguments: cloneToolArgs(normalized.Arguments), } } else { if normalized.Function.Name == "" { @@ -45,10 +39,21 @@ func NormalizeToolCall(tc ToolCall) ToolCall { if normalized.Name == "" { normalized.Name = normalized.Function.Name } - if normalized.Function.Arguments == "" { - normalized.Function.Arguments = string(argsJSON) + if len(normalized.Function.Arguments) == 0 { + normalized.Function.Arguments = cloneToolArgs(normalized.Arguments) } } return normalized } + +func cloneToolArgs(src map[string]any) map[string]any { + if len(src) == 0 { + return map[string]any{} + } + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 2ac38f86d..ae7efb5e1 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -2,6 +2,7 @@ package providers import ( "context" + "encoding/json" "fmt" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" @@ -97,3 +98,7 @@ type ModelConfig struct { Primary string Fallbacks []string } + +func MustMarshalParameters(params map[string]any) json.RawMessage { + return protocoltypes.MustMarshalParameters(params) +} diff --git a/pkg/session/legacy_adapter_test.go b/pkg/session/legacy_adapter_test.go index 213903c8e..4e48c0044 100644 --- a/pkg/session/legacy_adapter_test.go +++ b/pkg/session/legacy_adapter_test.go @@ -126,7 +126,7 @@ func TestBackend_AddFullMessage(t *testing.T) { Content: "sure", ToolCalls: []providers.ToolCall{ - {ID: "call_1", Type: "function", Function: &providers.FunctionCall{Name: "exec", Arguments: `{}`}}, + {ID: "call_1", Type: "function", Function: &providers.FunctionCall{Name: "exec", Arguments: map[string]any{}}}, }, }) diff --git a/pkg/session/sqlite_test.go b/pkg/session/sqlite_test.go index 33e573e67..3b3af13d7 100644 --- a/pkg/session/sqlite_test.go +++ b/pkg/session/sqlite_test.go @@ -412,7 +412,7 @@ func TestSQLite_MessagesRoundTrip(t *testing.T) { Function: &providers.FunctionCall{ Name: "exec", - Arguments: `{"cmd":"ls"}`, + Arguments: map[string]any{"cmd": "ls"}, }, }, }, @@ -447,8 +447,8 @@ func TestSQLite_MessagesRoundTrip(t *testing.T) { t.Errorf("tool call function name mismatch: %s", got[1].ToolCalls[0].Function.Name) } - if got[1].ToolCalls[0].Function.Arguments != `{"cmd":"ls"}` { - t.Errorf("tool call arguments mismatch: %s", got[1].ToolCalls[0].Function.Arguments) + if got[1].ToolCalls[0].Function.Arguments["cmd"] != "ls" { + t.Errorf("tool call arguments mismatch: %v", got[1].ToolCalls[0].Function.Arguments) } if got[2].ToolCallID != "call_1" { diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 1d428704f..13fbdd5bc 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "fmt" "sort" "strings" @@ -183,12 +184,19 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { desc, _ := fn["description"].(string) params, _ := fn["parameters"].(map[string]any) + paramsRaw := json.RawMessage(`{}`) + if len(params) > 0 { + if payload, err := json.Marshal(params); err == nil { + paramsRaw = json.RawMessage(payload) + } + } + definitions = append(definitions, providers.ToolDefinition{ Type: "function", Function: providers.ToolFunctionDefinition{ Name: name, Description: desc, - Parameters: params, + Parameters: paramsRaw, }, }) } diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index d6f2d9935..05c2676b3 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -285,7 +285,7 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) { Function: providers.ToolFunctionDefinition{ Name: "beta", Description: "tool B", - Parameters: params, + Parameters: providers.MustMarshalParameters(params), }, } got := defs[0] diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 6351879a0..793c51a3c 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -124,7 +124,6 @@ func RunToolLoop( Content: response.Content, } for _, tc := range normalizedToolCalls { - argumentsJSON, _ := json.Marshal(tc.Arguments) assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, Type: "function", @@ -132,7 +131,7 @@ func RunToolLoop( Arguments: tc.Arguments, Function: &providers.FunctionCall{ Name: tc.Name, - Arguments: string(argumentsJSON), + Arguments: tc.Arguments, }, }) } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 36ba38d86..42ad6d4e6 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -79,6 +79,36 @@ type SearchProvider interface { Search(ctx context.Context, query string, count int) (string, error) } +type searchResultItem struct { + Title string + URL string + Snippet string +} + +func formatWebSearchResults(query, provider string, results []searchResultItem, count int) string { + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query) + } + + header := fmt.Sprintf("Results for: %s", query) + if provider != "" { + header += " (via " + provider + ")" + } + + var sb strings.Builder + sb.WriteString(header) + for i, item := range results { + if i >= count { + break + } + fmt.Fprintf(&sb, "\n%d. %s\n %s", i+1, item.Title, item.URL) + if item.Snippet != "" { + fmt.Fprintf(&sb, "\n %s", item.Snippet) + } + } + return sb.String() +} + type BraveSearchProvider struct { apiKey string proxy string @@ -125,23 +155,16 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in } results := searchResp.Web.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil + items := make([]searchResultItem, 0, len(results)) + for _, item := range results { + items = append(items, searchResultItem{ + Title: item.Title, + URL: item.URL, + Snippet: item.Description, + }) } - var sb strings.Builder - fmt.Fprintf(&sb, "Results for: %s", query) - for i, item := range results { - if i >= count { - break - } - fmt.Fprintf(&sb, "\n%d. %s\n %s", i+1, item.Title, item.URL) - if item.Description != "" { - fmt.Fprintf(&sb, "\n %s", item.Description) - } - } - - return sb.String(), nil + return formatWebSearchResults(query, "", items, count), nil } type TavilySearchProvider struct { @@ -208,23 +231,16 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i } results := searchResp.Results - if len(results) == 0 { - return fmt.Sprintf("No results for: %s", query), nil + items := make([]searchResultItem, 0, len(results)) + for _, item := range results { + items = append(items, searchResultItem{ + Title: item.Title, + URL: item.URL, + Snippet: item.Content, + }) } - var sb strings.Builder - fmt.Fprintf(&sb, "Results for: %s (via Tavily)", query) - for i, item := range results { - if i >= count { - break - } - fmt.Fprintf(&sb, "\n%d. %s\n %s", i+1, item.Title, item.URL) - if item.Content != "" { - fmt.Fprintf(&sb, "\n %s", item.Content) - } - } - - return sb.String(), nil + return formatWebSearchResults(query, "Tavily", items, count), nil } type DuckDuckGoSearchProvider struct { @@ -269,12 +285,10 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query return fmt.Sprintf("No results found or extraction failed. Query: %s", query), nil } - var sb strings.Builder - fmt.Fprintf(&sb, "Results for: %s (via DuckDuckGo)", query) - snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5) maxItems := min(len(matches), count) + items := make([]searchResultItem, 0, maxItems) for i := range maxItems { urlStr := matches[i][1] @@ -291,19 +305,21 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query } } - fmt.Fprintf(&sb, "\n%d. %s\n %s", i+1, title, urlStr) - + snippet := "" // Attempt to attach snippet if available and index aligns if i < len(snippetMatches) { - snippet := stripTags(snippetMatches[i][1]) + snippet = stripTags(snippetMatches[i][1]) snippet = strings.TrimSpace(snippet) - if snippet != "" { - fmt.Fprintf(&sb, "\n %s", snippet) - } } + + items = append(items, searchResultItem{ + Title: title, + URL: urlStr, + Snippet: snippet, + }) } - return sb.String(), nil + return formatWebSearchResults(query, "DuckDuckGo", items, count), nil } func stripTags(content string) string { diff --git a/todo/TASKS-1.md b/todo/TASKS-1.md index f744eebef..3a2c8281e 100644 --- a/todo/TASKS-1.md +++ b/todo/TASKS-1.md @@ -1,4 +1,4 @@ -# TASKS-1: Memory & Performance Optimization +# TASKS-1: Memory & Performance Optimization`r`n`r`n> ✅ 2026-03-04: D-1 / D-2 / D-3 / D-4 / D-6 と stats.Tracker 定期フラッシュを実装済み。 内部リファクタリング。外部APIの変更なし。他トラックへの依存なし。 @@ -73,3 +73,4 @@ D-1 の解決策と合わせて、メソッド境界を「必要な情報の単 - FunctionCall の Unmarshal が1回に集約 (D-2) - ToolFunctionDefinition.Parameters の Marshal がプロバイダー初期化時のみ (D-3) - stats.json の書き込み頻度が 98% 削減 (stats.Tracker) + From 26e08f344507ae180bb7d668a9ec13dfd6051b2c Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:04:35 +0900 Subject: [PATCH 2/3] chore(task1): add work report and finalize protocoltypes receiver --- pkg/providers/protocoltypes/types.go | 4 ++-- todo/TASKS-1.md | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 00c5b202d..e962bafe1 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -153,8 +153,8 @@ type ToolFunctionDefinition struct { Parameters json.RawMessage `json:"parameters"` } -func (t ToolFunctionDefinition) ParametersMap() map[string]any { - if len(t.Parameters) == 0 { +func (t *ToolFunctionDefinition) ParametersMap() map[string]any { + if t == nil || len(t.Parameters) == 0 { return nil } var params map[string]any diff --git a/todo/TASKS-1.md b/todo/TASKS-1.md index 3a2c8281e..bb2685e41 100644 --- a/todo/TASKS-1.md +++ b/todo/TASKS-1.md @@ -1,4 +1,6 @@ -# TASKS-1: Memory & Performance Optimization`r`n`r`n> ✅ 2026-03-04: D-1 / D-2 / D-3 / D-4 / D-6 と stats.Tracker 定期フラッシュを実装済み。 +# TASKS-1: Memory & Performance Optimization + +> ✅ 2026-03-04: D-1 / D-2 / D-3 / D-4 / D-6 と stats.Tracker 定期フラッシュを実装済み。 内部リファクタリング。外部APIの変更なし。他トラックへの依存なし。 @@ -74,3 +76,16 @@ D-1 の解決策と合わせて、メソッド境界を「必要な情報の単 - ToolFunctionDefinition.Parameters の Marshal がプロバイダー初期化時のみ (D-3) - stats.json の書き込み頻度が 98% 削減 (stats.Tracker) + +## 作業報告 (2026-03-05) + +- 実装完了: D-1 / D-2 / D-3 / D-4 / D-6、stats.Tracker 定期フラッシュ +- 主要変更: + - MemoryStore に長期メモリキャッシュとパース済み plan state キャッシュを導入 + - FunctionCall.Arguments を map 中心に統一し、JSON 文字列は内部互換層で吸収 + - ToolFunctionDefinition.Parameters を json.RawMessage 化し、各 provider で必要時 decode + - Web 検索(Brave/Tavily/DuckDuckGo)の結果整形を共通化 +- 検証結果: + - Linux (WSL): go generate ./... 成功 + - Linux (WSL): go test ./... 成功 + - 差分 lint: golangci-lint run -n 0 issues From d84f2cb5ac4cb2686932353ce6a78e9c01632846 Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:11:34 +0900 Subject: [PATCH 3/3] docs(tasks): align CLAUDE and TASKS-2 with task1 state --- CLAUDE.md | 5 +++-- todo/TASKS-2.md | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d89a8baac..2909a3f9a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,12 +55,13 @@ Lint: `golangci-lint run` ## 未実装タスク -以下の `todo/` ファイルに分割。各ファイルは互いに依存関係がなく、別ブランチで並列実装可能。 +以下の `todo/` ファイルに分割。基本は別ブランチで並列実装可能(※ TASKS-2 は TASKS-1 の型変更前提あり)。 | ファイル | 概要 | |---|---| | [`todo/TASKS-1.md`](todo/TASKS-1.md) | ~~**Memory & Performance Optimization**~~ ✅ 実装済み(MemoryStore キャッシュ+パース済み state、FunctionCall.Arguments map統一、ToolDefinition.Parameters RawMessage化、検索結果フォーマット共通化、stats 定期フラッシュ) | -| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode | +| [`todo/TASKS-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan Mode(TASKS-1 の型変更前提メモ追記済み) | | [`todo/TASKS-3.md`](todo/TASKS-3.md) | **Session DAG (SQLite Store)** — セッション管理の SQLite 移行、Turn ベース線形+セッション間 DAG、Fork/Report フロー | | [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | | [`todo/TASKS-5.md`](todo/TASKS-5.md) | ~~**Heartbeat Worktree Management**~~ ✅ 実装済み(`/plan worktrees` の `list/inspect/merge/dispose`、安全化した `PruneOrphaned`、Mini App `/miniapp/api/worktrees` + Git タブ UI) | + diff --git a/todo/TASKS-2.md b/todo/TASKS-2.md index 78fc45fd3..8233e3b5f 100644 --- a/todo/TASKS-2.md +++ b/todo/TASKS-2.md @@ -1,5 +1,17 @@ # TASKS-2: Subagent Orchestration (Container Model) +## TASKS-1 反映メモ (2026-03-05) + +TASKS-2 実装時は以下の型変更を前提にすること。 + +- `FunctionCall.Arguments` は JSON 文字列ではなく `map[string]any` 扱い。 + - 旧来の `json.Unmarshal([]byte(tc.Function.Arguments), ...)` 前提コードは不要。 +- `ToolFunctionDefinition.Parameters` は `json.RawMessage`。 + - 生成時は `providers.MustMarshalParameters(...)` か `SetParametersMap(...)` を利用。 + - `map[string]any` を直接代入しない。 +- `MemoryStore` はキャッシュ化済み。 + - `GetPlanTaskName` / `GetPlanWorkDir` / `GetMemoryContext` を優先して利用し、`ReadLongTerm()` 直叩きは最小化する。 + SubagentManager を Container ベースの Orchestrator に進化させる。 セッション管理の内部実装 (TASKS-3) には依存しない — 現行の SessionManager 上で動作させ、後から SessionStore に差し替える。 @@ -225,3 +237,6 @@ conductor は spawn 後に Delegated に記録、結果受信後に Findings に - Deliberate preset の clarifying → review → executing フロー動作 - SandboxConfig による exec 制限が全 preset で正しく enforcement - escalation chain (subagent → conductor → human) が動作 + + +