merge: bring task1 memory/performance and docs updates
This commit is contained in:
commit
e4af360184
26 changed files with 609 additions and 221 deletions
|
|
@ -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 Mode(TASKS-1 の型変更前提メモ追記済み) |
|
||||||
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: SQLite SessionStore、LegacyAdapter、Fork/Report、CompactOldTurns、`/session` CLI コマンド、Mini App グラフ UI) |
|
| [`todo/TASKS-3.md`](todo/TASKS-3.md) | ~~**Session DAG (SQLite Store)**~~ ✅ 実装済み(Phase 0–3: 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 完了後に着手 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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("]")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
},
|
},
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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, ¶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.
|
// StreamEvent represents a single chunk from an SSE streaming response.
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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{}}},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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" {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 なし
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue