diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go index c19606f85..fb3ee43eb 100644 --- a/cmd/picoclaw/cmd_gateway.go +++ b/cmd/picoclaw/cmd_gateway.go @@ -306,7 +306,7 @@ func (p *agentLoopDataProvider) ListSkills() []skills.SkillInfo { } func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo { - hasPlan, status, currentPhase, totalPhases, display := p.loop.GetPlanInfo() + hasPlan, status, currentPhase, totalPhases, display, memory := p.loop.GetPlanInfo() // Convert agent.PlanPhase → miniapp.PlanPhase agentPhases := p.loop.GetPlanPhases() @@ -334,6 +334,7 @@ func (p *agentLoopDataProvider) GetPlanInfo() miniapp.PlanInfo { TotalPhases: totalPhases, Display: display, Phases: phases, + Memory: memory, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 2632e9e6e..a3361378c 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -960,6 +960,13 @@ func buildTaskReminder(userMessage string, lastBlocker string) providers.Message } } +// interviewRejectMessage is the fixed rejection text injected when tool calls +// are blocked during the interview phase. It is deliberately short to avoid +// wasting tokens, and ends with a purpose reminder to steer the LLM back. +const interviewRejectMessage = "[System] Tool call rejected. " + + "You are in interview mode — ask the user questions and update MEMORY.md. " + + "Do not execute, edit, or write project files." + // buildPlanReminder returns a reminder message for plan pre-execution states // (interviewing / review) to keep the AI focused on the interview workflow // during tool-call iterations. @@ -1594,6 +1601,39 @@ func (al *AgentLoop) runLLMIteration( normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } + // --- Interview mode: reject disallowed tool calls before they + // enter messages or session history. Rejected calls are stripped + // from normalizedToolCalls so they never reach the assistant + // message, the tool-result list, or the session store. + // A single compact rejection message is injected instead. + var interviewRejected []string + if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) { + allowed := normalizedToolCalls[:0] // reuse backing array + for _, tc := range normalizedToolCalls { + if isToolAllowedDuringInterview(tc.Name, tc.Arguments) { + allowed = append(allowed, tc) + } else { + interviewRejected = append(interviewRejected, tc.Name) + } + } + normalizedToolCalls = allowed + if len(interviewRejected) > 0 { + logger.InfoCF("agent", "Interview mode: rejected tool calls", + map[string]any{ + "agent_id": agent.ID, + "rejected": interviewRejected, + }) + messages = append(messages, providers.Message{ + Role: "user", + Content: interviewRejectMessage, + }) + } + // If all tool calls were rejected, skip to next iteration. + if len(normalizedToolCalls) == 0 { + continue + } + } + // Log tool calls toolNames := make([]string, 0, len(normalizedToolCalls)) for _, tc := range normalizedToolCalls { @@ -1734,15 +1774,8 @@ func (al *AgentLoop) runLLMIteration( } } - // Block non-allowed tools during plan interview mode. - // Only read-type tools and MEMORY.md writes are permitted. toolStart := time.Now() - var toolResult *tools.ToolResult - if isPlanPreExecution(agent.ContextBuilder.GetPlanStatus()) && !isToolAllowedDuringInterview(tc.Name, tc.Arguments) { - toolResult = tools.ErrorResult("Interview mode: only read tools and MEMORY.md edits are allowed. Focus on asking questions and updating the plan.") - } else { - toolResult = agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) - } + toolResult := agent.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback) toolDuration := time.Since(toolStart) // Update tool log entry with result @@ -2016,20 +2049,21 @@ func (al *AgentLoop) ListSkills() []skills.SkillInfo { } // GetPlanInfo returns plan state from the default agent's memory store. -func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string) { +func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) { agent := al.registry.GetDefaultAgent() if agent == nil { - return false, "", 0, 0, "No agent available." + return false, "", 0, 0, "No agent available.", "" } mem := agent.ContextBuilder.Memory() if mem == nil { - return false, "", 0, 0, "No memory store." + return false, "", 0, 0, "No memory store.", "" } hasPlan = mem.HasActivePlan() status = mem.GetPlanStatus() currentPhase = mem.GetCurrentPhase() totalPhases = mem.GetTotalPhases() display = mem.FormatPlanDisplay() + memory = mem.ReadLongTerm() return } diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 0c46bb45b..026264297 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -45,6 +45,7 @@ type PlanInfo struct { TotalPhases int `json:"total_phases"` Display string `json:"display"` Phases []PlanPhase `json:"phases"` + Memory string `json:"memory"` } // SessionInfo represents an active session entry for the API response. diff --git a/pkg/miniapp/static/index.html b/pkg/miniapp/static/index.html index 5b776b797..91176378d 100644 --- a/pkg/miniapp/static/index.html +++ b/pkg/miniapp/static/index.html @@ -403,6 +403,96 @@ border-color: var(--btn); } + /* MEMORY.md raw display */ + .memory-view { + font-family: 'SF Mono', 'Menlo', 'Consolas', monospace; + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-word; + padding: 14px 16px; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 16px; + box-shadow: 0 1px 3px var(--glass-shadow); + -webkit-backdrop-filter: blur(12px); + backdrop-filter: blur(12px); + } + + .memory-view .md-h1 { + font-size: 18px; + font-weight: 700; + margin: 16px 0 8px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + .memory-view .md-h1:first-child { margin-top: 0; } + + .memory-view .md-h2 { + font-size: 15px; + font-weight: 700; + margin: 14px 0 6px; + color: var(--link); + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .memory-view .md-h3 { + font-size: 14px; + font-weight: 600; + margin: 12px 0 4px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + } + + .memory-view .md-checkbox { + margin: 2px 0; + } + + .memory-view .md-checkbox-icon { + display: inline-block; + width: 16px; + height: 16px; + border-radius: 4px; + border: 1.5px solid var(--hint); + vertical-align: middle; + margin-right: 6px; + position: relative; + top: -1px; + } + + .memory-view .md-checkbox-icon.checked { + background: var(--done); + border-color: var(--done); + } + + .memory-view .md-checkbox-icon.checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 1.5px 1.5px 0; + transform: rotate(45deg); + } + + .memory-view .md-quote { + border-left: 3px solid var(--hint); + padding-left: 10px; + color: var(--hint); + margin: 4px 0; + } + + .memory-view .md-bullet { + margin: 2px 0; + padding-left: 12px; + text-indent: -12px; + } + + .memory-view .md-bullet::before { + content: '\2022 '; + color: var(--hint); + } +
@@ -584,13 +674,55 @@ function renderPlanFromData(data) { '