diff --git a/pkg/agent/context.go b/pkg/agent/context.go index da5af23a9..043cc2e7f 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -44,7 +44,7 @@ When results come back, synthesize and decide the next fork.` type ContextBuilder struct { workspace string - workDir string // session-specific working directory (worktree or project subdir) + workDir string // session-specific working directory (worktree or project subdir) skillsLoader *skills.SkillsLoader memory *MemoryStore tools *tools.ToolRegistry // Direct reference to tool registry diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index b9d39ee0d..5403c190e 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -19,30 +19,30 @@ import ( // AgentInstance represents a fully configured agent with its own workspace, // session manager, context builder, and tool registry. type AgentInstance struct { - ID string - Name string - Model string - Fallbacks []string - Workspace string + ID string + Name string + Model string + Fallbacks []string + Workspace string MaxIterations int TaskReminderInterval int MaxTokens int Temperature float64 ContextWindow int - Provider providers.LLMProvider - Sessions *session.SessionManager - ContextBuilder *ContextBuilder - Tools *tools.ToolRegistry - Subagents *config.SubagentsConfig - SkillsFilter []string - Candidates []providers.FallbackCandidate - PlanModel string - PlanFallbacks []string - PlanCandidates []providers.FallbackCandidate + Provider providers.LLMProvider + Sessions *session.SessionManager + ContextBuilder *ContextBuilder + Tools *tools.ToolRegistry + Subagents *config.SubagentsConfig + SkillsFilter []string + Candidates []providers.FallbackCandidate + PlanModel string + PlanFallbacks []string + PlanCandidates []providers.FallbackCandidate // Interview staleness tracking: consecutive turns where MEMORY.md was not updated. - interviewStaleCount int - interviewMemoryLen int + interviewStaleCount int + interviewMemoryLen int // Per-session worktree isolation worktrees map[string]*git.WorktreeInfo // sessionKey → worktree @@ -189,26 +189,26 @@ func NewAgentInstance( } return &AgentInstance{ - ID: agentID, - Name: agentName, - Model: model, - Fallbacks: fallbacks, - Workspace: workspace, + ID: agentID, + Name: agentName, + Model: model, + Fallbacks: fallbacks, + Workspace: workspace, MaxIterations: maxIter, TaskReminderInterval: reminderInterval, MaxTokens: maxTokens, Temperature: temperature, ContextWindow: maxTokens, - Provider: provider, - Sessions: sessionsManager, - ContextBuilder: contextBuilder, - Tools: toolsRegistry, - Subagents: subagents, - SkillsFilter: skillsFilter, - Candidates: candidates, - PlanModel: planModel, - PlanFallbacks: planFallbacks, - PlanCandidates: planCandidates, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, + PlanModel: planModel, + PlanFallbacks: planFallbacks, + PlanCandidates: planCandidates, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8b6c10471..6914ede4e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -41,24 +41,24 @@ import ( // activeTask tracks a running agent task for live status and intervention. type activeTask struct { - Description string - Iteration int - MaxIter int - StartedAt time.Time - cancel context.CancelFunc - interrupt chan string // buffered 1, for user message injection - toolLog []toolLogEntry - lastError *toolLogEntry // sticky: most recent error, persists across iterations - projectDir string // detected from exec cd target (authoritative) - fileCommonDir string // LCP of file paths relative to workspace (fallback) + Description string + Iteration int + MaxIter int + StartedAt time.Time + cancel context.CancelFunc + interrupt chan string // buffered 1, for user message injection + toolLog []toolLogEntry + lastError *toolLogEntry // sticky: most recent error, persists across iterations + projectDir string // detected from exec cd target (authoritative) + fileCommonDir string // LCP of file paths relative to workspace (fallback) mu sync.Mutex } // toolLogEntry records a single tool call for the live terminal view. type toolLogEntry struct { - Name string - ArgsSnip string // first ~80 chars of args - Result string // "✓ 4.9s" or "✗ 3.2s" + Name string + ArgsSnip string // first ~80 chars of args + Result string // "✓ 4.9s" or "✗ 3.2s" ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1" } @@ -78,26 +78,26 @@ func newSessionSemaphore() *sessionSemaphore { } type AgentLoop struct { - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - stats *stats.Tracker // nil when --stats not passed - running atomic.Bool - summarizing sync.Map + bus *bus.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + stats *stats.Tracker // nil when --stats not passed + running atomic.Bool + summarizing sync.Map fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore providerCache map[string]providers.LLMProvider - planStartPending bool // set by /plan start to trigger LLM execution - planClearHistory bool // set by /plan start clear to wipe history on transition + planStartPending bool // set by /plan start to trigger LLM execution + planClearHistory bool // set by /plan start clear to wipe history on transition sessionLocks sync.Map // sessionKey → *sessionSemaphore activeTasks sync.Map // sessionKey → *activeTask sessions *SessionTracker - lastSystemPrompt atomic.Value // string — last system prompt sent to LLM - promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read - OnStateChange func() // called on plan/session/skills mutations - OnUserMessage func() // called when a real user message is processed + lastSystemPrompt atomic.Value // string — last system prompt sent to LLM + promptDirty atomic.Bool // true = rebuild needed on next GetSystemPrompt read + OnStateChange func() // called on plan/session/skills mutations + OnUserMessage func() // called when a real user message is processed orchBroadcaster *orch.Broadcaster // nil when --orchestration not set orchReporter orch.AgentReporter // always non-nil (Noop when disabled) } @@ -601,8 +601,8 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha DefaultResponse: defaultResponse, EnableSummary: false, SendResponse: false, - NoHistory: true, // Don't load session history for heartbeat - Background: true, // Enable live task notifications on Telegram + NoHistory: true, // Don't load session history for heartbeat + Background: true, // Enable live task notifications on Telegram }) } @@ -976,8 +976,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if removedCount > 0 { logger.WarnCF("agent", "Sanitized session history: removed orphaned messages", map[string]any{ - "session_key": opts.SessionKey, - "removed_count": removedCount, + "session_key": opts.SessionKey, + "removed_count": removedCount, }) // Persist the sanitized history agent.Sessions.SetHistory(opts.SessionKey, history) @@ -1174,8 +1174,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt } // Task reminder constants and helpers. -const taskReminderMaxChars = 500 -const blockerMaxChars = 200 +const ( + taskReminderMaxChars = 500 + blockerMaxChars = 200 +) func shouldInjectReminder(iteration, interval int) bool { if interval <= 0 { @@ -1361,7 +1363,7 @@ func buildArgsSnippet(toolName string, args map[string]interface{}, workspace st if runes := []rune(path); len(runes) > maxPath { // Find last slash to extract filename if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 { - filename := path[lastSlash:] // includes "/" + filename := path[lastSlash:] // includes "/" dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…" if dirBudget > 0 { dir := []rune(path[:lastSlash]) @@ -1509,10 +1511,10 @@ func compressRepeats(s string) string { // Display layout constants. const ( - displayPastEntries = 4 // number of compact 1-line past entries - displayErrorLines = 5 // content lines inside the error code block - statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" - streamingDisplayLines = 17 // line count matching buildRichStatus output + displayPastEntries = 4 // number of compact 1-line past entries + displayErrorLines = 5 // content lines inside the error code block + statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" + streamingDisplayLines = 17 // line count matching buildRichStatus output ) // buildRichStatus builds a fixed-height terminal-like status display. @@ -3338,16 +3340,16 @@ func isPlanPreExecution(status string) bool { // filterInterviewTools uses this to strip tool *definitions* before the LLM call, // while isToolAllowedDuringInterview adds argument-level checks as a second gate. var interviewAllowedTools = map[string]bool{ - "readfile": true, - "listdir": true, - "websearch": true, - "webfetch": true, - "message": true, - "editfile": true, + "readfile": true, + "listdir": true, + "websearch": true, + "webfetch": true, + "message": true, + "editfile": true, "appendfile": true, - "writefile": true, - "exec": true, - "logs": true, + "writefile": true, + "exec": true, + "logs": true, } // filterInterviewTools removes tool definitions that are not in the diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index b75d32a45..d8d998f2f 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1664,8 +1664,8 @@ func TestBuildRichStatus(t *testing.T) { mustContain := []string{ "Task in progress (3/20)", "my-projects", - "read_file", // latest entry - "No errors", // no error yet + "read_file", // latest entry + "No errors", // no error yet } for _, s := range mustContain { if !strings.Contains(got, s) { @@ -1833,15 +1833,18 @@ func TestBuildRichStatus_FixedHeight(t *testing.T) { lines0 := countLines(buildRichStatus(task0, true, "/ws/p")) // 1 entry - task1 := &activeTask{Iteration: 1, MaxIter: 10, - toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}} + task1 := &activeTask{ + Iteration: 1, MaxIter: 10, + toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}, + } lines1 := countLines(buildRichStatus(task1, true, "/ws/p")) // 5 entries task5 := &activeTask{Iteration: 5, MaxIter: 10} for i := 0; i < 5; i++ { task5.toolLog = append(task5.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s"}) + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", + }) } lines5 := countLines(buildRichStatus(task5, true, "/ws/p")) @@ -1849,10 +1852,13 @@ func TestBuildRichStatus_FixedHeight(t *testing.T) { task5err := &activeTask{Iteration: 5, MaxIter: 10} for i := 0; i < 5; i++ { task5err.toolLog = append(task5err.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s"}) + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", + }) + } + errEntry := toolLogEntry{ + Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s", + ErrDetail: "FAILED test\nExit code: 1", } - errEntry := toolLogEntry{Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s", - ErrDetail: "FAILED test\nExit code: 1"} task5err.lastError = &errEntry lines5err := countLines(buildRichStatus(task5err, true, "/ws/p")) @@ -2433,9 +2439,9 @@ func TestConsumeStream_OnChunkWithRepetitionDetection(t *testing.T) { // modelCapturingMockProvider records which model was passed to Chat. type modelCapturingMockProvider struct { - mu sync.Mutex - models []string - response string + mu sync.Mutex + models []string + response string } func (m *modelCapturingMockProvider) Chat( diff --git a/pkg/config/config.go b/pkg/config/config.go index 82d5e09ed..8ac57f16c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -171,16 +171,16 @@ type SessionConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead - ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"` - PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"` + PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 58022c761..22cc2a822 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -10,12 +10,12 @@ func DefaultConfig() *Config { return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ - Workspace: "~/.picoclaw/workspace", - RestrictToWorkspace: true, - Provider: "", - Model: "", - MaxTokens: 32768, - Temperature: nil, // nil means use provider default + Workspace: "~/.picoclaw/workspace", + RestrictToWorkspace: true, + Provider: "", + Model: "", + MaxTokens: 32768, + Temperature: nil, // nil means use provider default MaxToolIterations: 50, TaskReminderInterval: 5, }, diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index ef7270380..958c70a59 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -294,10 +294,10 @@ func TestUnsubscribe_ClosesChannel(t *testing.T) { func TestSanitizeFields(t *testing.T) { tests := []struct { - name string - input map[string]any - maskedK []string // keys that should be "***" - safeK []string // keys that should keep original value + name string + input map[string]any + maskedK []string // keys that should be "***" + safeK []string // keys that should keep original value }{ { name: "nil fields", @@ -320,9 +320,9 @@ func TestSanitizeFields(t *testing.T) { maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"}, }, { - name: "safe keys preserved", - input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"}, - safeK: []string{"error", "count", "user_id", "component"}, + name: "safe keys preserved", + input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"}, + safeK: []string{"error", "count", "user_id", "component"}, }, { name: "mixed keys", @@ -363,9 +363,9 @@ func TestRecentLogsSanitizesFields(t *testing.T) { SetLevel(DEBUG) InfoCF("sanitize-test", "log with sensitive fields", map[string]any{ - "token": "my-secret-token", - "api_key": "sk-12345", - "user_id": "safe-value", + "token": "my-secret-token", + "api_key": "sk-12345", + "user_id": "safe-value", }) got := RecentLogs(DEBUG, "sanitize-test", 100) diff --git a/pkg/miniapp/api.go b/pkg/miniapp/api.go index 697dafa79..c8fd33adb 100644 --- a/pkg/miniapp/api.go +++ b/pkg/miniapp/api.go @@ -10,19 +10,16 @@ import ( "time" ) - func (h *Handler) apiSkills(w http.ResponseWriter, r *http.Request) { skillsList := h.provider.ListSkills() writeJSON(w, skillsList) } - func (h *Handler) apiPlan(w http.ResponseWriter, r *http.Request) { info := h.provider.GetPlanInfo() writeJSON(w, info) } - func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) { sessions := h.provider.GetActiveSessions() if sessions == nil { @@ -31,7 +28,6 @@ func (h *Handler) apiSessions(w http.ResponseWriter, r *http.Request) { writeJSON(w, sessions) } - func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { s := h.provider.GetSessionStats() if s == nil { @@ -41,17 +37,14 @@ func (h *Handler) apiSession(w http.ResponseWriter, r *http.Request) { writeJSON(w, s) } - func (h *Handler) apiContext(w http.ResponseWriter, r *http.Request) { writeJSON(w, h.provider.GetContextInfo()) } - func (h *Handler) apiPrompt(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"prompt": h.provider.GetSystemPrompt()}) } - func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { repo := r.URL.Query().Get("repo") if repo == "" { @@ -61,7 +54,6 @@ func (h *Handler) apiGit(w http.ResponseWriter, r *http.Request) { } } - func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) @@ -99,7 +91,6 @@ func (h *Handler) apiCommand(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } - func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { @@ -148,7 +139,6 @@ func (h *Handler) apiEvents(w http.ResponseWriter, r *http.Request) { } } - func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any, last *[]byte) { data, _ := json.Marshal(v) if !bytes.Equal(data, *last) { @@ -158,11 +148,9 @@ func sendSSEIfChanged(w http.ResponseWriter, f http.Flusher, event string, v any } } - func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) } // apiDevConsole receives console output from dev preview iframes. - diff --git a/pkg/miniapp/dev.go b/pkg/miniapp/dev.go index 1dfbaa7fa..67bfcd898 100644 --- a/pkg/miniapp/dev.go +++ b/pkg/miniapp/dev.go @@ -17,7 +17,6 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) - // validateLocalhostURL parses and validates that a URL targets localhost. func validateLocalhostURL(target string) (*url.URL, error) { u, err := url.Parse(target) @@ -33,7 +32,6 @@ func validateLocalhostURL(target string) (*url.URL, error) { // RegisterDevTarget registers a new dev server target. Only localhost targets are allowed. - // RegisterDevTarget registers a new dev server target. Only localhost targets are allowed. func (h *Handler) RegisterDevTarget(name, target string) (string, error) { if _, err := validateLocalhostURL(target); err != nil { @@ -55,7 +53,6 @@ func (h *Handler) RegisterDevTarget(name, target string) (string, error) { // UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled. - // UnregisterDevTarget removes a registered target. If it was active, the proxy is disabled. func (h *Handler) UnregisterDevTarget(id string) error { h.devMu.Lock() @@ -79,7 +76,6 @@ func (h *Handler) UnregisterDevTarget(id string) error { // ActivateDevTarget sets the reverse proxy to the registered target with the given ID. - // ActivateDevTarget sets the reverse proxy to the registered target with the given ID. func (h *Handler) ActivateDevTarget(id string) error { h.devMu.Lock() @@ -148,7 +144,6 @@ p{color:#8e8e93;font-size:14px;margin:0} // DeactivateDevTarget disables the reverse proxy without removing registrations. - // DeactivateDevTarget disables the reverse proxy without removing registrations. func (h *Handler) DeactivateDevTarget() error { h.devMu.Lock() @@ -165,7 +160,6 @@ func (h *Handler) DeactivateDevTarget() error { // GetDevTarget returns the current dev proxy target URL, or empty string if disabled. - // GetDevTarget returns the current dev proxy target URL, or empty string if disabled. func (h *Handler) GetDevTarget() string { h.devMu.RLock() @@ -178,7 +172,6 @@ func (h *Handler) GetDevTarget() string { // ListDevTargets returns all registered dev targets. - // ListDevTargets returns all registered dev targets. func (h *Handler) ListDevTargets() []DevTarget { h.devMu.RLock() @@ -198,7 +191,6 @@ func (h *Handler) ListDevTargets() []DevTarget { // "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. // It also captures console.log/warn/error/info and forwards them to the server. - // devProxyScript is the JavaScript injected into HTML responses from the dev proxy. // It rewrites fetch() and XMLHttpRequest.open() so that absolute paths like // "/api/items" are prefixed with "/miniapp/dev", matching the reverse proxy mount. @@ -255,7 +247,6 @@ const devProxyScript = `