merge: bring task1 memory/performance and docs updates

This commit is contained in:
dj-oyu 2026-03-05 00:14:10 +09:00
commit e4af360184
26 changed files with 609 additions and 221 deletions

View file

@ -56,13 +56,14 @@ Lint: `golangci-lint run`
## 未実装タスク ## 未実装タスク
以下の `todo/` ファイルに分割。各ファイルは互いに依存関係がなく、別ブランチで並列実装可能 以下の `todo/` ファイルに分割。基本は別ブランチで並列実装可能(※ TASKS-2 は TASKS-1 の型変更前提あり)
| ファイル | 概要 | | ファイル | 概要 |
|---|---| |---|---|
| [`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-2.md`](todo/TASKS-2.md) | **Subagent Orchestration (Container Model)** — SubagentContainer、Orchestrator、Presets enforcement、Subagent Plan ModeTASKS-1 の型変更前提メモ追記済み) |
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済みPhase 03: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI | | [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済みPhase 03: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI |
| [`todo/TASKS-4.md`](todo/TASKS-4.md) | **Mini App & Static Serving** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 | | [`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 | | [`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 |
| [`todo/TASKS-6.md`](todo/TASKS-6.md) | **SOUL.md — AI Persona Evolution** — 睡眠フェーズで体験を統合・忘却し人格を再構成。TASKS-2 完了後に着手 | | [`todo/TASKS-6.md`](todo/TASKS-6.md) | **SOUL.md — AI Persona Evolution** — 睡眠フェーズで体験を統合・忘却し人格を再構成。TASKS-2 完了後に着手 |

View file

@ -2839,7 +2839,6 @@ func (al *AgentLoop) runLLMIteration(
ReasoningContent: response.ReasoningContent, ReasoningContent: response.ReasoningContent,
} }
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
// Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
extraContent := tc.ExtraContent extraContent := tc.ExtraContent
thoughtSignature := "" thoughtSignature := ""
@ -2851,9 +2850,10 @@ func (al *AgentLoop) runLLMIteration(
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
Name: tc.Name, Name: tc.Name,
Arguments: tc.Arguments,
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: tc.Name, Name: tc.Name,
Arguments: string(argumentsJSON), Arguments: tc.Arguments,
ThoughtSignature: thoughtSignature, ThoughtSignature: thoughtSignature,
}, },
ExtraContent: extraContent, ExtraContent: extraContent,
@ -3367,8 +3367,13 @@ func formatMessagesForLog(messages []providers.Message) string {
sb.WriteString(" ToolCalls:\n") sb.WriteString(" ToolCalls:\n")
for _, tc := range msg.ToolCalls { for _, tc := range msg.ToolCalls {
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil { args := tc.Arguments
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200)) 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, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description) fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 { 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("]") sb.WriteString("]")

View file

@ -13,6 +13,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/fileutil"
@ -25,6 +26,35 @@ type MemoryStore struct {
workspace string workspace string
memoryDir string memoryDir string
memoryFile 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. // NewMemoryStore creates a new MemoryStore with the given workspace path.
@ -51,20 +81,165 @@ func (ms *MemoryStore) getTodayFile() string {
return filePath 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). // ReadLongTerm reads the long-term memory (MEMORY.md).
// Returns empty string if the file doesn't exist. // Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadLongTerm() string { func (ms *MemoryStore) ReadLongTerm() string {
if data, err := os.ReadFile(ms.memoryFile); err == nil { return ms.readLongTermCached()
return string(data)
}
return ""
} }
// WriteLongTerm writes content to the long-term memory file (MEMORY.md). // WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error { func (ms *MemoryStore) WriteLongTerm(content string) error {
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions. // 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. // 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) { if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) {
return err return err
} }
ms.InvalidateCache()
return nil return nil
} }
@ -151,50 +327,27 @@ var (
// HasActivePlan returns true if MEMORY.md contains an active plan. // HasActivePlan returns true if MEMORY.md contains an active plan.
func (ms *MemoryStore) HasActivePlan() bool { func (ms *MemoryStore) HasActivePlan() bool {
content := ms.ReadLongTerm() return ms.getParsedPlanState().hasActivePlan
return reActivePlan.MatchString(content)
} }
// GetPlanStatus returns the plan status: "interviewing", "executing", or "". // GetPlanStatus returns the plan status: "interviewing", "executing", or "".
func (ms *MemoryStore) GetPlanStatus() string { func (ms *MemoryStore) GetPlanStatus() string {
content := ms.ReadLongTerm() return ms.getParsedPlanState().status
m := reStatus.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
} }
// GetCurrentPhase returns the current phase number from "> Phase: N". // GetCurrentPhase returns the current phase number from "> Phase: N".
func (ms *MemoryStore) GetCurrentPhase() int { func (ms *MemoryStore) GetCurrentPhase() int {
content := ms.ReadLongTerm() return ms.getParsedPlanState().currentPhase
m := rePhase.FindStringSubmatch(content)
if len(m) < 2 {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
} }
// GetTotalPhases returns the total number of phases (max ## Phase N). // GetTotalPhases returns the total number of phases (max ## Phase N).
func (ms *MemoryStore) GetTotalPhases() int { func (ms *MemoryStore) GetTotalPhases() int {
content := ms.ReadLongTerm() return ms.getParsedPlanState().totalPhases
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
} }
// IsPlanComplete returns true if all steps in all phases are [x]. // IsPlanComplete returns true if all steps in all phases are [x].
func (ms *MemoryStore) IsPlanComplete() bool { func (ms *MemoryStore) IsPlanComplete() bool {
phases := ms.GetPlanPhases() phases := ms.getParsedPlanState().phases
if len(phases) == 0 { if len(phases) == 0 {
return false return false
} }
@ -212,13 +365,12 @@ func (ms *MemoryStore) IsPlanComplete() bool {
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. // IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
func (ms *MemoryStore) IsCurrentPhaseComplete() bool { func (ms *MemoryStore) IsCurrentPhaseComplete() bool {
current := ms.GetCurrentPhase() state := ms.getParsedPlanState()
if current == 0 { if state.currentPhase == 0 {
return false return false
} }
phases := ms.GetPlanPhases() for _, p := range state.phases {
for _, p := range phases { if p.Number == state.currentPhase {
if p.Number == current {
if len(p.Steps) == 0 { if len(p.Steps) == 0 {
return false return false
} }
@ -273,7 +425,7 @@ type PlanStep struct {
// GetPlanPhases parses MEMORY.md and returns all phases with their steps. // GetPlanPhases parses MEMORY.md and returns all phases with their steps.
func (ms *MemoryStore) GetPlanPhases() []PlanPhase { func (ms *MemoryStore) GetPlanPhases() []PlanPhase {
return ms.getPlanPhasesFrom(ms.ReadLongTerm()) return clonePlanPhases(ms.getParsedPlanState().phases)
} }
func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase { 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) // 3. At least one phase header (## Phase N: title)
phases := ms.GetPlanPhases() phases := ms.getPlanPhasesFrom(content)
if len(phases) == 0 { if len(phases) == 0 {
return fmt.Errorf("no '## Phase N:' sections found") 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 "". // GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
func (ms *MemoryStore) GetPlanWorkDir() string { func (ms *MemoryStore) GetPlanWorkDir() string {
content := ms.ReadLongTerm() return ms.getParsedPlanState().workDir
m := reWorkDir.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
} }
// reTaskLine extracts the task name from "> Task: <description>". // reTaskLine extracts the task name from "> Task: <description>".
@ -478,12 +625,7 @@ var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`)
// GetPlanTaskName returns the task description from the plan metadata, or "". // GetPlanTaskName returns the task description from the plan metadata, or "".
func (ms *MemoryStore) GetPlanTaskName() string { func (ms *MemoryStore) GetPlanTaskName() string {
content := ms.ReadLongTerm() return ms.getParsedPlanState().taskName
m := reTaskLine.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return strings.TrimSpace(m[1])
} }
// interviewSeed is the initial content written to MEMORY.md when /plan starts. // 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. // FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators.
func (ms *MemoryStore) FormatPlanDisplay() string { func (ms *MemoryStore) FormatPlanDisplay() string {
content := ms.ReadLongTerm() state := ms.getParsedPlanState()
if !reActivePlan.MatchString(content) { if !state.hasActivePlan {
return "No active plan." 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 var sb strings.Builder
sb.WriteString(fmt.Sprintf("Plan: %s\n", taskLine)) sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName))
sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", status, currentPhase, len(phases))) 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 // Determine phase emoji
var emoji string var emoji string
if p.Number < currentPhase { if p.Number < state.currentPhase {
emoji = "\u2705" // checkmark emoji = "\u2705" // checkmark
} else if p.Number == currentPhase { } else if p.Number == state.currentPhase {
emoji = "\u25B6\uFE0F" // play button emoji = "\u25B6\uFE0F" // play button
} else { } else {
emoji = "\u23F3" // hourglass 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)) sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title))
// Show steps for current and completed phases // Show steps for current and completed phases
if p.Number <= currentPhase { if p.Number <= state.currentPhase {
for _, s := range p.Steps { for _, s := range p.Steps {
if s.Done { if s.Done {
sb.WriteString(" \u2611 " + s.Description + "\n") 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 != "" { if commandsContent != "" {
sb.WriteString("\nCommands:\n") sb.WriteString("\nCommands:\n")
for _, line := range strings.Split(commandsContent, "\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 != "" { if contextContent != "" {
sb.WriteString("\nContext: " + contextContent + "\n") sb.WriteString("\nContext: " + contextContent + "\n")
} }
@ -781,16 +909,12 @@ func (ms *MemoryStore) FormatPlanDisplay() string {
func (ms *MemoryStore) GetMemoryContext() string { func (ms *MemoryStore) GetMemoryContext() string {
var parts []string var parts []string
longTerm := ms.ReadLongTerm() state := ms.getParsedPlanState()
hasActivePlan := longTerm != "" && reActivePlan.MatchString(longTerm) longTerm := state.content
if longTerm != "" { if longTerm != "" {
if hasActivePlan { if state.hasActivePlan {
var status string switch state.status {
if m := reStatus.FindStringSubmatch(longTerm); len(m) >= 2 {
status = strings.TrimSpace(m[1])
}
switch status {
case "interviewing": case "interviewing":
parts = append(parts, ms.getInterviewContextFrom(longTerm)) parts = append(parts, ms.getInterviewContextFrom(longTerm))
case "review": case "review":
@ -804,7 +928,7 @@ func (ms *MemoryStore) GetMemoryContext() string {
} }
// Suppress daily notes when a plan is active to save context // Suppress daily notes when a plan is active to save context
if !hasActivePlan { if !state.hasActivePlan {
recentNotes := ms.GetRecentDailyNotes(3) recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" { if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)

View file

@ -188,16 +188,19 @@ func buildParams(
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
result := make([]anthropic.ToolUnionParam, 0, len(tools)) result := make([]anthropic.ToolUnionParam, 0, len(tools))
for _, t := range tools { for _, t := range tools {
params := t.Function.ParametersMap()
tool := anthropic.ToolParam{ tool := anthropic.ToolParam{
Name: t.Function.Name, Name: t.Function.Name,
InputSchema: anthropic.ToolInputSchemaParam{ InputSchema: anthropic.ToolInputSchemaParam{
Properties: t.Function.Parameters["properties"], Properties: params["properties"],
}, },
} }
if desc := t.Function.Description; desc != "" { if desc := t.Function.Description; desc != "" {
tool.Description = anthropic.String(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)) required := make([]string, 0, len(req))
for _, r := range req { for _, r := range req {
if s, ok := r.(string); ok { if s, ok := r.(string); ok {
@ -205,7 +208,10 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
} }
} }
tool.InputSchema.Required = required tool.InputSchema.Required = required
case []string:
tool.InputSchema.Required = append([]string(nil), req...)
} }
result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
} }
return result return result

View file

@ -9,6 +9,7 @@ import (
"github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go"
anthropicoption "github.com/anthropics/anthropic-sdk-go/option" anthropicoption "github.com/anthropics/anthropic-sdk-go/option"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
) )
func TestBuildParams_BasicMessage(t *testing.T) { func TestBuildParams_BasicMessage(t *testing.T) {
@ -84,13 +85,13 @@ func TestBuildParams_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather for a city", Description: "Get weather for a city",
Parameters: map[string]any{ Parameters: protocoltypes.MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
"required": []any{"city"}, "required": []any{"city"},
}, }),
}, },
}, },
} }

View file

@ -297,7 +297,7 @@ func (p *AntigravityProvider) buildRequest(
if t.Type != "function" { if t.Type != "function" {
continue continue
} }
params := sanitizeSchemaForGemini(t.Function.Parameters) params := sanitizeSchemaForGemini(t.Function.ParametersMap())
funcDecls = append(funcDecls, antigravityFuncDecl{ funcDecls = append(funcDecls, antigravityFuncDecl{
Name: t.Function.Name, Name: t.Function.Name,
Description: t.Function.Description, Description: t.Function.Description,
@ -340,17 +340,13 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) {
thoughtSignature = tc.Function.ThoughtSignature thoughtSignature = tc.Function.ThoughtSignature
} }
if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 {
args = cloneToolArgs(tc.Function.Arguments)
}
if args == nil { if args == nil {
args = map[string]any{} 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 return name, args, thoughtSignature
} }
@ -436,14 +432,13 @@ func (p *AntigravityProvider) parseSSEResponse(body string) (*LLMResponse, error
contentParts = append(contentParts, part.Text) contentParts = append(contentParts, part.Text)
} }
if part.FunctionCall != nil { if part.FunctionCall != nil {
argumentsJSON, _ := json.Marshal(part.FunctionCall.Args)
toolCalls = append(toolCalls, ToolCall{ toolCalls = append(toolCalls, ToolCall{
ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()), ID: fmt.Sprintf("call_%s_%d", part.FunctionCall.Name, time.Now().UnixNano()),
Name: part.FunctionCall.Name, Name: part.FunctionCall.Name,
Arguments: part.FunctionCall.Args, Arguments: part.FunctionCall.Args,
Function: &FunctionCall{ Function: &FunctionCall{
Name: part.FunctionCall.Name, Name: part.FunctionCall.Name,
Arguments: string(argumentsJSON), Arguments: cloneToolArgs(part.FunctionCall.Args),
ThoughtSignature: extractPartThoughtSignature( ThoughtSignature: extractPartThoughtSignature(
part.ThoughtSignature, part.ThoughtSignature,
part.ThoughtSignatureSnake, part.ThoughtSignatureSnake,

View file

@ -12,7 +12,7 @@ func TestBuildRequestUsesFunctionFieldsWhenToolCallNameMissing(t *testing.T) {
ID: "call_read_file_123", ID: "call_read_file_123",
Function: &FunctionCall{ Function: &FunctionCall{
Name: "read_file", Name: "read_file",
Arguments: `{"path":"README.md"}`, Arguments: map[string]any{"path": "README.md"},
}, },
}}, }},
}, },

View file

@ -129,9 +129,8 @@ func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
} }
if len(tool.Function.Parameters) > 0 { if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString("Parameters:\n```json\n") sb.WriteString("Parameters:\n```json\n")
sb.Write(paramsJSON) sb.Write(tool.Function.Parameters)
sb.WriteString("\n```\n") sb.WriteString("\n```\n")
} }
sb.WriteString("\n") sb.WriteString("\n")

View file

@ -619,12 +619,12 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather for a location", Description: "Get weather for a location",
Parameters: map[string]any{ Parameters: MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"location": map[string]any{"type": "string"}, "location": map[string]any{"type": "string"},
}, },
}, }),
}, },
}, },
} }
@ -920,9 +920,9 @@ func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) {
if got[0].Arguments["name"] != "test" { if got[0].Arguments["name"] != "test" {
t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"])
} }
// Verify raw arguments string is preserved in FunctionCall // Verify parsed arguments are also set on FunctionCall
if got[0].Function.Arguments == "" { if len(got[0].Function.Arguments) == 0 {
t.Error("Function.Arguments should contain raw JSON string") t.Error("Function.Arguments should contain parsed JSON arguments")
} }
} }

View file

@ -151,9 +151,8 @@ func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string {
sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description))
} }
if len(tool.Function.Parameters) > 0 { if len(tool.Function.Parameters) > 0 {
paramsJSON, _ := json.Marshal(tool.Function.Parameters)
sb.WriteString("Parameters:\n```json\n") sb.WriteString("Parameters:\n```json\n")
sb.Write(paramsJSON) sb.Write(tool.Function.Parameters)
sb.WriteString("\n```\n") sb.WriteString("\n```\n")
} }
sb.WriteString("\n") sb.WriteString("\n")

View file

@ -76,8 +76,8 @@ func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) {
if resp.ToolCalls[0].ID != "call_1" { if resp.ToolCalls[0].ID != "call_1" {
t.Errorf("ToolCalls[0].ID = %q, want %q", 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"}` { if resp.ToolCalls[0].Function.Arguments["path"] != "/tmp/test.txt" {
t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) t.Errorf("ToolCalls[0].Function.Arguments[path] = %v", resp.ToolCalls[0].Function.Arguments["path"])
} }
// Content should have the tool call JSON stripped // Content should have the tool call JSON stripped
if strings.Contains(resp.Content, "tool_calls") { if strings.Contains(resp.Content, "tool_calls") {
@ -292,12 +292,12 @@ func TestBuildPrompt_WithTools(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get current weather", Description: "Get current weather",
Parameters: map[string]any{ Parameters: MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
}, }),
}, },
}, },
} }

View file

@ -317,21 +317,21 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
return "", "", false return "", "", false
} }
if len(tc.Arguments) > 0 { args := tc.Arguments
argsJSON, err := json.Marshal(tc.Arguments) if len(args) == 0 && tc.Function != nil {
args = tc.Function.Arguments
}
if len(args) == 0 {
return name, "{}", true
}
argsJSON, err := json.Marshal(args)
if err != nil { if err != nil {
return "", "", false return "", "", false
} }
return name, string(argsJSON), true return name, string(argsJSON), true
} }
if tc.Function != nil && tc.Function.Arguments != "" {
return name, tc.Function.Arguments, true
}
return name, "{}", true
}
func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam {
capHint := len(tools) capHint := len(tools)
if enableWebSearch { if enableWebSearch {
@ -345,9 +345,13 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp
if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") {
continue continue
} }
params := t.Function.ParametersMap()
if params == nil {
params = map[string]any{}
}
ft := responses.FunctionToolParam{ ft := responses.FunctionToolParam{
Name: t.Function.Name, Name: t.Function.Name,
Parameters: t.Function.Parameters, Parameters: params,
Strict: openai.Opt(false), Strict: openai.Opt(false),
} }
if t.Function.Description != "" { if t.Function.Description != "" {
@ -382,6 +386,10 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse {
ID: item.CallID, ID: item.CallID,
Name: item.Name, Name: item.Name,
Arguments: args, Arguments: args,
Function: &FunctionCall{
Name: item.Name,
Arguments: cloneToolArgs(args),
},
}) })
} }
} }

View file

@ -79,7 +79,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) {
Type: "function", Type: "function",
Function: &FunctionCall{ Function: &FunctionCall{
Name: "read_file", 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{ Function: ToolFunctionDefinition{
Name: "get_weather", Name: "get_weather",
Description: "Get weather", Description: "Get weather",
Parameters: map[string]any{ Parameters: MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"city": map[string]any{"type": "string"}, "city": map[string]any{"type": "string"},
}, },
}, }),
}, },
}, },
} }
@ -166,9 +166,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "web_search", Name: "web_search",
Description: "local web search", Description: "local web search",
Parameters: map[string]any{ Parameters: MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
}, }),
}, },
}, },
{ {
@ -176,9 +176,9 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) {
Function: ToolFunctionDefinition{ Function: ToolFunctionDefinition{
Name: "read_file", Name: "read_file",
Description: "read file", Description: "read file",
Parameters: map[string]any{ Parameters: MustMarshalParameters(map[string]any{
"type": "object", "type": "object",
}, }),
}, },
}, },
} }

View file

@ -462,6 +462,10 @@ func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error)
ID: tc.ID, ID: tc.ID,
Name: tc.Name, Name: tc.Name,
Arguments: arguments, Arguments: arguments,
Function: &FunctionCall{
Name: tc.Name,
Arguments: cloneOpenAIToolArgs(arguments),
},
}) })
} }
@ -534,6 +538,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
Name: name, Name: name,
Arguments: arguments, Arguments: arguments,
ThoughtSignature: thoughtSignature, ThoughtSignature: thoughtSignature,
Function: &FunctionCall{
Name: name,
Arguments: cloneOpenAIToolArgs(arguments),
ThoughtSignature: thoughtSignature,
},
} }
if thoughtSignature != "" { if thoughtSignature != "" {
@ -564,10 +573,21 @@ func parseResponse(body []byte) (*LLMResponse, error) {
type openaiMessage struct { type openaiMessage struct {
Role string `json:"role"` Role string `json:"role"`
Content string `json:"content"` Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []openaiToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,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 // stripSystemParts converts []Message to []openaiMessage, dropping the
// SystemParts field so it doesn't leak into the JSON payload sent to // SystemParts field so it doesn't leak into the JSON payload sent to
// OpenAI-compatible APIs (some strict endpoints reject unknown fields). // OpenAI-compatible APIs (some strict endpoints reject unknown fields).
@ -577,13 +597,74 @@ func stripSystemParts(messages []Message) []openaiMessage {
out[i] = openaiMessage{ out[i] = openaiMessage{
Role: m.Role, Role: m.Role,
Content: m.Content, Content: m.Content,
ToolCalls: m.ToolCalls, ToolCalls: toOpenAIWireToolCalls(m.ToolCalls),
ToolCallID: m.ToolCallID, ToolCallID: m.ToolCallID,
} }
} }
return out 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 { func normalizeModel(model, apiBase string) string {
before, after, ok := strings.Cut(model, "/") before, after, ok := strings.Cut(model, "/")
if !ok { if !ok {

View file

@ -1,5 +1,10 @@
package protocoltypes package protocoltypes
import (
"encoding/json"
"strings"
)
type ToolCall struct { type ToolCall struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
@ -19,9 +24,75 @@ type GoogleExtra struct {
} }
type FunctionCall struct { type FunctionCall struct {
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"` Name string `json:"name"`
Arguments string `json:"arguments"` Arguments string `json:"arguments"`
ThoughtSignature string `json:"thought_signature,omitempty"` 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 { type LLMResponse struct {
@ -79,7 +150,42 @@ type ToolDefinition struct {
type ToolFunctionDefinition struct { type ToolFunctionDefinition struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Parameters map[string]any `json:"parameters"` Parameters json.RawMessage `json:"parameters"`
}
func (t *ToolFunctionDefinition) ParametersMap() map[string]any {
if t == nil || len(t.Parameters) == 0 {
return nil
}
var params map[string]any
if err := json.Unmarshal(t.Parameters, &params); 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. // StreamEvent represents a single chunk from an SSE streaming response.

View file

@ -41,7 +41,9 @@ func extractToolCallsFromText(text string) []ToolCall {
var result []ToolCall var result []ToolCall
for _, tc := range wrapper.ToolCalls { for _, tc := range wrapper.ToolCalls {
var args map[string]any 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{ result = append(result, ToolCall{
ID: tc.ID, ID: tc.ID,
@ -50,7 +52,7 @@ func extractToolCallsFromText(text string) []ToolCall {
Arguments: args, Arguments: args,
Function: &FunctionCall{ Function: &FunctionCall{
Name: tc.Function.Name, 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("</parameter>"):] paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):]
} }
argsJSON, _ := json.Marshal(args)
*callIdx++ *callIdx++
result = append(result, ToolCall{ result = append(result, ToolCall{
ID: fmt.Sprintf("xmltc_%d", *callIdx), ID: fmt.Sprintf("xmltc_%d", *callIdx),
@ -287,7 +288,7 @@ func parseInvokeElements(text string, callIdx *int) []ToolCall {
Arguments: args, Arguments: args,
Function: &FunctionCall{ Function: &FunctionCall{
Name: toolName, Name: toolName,
Arguments: string(argsJSON), Arguments: cloneToolArgs(args),
}, },
}) })
} }

View file

@ -5,38 +5,32 @@
package providers package providers
import "encoding/json"
// NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // 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) // It handles cases where Name/Arguments might be in different locations (top-level vs Function)
// and ensures both are populated consistently. // and ensures both are populated consistently.
func NormalizeToolCall(tc ToolCall) ToolCall { func NormalizeToolCall(tc ToolCall) ToolCall {
normalized := tc 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 { if normalized.Name == "" && normalized.Function != nil {
normalized.Name = normalized.Function.Name normalized.Name = normalized.Function.Name
} }
// Ensure Arguments is not nil // Ensure Arguments is not nil.
if normalized.Arguments == nil { if normalized.Arguments == nil {
normalized.Arguments = map[string]any{} normalized.Arguments = map[string]any{}
} }
// Parse Arguments from Function.Arguments if not already set // Populate top-level arguments from Function arguments when needed.
if len(normalized.Arguments) == 0 && normalized.Function != nil && normalized.Function.Arguments != "" { if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 {
var parsed map[string]any normalized.Arguments = cloneToolArgs(normalized.Function.Arguments)
if err := json.Unmarshal([]byte(normalized.Function.Arguments), &parsed); err == nil && parsed != nil {
normalized.Arguments = parsed
}
} }
// Ensure Function is populated with consistent values // Ensure Function is populated with consistent values.
argsJSON, _ := json.Marshal(normalized.Arguments)
if normalized.Function == nil { if normalized.Function == nil {
normalized.Function = &FunctionCall{ normalized.Function = &FunctionCall{
Name: normalized.Name, Name: normalized.Name,
Arguments: string(argsJSON), Arguments: cloneToolArgs(normalized.Arguments),
} }
} else { } else {
if normalized.Function.Name == "" { if normalized.Function.Name == "" {
@ -45,10 +39,21 @@ func NormalizeToolCall(tc ToolCall) ToolCall {
if normalized.Name == "" { if normalized.Name == "" {
normalized.Name = normalized.Function.Name normalized.Name = normalized.Function.Name
} }
if normalized.Function.Arguments == "" { if len(normalized.Function.Arguments) == 0 {
normalized.Function.Arguments = string(argsJSON) normalized.Function.Arguments = cloneToolArgs(normalized.Arguments)
} }
} }
return normalized 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
}

View file

@ -2,6 +2,7 @@ package providers
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
@ -97,3 +98,7 @@ type ModelConfig struct {
Primary string Primary string
Fallbacks []string Fallbacks []string
} }
func MustMarshalParameters(params map[string]any) json.RawMessage {
return protocoltypes.MustMarshalParameters(params)
}

View file

@ -126,7 +126,7 @@ func TestBackend_AddFullMessage(t *testing.T) {
Content: "sure", Content: "sure",
ToolCalls: []providers.ToolCall{ 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{}}},
}, },
}) })

View file

@ -412,7 +412,7 @@ func TestSQLite_MessagesRoundTrip(t *testing.T) {
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: "exec", 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) t.Errorf("tool call function name mismatch: %s", got[1].ToolCalls[0].Function.Name)
} }
if got[1].ToolCalls[0].Function.Arguments != `{"cmd":"ls"}` { if got[1].ToolCalls[0].Function.Arguments["cmd"] != "ls" {
t.Errorf("tool call arguments mismatch: %s", got[1].ToolCalls[0].Function.Arguments) t.Errorf("tool call arguments mismatch: %v", got[1].ToolCalls[0].Function.Arguments)
} }
if got[2].ToolCallID != "call_1" { if got[2].ToolCallID != "call_1" {

View file

@ -2,6 +2,7 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@ -183,12 +184,19 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
desc, _ := fn["description"].(string) desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any) 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{ definitions = append(definitions, providers.ToolDefinition{
Type: "function", Type: "function",
Function: providers.ToolFunctionDefinition{ Function: providers.ToolFunctionDefinition{
Name: name, Name: name,
Description: desc, Description: desc,
Parameters: params, Parameters: paramsRaw,
}, },
}) })
} }

View file

@ -285,7 +285,7 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) {
Function: providers.ToolFunctionDefinition{ Function: providers.ToolFunctionDefinition{
Name: "beta", Name: "beta",
Description: "tool B", Description: "tool B",
Parameters: params, Parameters: providers.MustMarshalParameters(params),
}, },
} }
got := defs[0] got := defs[0]

View file

@ -124,7 +124,6 @@ func RunToolLoop(
Content: response.Content, Content: response.Content,
} }
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
@ -132,7 +131,7 @@ func RunToolLoop(
Arguments: tc.Arguments, Arguments: tc.Arguments,
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: tc.Name, Name: tc.Name,
Arguments: string(argumentsJSON), Arguments: tc.Arguments,
}, },
}) })
} }

View file

@ -79,6 +79,36 @@ type SearchProvider interface {
Search(ctx context.Context, query string, count int) (string, error) 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 { type BraveSearchProvider struct {
apiKey string apiKey string
proxy string proxy string
@ -125,23 +155,16 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
} }
results := searchResp.Web.Results results := searchResp.Web.Results
if len(results) == 0 { items := make([]searchResultItem, 0, len(results))
return fmt.Sprintf("No results for: %s", query), nil for _, item := range results {
items = append(items, searchResultItem{
Title: item.Title,
URL: item.URL,
Snippet: item.Description,
})
} }
var sb strings.Builder return formatWebSearchResults(query, "", items, count), nil
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
} }
type TavilySearchProvider struct { type TavilySearchProvider struct {
@ -208,23 +231,16 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
} }
results := searchResp.Results results := searchResp.Results
if len(results) == 0 { items := make([]searchResultItem, 0, len(results))
return fmt.Sprintf("No results for: %s", query), nil for _, item := range results {
items = append(items, searchResultItem{
Title: item.Title,
URL: item.URL,
Snippet: item.Content,
})
} }
var sb strings.Builder return formatWebSearchResults(query, "Tavily", items, count), nil
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
} }
type DuckDuckGoSearchProvider struct { 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 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) snippetMatches := reDDGSnippet.FindAllStringSubmatch(html, count+5)
maxItems := min(len(matches), count) maxItems := min(len(matches), count)
items := make([]searchResultItem, 0, maxItems)
for i := range maxItems { for i := range maxItems {
urlStr := matches[i][1] 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 // Attempt to attach snippet if available and index aligns
if i < len(snippetMatches) { if i < len(snippetMatches) {
snippet := stripTags(snippetMatches[i][1]) snippet = stripTags(snippetMatches[i][1])
snippet = strings.TrimSpace(snippet) snippet = strings.TrimSpace(snippet)
if snippet != "" {
fmt.Fprintf(&sb, "\n %s", snippet)
}
}
} }
return sb.String(), nil items = append(items, searchResultItem{
Title: title,
URL: urlStr,
Snippet: snippet,
})
}
return formatWebSearchResults(query, "DuckDuckGo", items, count), nil
} }
func stripTags(content string) string { func stripTags(content string) string {

View file

@ -1,5 +1,7 @@
# TASKS-1: Memory & Performance Optimization # TASKS-1: Memory & Performance Optimization
> ✅ 2026-03-04: D-1 / D-2 / D-3 / D-4 / D-6 と stats.Tracker 定期フラッシュを実装済み。
内部リファクタリング。外部APIの変更なし。他トラックへの依存なし。 内部リファクタリング。外部APIの変更なし。他トラックへの依存なし。
## タスク一覧 ## タスク一覧
@ -73,3 +75,17 @@ D-1 の解決策と合わせて、メソッド境界を「必要な情報の単
- FunctionCall の Unmarshal が1回に集約 (D-2) - FunctionCall の Unmarshal が1回に集約 (D-2)
- ToolFunctionDefinition.Parameters の Marshal がプロバイダー初期化時のみ (D-3) - ToolFunctionDefinition.Parameters の Marshal がプロバイダー初期化時のみ (D-3)
- stats.json の書き込み頻度が 98% 削減 (stats.Tracker) - 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

View file

@ -1,5 +1,17 @@
# TASKS-2: Subagent Orchestration (Container Model) # 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 に進化させる。 SubagentManager を Container ベースの Orchestrator に進化させる。
escalation chain質問→回答と Deliberate preset の plan mode を追加。 escalation chain質問→回答と Deliberate preset の plan mode を追加。
@ -669,5 +681,6 @@ Phase 3: Context Injection + Guidance (Task 6-7) ← 独立して先行も可
- Deliberate preset (coder/worker): clarifying → review → executing の 3 段階動作 - Deliberate preset (coder/worker): clarifying → review → executing の 3 段階動作
- `ask_conductor` → conductor LLM 回答 → subagent 再開 のラウンドトリップ - `ask_conductor` → conductor LLM 回答 → subagent 再開 のラウンドトリップ
- conductor が回答不可 → message tool で human escalate → 回答転送 - conductor が回答不可 → message tool で human escalate → 回答転送
- SandboxConfig による exec 制限が全 preset で正しく enforcement
- MEMORY.md Orchestration セクションが conductor guidance に含まれる - MEMORY.md Orchestration セクションが conductor guidance に含まれる
- 既存の spawn/subagent E2E フローExploratoryに regression なし - 既存の spawn/subagent E2E フローExploratoryに regression なし