feat(task1): implement memory and performance optimizations
This commit is contained in:
parent
c3053052ee
commit
1ecd70c62a
25 changed files with 579 additions and 220 deletions
|
|
@ -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** — 静的配信の汎用化、バンドラ導入、フロントエンドテスト追加 |
|
||||
|
|
|
|||
|
|
@ -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("]")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -317,19 +317,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool)
|
|||
return "", "", false
|
||||
}
|
||||
|
||||
if len(tc.Arguments) > 0 {
|
||||
argsJSON, err := json.Marshal(tc.Arguments)
|
||||
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 {
|
||||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, ¶ms); err != nil {
|
||||
return nil
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func (t *ToolFunctionDefinition) SetParametersMap(params map[string]any) error {
|
||||
if len(params) == 0 {
|
||||
t.Parameters = json.RawMessage(`{}`)
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Parameters = json.RawMessage(payload)
|
||||
return nil
|
||||
}
|
||||
|
||||
func MustMarshalParameters(params map[string]any) json.RawMessage {
|
||||
if len(params) == 0 {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
payload, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
return json.RawMessage(payload)
|
||||
}
|
||||
|
||||
// StreamEvent represents a single chunk from an SSE streaming response.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{}}},
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue