feat(task1): implement memory and performance optimizations

This commit is contained in:
dj-oyu 2026-03-04 23:54:01 +09:00
parent c3053052ee
commit 1ecd70c62a
25 changed files with 579 additions and 220 deletions

View file

@ -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** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 |

View file

@ -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 := ""
@ -2851,9 +2850,10 @@ func (al *AgentLoop) runLLMIteration(
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("]")

View file

@ -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: <description>".
@ -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)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -317,21 +317,21 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
return "", "", false
}
if len(tc.Arguments) > 0 {
argsJSON, err := json.Marshal(tc.Arguments)
args := 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 {
return "", "", false
}
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 {
capHint := len(tools)
if enableWebSearch {
@ -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),
},
})
}
}

View file

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

View file

@ -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 != "" {
@ -564,10 +573,21 @@ func parseResponse(body []byte) (*LLMResponse, error) {
type openaiMessage struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
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
// SystemParts field so it doesn't leak into the JSON payload sent to
// OpenAI-compatible APIs (some strict endpoints reject unknown fields).
@ -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 {

View file

@ -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 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 {
@ -79,7 +150,42 @@ type ToolDefinition struct {
type ToolFunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
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, &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.

View file

@ -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("</parameter>"):]
}
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),
},
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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