refactor: split loop.go and tools into upstream-friendly file layout

Reduce upstream merge friction by extracting fork-specific code from
files that upstream also modifies. loop.go is reduced from 6522 to 3165
lines by moving self-contained functions to dedicated files:

- loop_task.go: activeTask, status display, tool log formatting
- loop_plan.go: plan mode, interview filtering, worktree commands
- loop_orch.go: orchestration reporting, system messages, peer extraction
- loop_commands.go: slash commands, session commands, skill commands
- loop_streaming.go: streaming display, repetition detection, reasoning
- loop_session.go: session locks, GC, summarization
- loop_info.go: getters (startup info, plan info, context info)

Similarly for tools/:
- base.go → base.go (Tool interface only) + base_ext.go (AsyncExecutor, StatusProvider, context helpers)
- registry.go → registry.go (core CRUD) + registry_ext.go (ExecuteWithContext, ToProviderDefs, GetSummaries)

config/:
- SubagentsConfig, PeerMatch moved to config_ext.go
- Fix missing "strings" import (pre-existing bug)

No behavior changes. All code stays in the same Go packages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 00:56:40 +09:00
parent 7bfd5cc658
commit 77c4f69d2e
14 changed files with 3786 additions and 3706 deletions

File diff suppressed because it is too large Load diff

717
pkg/agent/loop_commands.go Normal file
View file

@ -0,0 +1,717 @@
package agent
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/stats"
)
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/") {
return "", false
}
parts := strings.Fields(content)
if len(parts) == 0 {
return "", false
}
cmd := parts[0]
args := parts[1:]
switch cmd {
case "/show":
if len(args) < 1 {
return "Usage: /show [model|channel|agents]", true
}
switch args[0] {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
case "channel":
return fmt.Sprintf("Current channel: %s", msg.Channel), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown show target: %s", args[0]), true
}
case "/list":
if len(args) < 1 {
return "Usage: /list [models|channels|agents]", true
}
switch args[0] {
case "models":
return "Available models: configured in config.json per agent", true
case "channels":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
channels := al.channelManager.GetEnabledChannels()
if len(channels) == 0 {
return "No channels enabled", true
}
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown list target: %s", args[0]), true
}
case "/switch":
if len(args) < 3 || args[1] != "to" {
return "Usage: /switch [model|channel] to <name>", true
}
target := args[0]
value := args[2]
switch target {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
oldModel := defaultAgent.Model
defaultAgent.Model = value
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
case "channel":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
}
return fmt.Sprintf("Switched target channel to %s", value), true
default:
return fmt.Sprintf("Unknown switch target: %s", target), true
}
case "/session":
return al.handleSessionCommand(args, msg.SessionKey), true
case "/skills":
return al.handleSkillsCommand(), true
case "/plan":
resp, handled := al.handlePlanCommand(args, msg.SessionKey)
if handled {
al.notifyStateChange()
}
return resp, handled
case "/heartbeat":
resp, handled := al.handleHeartbeatCommand(args, msg)
if handled {
al.notifyStateChange()
}
return resp, handled
}
return "", false
}
func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessage) (string, bool) {
if len(args) == 0 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if args[0] != "thread" {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if len(args) < 2 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if msg.Channel != "telegram" {
return "/heartbeat thread is only supported from Telegram chats.", true
}
baseChatID, currentThreadID := splitChatAndThread(msg.ChatID)
if baseChatID == "" {
return "Unable to detect Telegram chat ID for heartbeat routing.", true
}
arg := strings.ToLower(strings.TrimSpace(args[1]))
var threadID int
var err error
switch arg {
case "off", "disable", "clear":
threadID = 0
case "here", "this":
if currentThreadID <= 0 {
return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true
}
threadID = currentThreadID
default:
threadID, err = strconv.Atoi(arg)
if err != nil || threadID < 0 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
}
al.cfg.Channels.Telegram.HeartbeatThreadID = threadID
if al.state != nil {
_ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID))
}
if al.onHeartbeatThreadUpdate != nil {
al.onHeartbeatThreadUpdate(threadID)
}
if al.saveConfig != nil {
if err := al.saveConfig(al.cfg); err != nil {
return fmt.Sprintf("Failed to persist config.json: %v", err), true
}
}
if threadID == 0 {
return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true
}
return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true
}
func splitChatAndThread(chatID string) (baseChatID string, threadID int) {
baseChatID = strings.TrimSpace(chatID)
if baseChatID == "" {
return "", 0
}
if slash := strings.Index(baseChatID, "/"); slash >= 0 {
threadPart := strings.TrimSpace(baseChatID[slash+1:])
baseChatID = strings.TrimSpace(baseChatID[:slash])
if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 {
threadID = tid
}
}
return baseChatID, threadID
}
// handleSessionCommand dispatches /session subcommands.
func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string {
sub := ""
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
switch sub {
case "list":
return al.handleSessionList()
case "graph":
return al.handleSessionGraph()
case "fork":
return al.handleSessionFork(args[1:], sessionKey)
case "reset":
if al.stats == nil {
return "Stats tracking is disabled."
}
al.stats.Reset()
return "Session statistics have been reset."
default:
return al.handleSessionStats()
}
}
func (al *AgentLoop) handleSessionStats() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
// Session DAG summary
sessions, _ := store.List(nil)
var sb strings.Builder
fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions))
if len(sessions) > 0 {
active, completed := 0, 0
for _, s := range sessions {
switch s.Status {
case "active":
active++
case "completed":
completed++
}
}
fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed)
}
sb.WriteString("\nUse: /session list | graph | fork [label]\n")
// Token stats if available
if al.stats != nil {
s := al.stats.GetStats()
fmt.Fprintf(&sb,
"\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+
"All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)",
s.Today.Date,
s.Today.Prompts,
s.Today.Requests,
stats.FormatTokenCount(s.Today.TotalTokens),
stats.FormatTokenCount(s.Today.PromptTokens),
stats.FormatTokenCount(s.Today.CompletionTokens),
s.Since.Format("2006-01-02"),
s.TotalPrompts,
s.TotalRequests,
stats.FormatTokenCount(s.TotalTokens),
stats.FormatTokenCount(s.TotalPromptTokens),
stats.FormatTokenCount(s.TotalCompletionTokens),
)
}
return sb.String()
}
// shortSessionKey truncates long session keys for display.
func shortSessionKey(key string) string {
parts := strings.Split(key, ":")
if len(parts) > 2 {
return strings.Join(parts[2:], ":")
}
return key
}
func (al *AgentLoop) handleSessionList() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return fmt.Sprintf("Error listing sessions: %v", err)
}
if len(sessions) == 0 {
return "No sessions in store."
}
var sb strings.Builder
fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions))
for _, s := range sessions {
age := time.Since(s.UpdatedAt).Truncate(time.Second)
label := s.Label
if label == "" {
label = shortSessionKey(s.Key)
}
parent := ""
if s.ParentKey != "" {
parent = " parent=" + shortSessionKey(s.ParentKey)
}
fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n",
label, s.Status, age, s.TurnCount, parent)
}
return sb.String()
}
func (al *AgentLoop) handleSessionGraph() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return fmt.Sprintf("Error listing sessions: %v", err)
}
if len(sessions) == 0 {
return "No sessions in store."
}
// Build parent→children map and find roots
byKey := make(map[string]*session.SessionInfo, len(sessions))
children := make(map[string][]string)
var roots []string
for _, s := range sessions {
byKey[s.Key] = s
if s.ParentKey == "" {
roots = append(roots, s.Key)
} else {
children[s.ParentKey] = append(children[s.ParentKey], s.Key)
}
}
var sb strings.Builder
sb.WriteString("Session Graph\n")
for i, root := range roots {
last := i == len(roots)-1
printSessionTree(&sb, root, byKey, children, "", last)
}
return sb.String()
}
func printSessionTree(
sb *strings.Builder,
key string,
byKey map[string]*session.SessionInfo,
children map[string][]string,
prefix string,
last bool,
) {
s := byKey[key]
if s == nil {
return
}
connector := "├── "
if last {
connector = "└── "
}
icon := "●"
if s.Status == "completed" {
icon = "✓"
}
label := s.Label
if label == "" {
label = shortSessionKey(s.Key)
}
fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount)
childPrefix := prefix + "│ "
if last {
childPrefix = prefix + " "
}
kids := children[key]
for i, childKey := range kids {
printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1)
}
}
func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string {
if sessionKey == "" {
return "Cannot fork: no active session key."
}
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
label := "fork"
if len(args) > 0 {
label = strings.Join(args, " ")
}
childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405")
err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label})
if err != nil {
return fmt.Sprintf("Fork failed: %v", err)
}
return fmt.Sprintf(
"Forked session\n parent: %s\n child: %s",
shortSessionKey(sessionKey),
shortSessionKey(childKey),
)
}
type SessionGraphNode struct {
Key string `json:"key"`
Label string `json:"label"`
Status string `json:"status"`
Summary string `json:"summary"`
ParentKey string `json:"parent_key"`
ForkTurnID string `json:"fork_turn_id"`
TurnCount int `json:"turn_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GetSessionGraph returns all sessions as a flat list of graph nodes.
func (al *AgentLoop) GetSessionGraph() []SessionGraphNode {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return nil
}
nodes := make([]SessionGraphNode, 0, len(sessions))
for _, s := range sessions {
nodes = append(nodes, SessionGraphNode{
Key: s.Key,
Label: s.Label,
Status: s.Status,
Summary: s.Summary,
ParentKey: s.ParentKey,
ForkTurnID: s.ForkTurnID,
TurnCount: s.TurnCount,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
})
}
return nodes
}
// expandSkillCommand detects "/skill <name> [message]" and returns:
// - expanded: full content with SKILL.md injected (for LLM)
// - compact: skill name tag + user message only (for history)
// - ok: whether expansion happened
func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/skill ") {
return "", "", false
}
// Parse: /skill <name> [message]
rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 0 || parts[0] == "" {
return "", "", false
}
skillName := parts[0]
userMessage := ""
if len(parts) > 1 {
userMessage = strings.TrimSpace(parts[1])
}
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", false
}
skillContent, found := agent.ContextBuilder.LoadSkill(skillName)
if !found {
return "", "", false
}
tag := fmt.Sprintf("[Skill: %s]", skillName)
// Build expanded message: skill instructions + user message (for LLM)
var sb strings.Builder
sb.WriteString(tag)
sb.WriteString("\n\n")
sb.WriteString(skillContent)
if userMessage != "" {
sb.WriteString("\n\n---\n\n")
sb.WriteString(userMessage)
}
// Build compact form: skill name tag + user message only (for history)
compactForm := tag
if userMessage != "" {
compactForm = tag + "\n" + userMessage
}
return sb.String(), compactForm, true
}
// handleSkillsCommand lists all available skills.
func (al *AgentLoop) handleSkillsCommand() string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured."
}
skillsList := agent.ContextBuilder.ListSkills()
if len(skillsList) == 0 {
return "No skills available.\nAdd skills to your workspace/skills/ directory."
}
var sb strings.Builder
sb.WriteString("Available Skills\n\n")
for _, s := range skillsList {
fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source)
if s.Description != "" {
fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description)
}
}
sb.WriteString("\nUse: /skill <name> [message]")
return sb.String()
}

281
pkg/agent/loop_info.go Normal file
View file

@ -0,0 +1,281 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
if agent == nil {
return info
}
// Tools info
toolsList := agent.Tools.List()
toolsMap := map[string]any{
"count": len(toolsList),
"names": toolsList,
}
// Report web search provider if registered
if t, ok := agent.Tools.Get("web_search"); ok {
if wst, ok := t.(*tools.WebSearchTool); ok {
toolsMap["web_search_provider"] = wst.ProviderName()
}
}
info["tools"] = toolsMap
// Skills info
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
return info
}
// ListSkills returns all available skills from the default agent.
func (al *AgentLoop) ListSkills() []skills.SkillInfo {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return nil
}
return agent.ContextBuilder.ListSkills()
}
// GetPlanInfo returns plan state from the default agent's memory store.
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.", ""
}
mem := agent.ContextBuilder.Memory()
if mem == nil {
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 hasPlan, status, currentPhase, totalPhases, display, memory
}
// GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "".
func (al *AgentLoop) GetPlanStatus() string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return ""
}
return agent.ContextBuilder.GetPlanStatus()
}
// GetPlanPhases returns structured phase/step data from the default agent's plan.
func (al *AgentLoop) GetPlanPhases() []PlanPhase {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return nil
}
mem := agent.ContextBuilder.Memory()
if mem == nil {
return nil
}
return mem.GetPlanPhases()
}
// GetActiveSessions returns currently active sessions for the mini app API.
func (al *AgentLoop) GetActiveSessions() []SessionEntry {
return al.sessions.ListActive()
}
// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled.
func (al *AgentLoop) GetSessionStats() *stats.Stats {
if al.stats == nil {
return nil
}
s := al.stats.GetStats()
return &s
}
// GetContextInfo returns the bootstrap file resolution and directory context for the default agent.
func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", "", nil
}
workspace = agent.Workspace
planWorkDir = agent.ContextBuilder.GetPlanWorkDir()
// Use the most recent active session's touch_dir (tool-detected project directory)
if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" {
workDir = active[0].TouchDir
} else {
workDir = agent.ContextBuilder.workDir
}
bootstrap = agent.ContextBuilder.ResolveBootstrapPaths()
return workDir, planWorkDir, workspace, bootstrap
}
// GetSystemPrompt returns the system prompt last sent to the LLM.
// If the prompt is dirty (state changed since last capture), it rebuilds
// from current state. Falls back to building if no LLM call has occurred yet.
func (al *AgentLoop) GetSystemPrompt() string {
if !al.promptDirty.Load() {
if v := al.lastSystemPrompt.Load(); v != nil {
return v.(string)
}
}
// Rebuild from current state
agent := al.registry.GetDefaultAgent()
if agent == nil {
return ""
}
prompt := agent.ContextBuilder.BuildSystemPrompt()
al.lastSystemPrompt.Store(prompt)
al.promptDirty.Store(false)
return prompt
}
// formatMessagesForLog formats messages for logging
func formatMessagesForLog(messages []providers.Message) string {
if len(messages) == 0 {
return "[]"
}
var sb strings.Builder
sb.WriteString("[\n")
for i, msg := range messages {
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
if len(msg.ToolCalls) > 0 {
sb.WriteString(" ToolCalls:\n")
for _, tc := range msg.ToolCalls {
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
args := tc.Arguments
if len(args) == 0 && tc.Function != nil {
args = tc.Function.Arguments
}
if len(args) > 0 {
argsJSON, _ := json.Marshal(args)
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 200))
}
}
}
if msg.Content != "" {
content := utils.Truncate(msg.Content, 200)
fmt.Fprintf(&sb, " Content: %s\n", content)
}
if msg.ToolCallID != "" {
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
}
sb.WriteString("\n")
}
sb.WriteString("]")
return sb.String()
}
// formatToolsForLog formats tool definitions for logging
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
if len(toolDefs) == 0 {
return "[]"
}
var sb strings.Builder
sb.WriteString("[\n")
for i, tool := range toolDefs {
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(string(tool.Function.Parameters), 200))
}
}
sb.WriteString("]")
return sb.String()
}

340
pkg/agent/loop_orch.go Normal file
View file

@ -0,0 +1,340 @@
package agent
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
)
func (al *AgentLoop) reporter() orch.AgentReporter {
if al.orchReporter == nil {
return orch.Noop
}
return al.orchReporter
}
// SetOrchReporter wires a Broadcaster as the active reporter.
// Called from cmd_gateway.go when --orchestration is set.
// --orchestration なし → 呼ばれない → reporter() は Noop を返す。
func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) {
al.orchBroadcaster = b
al.orchReporter = b
}
// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring.
// Returns nil when orchestration is disabled.
func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster {
return al.orchBroadcaster
}
func (al *AgentLoop) notifyStateChange() {
al.promptDirty.Store(true)
if al.OnStateChange != nil {
al.OnStateChange()
}
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
}
logger.InfoCF("agent", "Processing system message",
map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
// Parse origin channel from chat_id (format: "channel:chat_id")
var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else {
originChannel = "cli"
originChatID = msg.ChatID
}
// Extract subagent result from message content
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
content := msg.Content
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
content = content[idx+8:] // Extract just the result part
}
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
})
return "", nil
}
// Inject subagent result into session history without running a full LLM loop.
// The conductor will see the result on its next turn. This avoids:
// - Flooding the chat with a response for every subagent completion
// - Consuming the Telegram "Thinking..." placeholder
// - Wasting LLM tokens on processing each result individually
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for system message")
}
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content)
// Write as TurnReport to the store for DAG tracking, with legacy fallback.
subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID))
store := agent.Sessions.Store()
reportTurn := &session.Turn{
Kind: session.TurnReport,
OriginKey: subagentSessionKey,
Author: msg.SenderID,
Messages: []providers.Message{{Role: "user", Content: historyMsg}},
}
if err := store.Append(sessionKey, reportTurn); err != nil {
logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy",
map[string]any{"error": err.Error()})
agent.Sessions.AddMessage(sessionKey, "user", historyMsg)
agent.Sessions.MarkDirty(sessionKey)
} else {
// Update in-memory cache so conductor sees the message on next turn.
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg})
agent.Sessions.AdvanceStored(sessionKey, 1)
}
// Send a brief notification (SkipPlaceholder to avoid corrupting status messages)
label := msg.SenderID
if idx := strings.LastIndex(label, ":"); idx >= 0 {
label = label[idx+1:]
}
notification := formatSubagentCompletion(label, msg.Metadata)
subagentThreadID := 0
if al.cfg != nil {
subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID
}
notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID)
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: originChannel,
ChatID: notifyChatID,
Content: notification,
SkipPlaceholder: true,
})
logger.InfoCF("agent", "Subagent result injected into session history",
map[string]any{
"sender_id": msg.SenderID,
"session_key": sessionKey,
"content_len": len(content),
})
return "", nil
}
// extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1".
func extractTaskID(senderID string) string {
if idx := strings.LastIndex(senderID, ":"); idx >= 0 {
return senderID[idx+1:]
}
return senderID
}
// formatSubagentCompletion builds the user-facing notification for a completed subagent.
// If metadata contains duration_ms and tool_calls it produces e.g.:
//
// "📋 scout-1 completed (3.2s, 5 tool calls)."
//
// Without metadata it falls back to the plain "📋 scout-1 completed." format.
func formatSubagentCompletion(label string, metadata map[string]string) string {
if len(metadata) == 0 {
return fmt.Sprintf("📋 %s completed.", label)
}
durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64)
toolCalls, _ := strconv.Atoi(metadata["tool_calls"])
if durationMs <= 0 && toolCalls <= 0 {
return fmt.Sprintf("📋 %s completed.", label)
}
parts := make([]string, 0, 2)
if durationMs > 0 {
parts = append(parts, formatDurationMs(durationMs))
}
if toolCalls > 0 {
if toolCalls == 1 {
parts = append(parts, "1 tool call")
} else {
parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls))
}
}
return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", "))
}
// formatDurationMs converts milliseconds to a human-readable duration string.
// Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s".
func formatDurationMs(ms int64) string {
if ms < 1000 {
return fmt.Sprintf("%dms", ms)
}
totalSec := ms / 1000
if totalSec < 60 {
tenths := (ms % 1000) / 100
return fmt.Sprintf("%d.%ds", totalSec, tenths)
}
mins := totalSec / 60
sec := totalSec % 60
if sec == 0 {
return fmt.Sprintf("%dm", mins)
}
return fmt.Sprintf("%dm%ds", mins, sec)
}
// buildOrchReminder returns a reminder to use spawn/subagent during plan execution.
// Fires on first iteration and every 3rd iteration to reinforce delegation behavior.
func buildOrchReminder(iteration int) (providers.Message, bool) {
if iteration != 1 && iteration%3 != 0 {
return providers.Message{}, false
}
content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents.
Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result).
Do NOT implement steps inline unless they are a single trivial tool call.
To delegate, call the tool with JSON arguments:
Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."}
Tool: subagent Arguments: {"task": "...", "label": "..."}
Spawn multiple independent steps in parallel for maximum throughput.`
return providers.Message{Role: "user", Content: content}, true
}
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
if msg.Peer.Kind == "" {
return nil
}
peerID := msg.Peer.ID
if peerID == "" {
if msg.Peer.Kind == "direct" {
peerID = msg.SenderID
} else {
peerID = msg.ChatID
}
}
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
parentKind := msg.Metadata["parent_peer_kind"]
parentID := msg.Metadata["parent_peer_id"]
if parentKind == "" || parentID == "" {
return nil
}
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}

674
pkg/agent/loop_plan.go Normal file
View file

@ -0,0 +1,674 @@
package agent
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/git"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
// 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.
func buildPlanReminder(planStatus string) (providers.Message, bool) {
var content string
switch planStatus {
case "interviewing":
content = "[System] You are interviewing the user to build a plan. " +
"Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " +
"When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " +
"Then change > Status: to review. Do NOT set it to executing."
case "review":
content = "[System] The plan is under review. " +
"Wait for the user to approve or request changes. Do not proceed with execution."
default:
return providers.Message{}, false
}
return providers.Message{Role: "user", Content: content}, true
}
func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured.", true
}
if len(args) == 0 {
// /plan — show current plan
return agent.ContextBuilder.FormatPlanDisplay(), true
}
sub := args[0]
switch sub {
case "clear":
if agent.ContextBuilder.ReadMemory() == "" {
return "No active plan to clear.", true
}
// Deactivate worktree on plan clear
if sessionKey != "" {
agent.DeactivateWorktree(sessionKey, "", true)
}
if err := agent.ContextBuilder.ClearMemory(); err != nil {
return fmt.Sprintf("Error clearing plan: %v", err), true
}
return "Plan cleared.", true
case "done":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if len(args) < 2 {
return "Usage: /plan done <step number>", true
}
stepNum, err := strconv.Atoi(args[1])
if err != nil || stepNum < 1 {
return "Step number must be a positive integer.", true
}
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true
case "add":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if len(args) < 2 {
return "Usage: /plan add <step description>", true
}
desc := strings.Join(args[1:], " ")
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true
case "start":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
status := agent.ContextBuilder.GetPlanStatus()
if status == "executing" {
return "Plan is already executing.", true
}
if status != "interviewing" && status != "review" {
return fmt.Sprintf("Cannot start from status %q.", status), true
}
if agent.ContextBuilder.GetTotalPhases() == 0 {
return "Cannot start: no phases defined yet. Complete the interview first.", true
}
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "")
al.planStartPending = true
clearHistory := len(args) > 1 && args[1] == "clear"
al.planClearHistory = clearHistory
if clearHistory {
return "Plan approved. Executing with clean history.", true
}
return "Plan approved. Executing.", true
case "next":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if err := agent.ContextBuilder.AdvancePhase(); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
phase := agent.ContextBuilder.GetCurrentPhase()
return fmt.Sprintf("Advanced to phase %d.", phase), true
case "worktrees":
return al.handlePlanWorktreesCommand(agent, args[1:]), true
default:
// /plan <task description> — start new plan
// Block if a plan is already active (fast-path error).
if agent.ContextBuilder.HasActivePlan() {
return "A plan is already active. Use /plan clear first.", true
}
// Not handled here — let the message flow to the LLM queue.
// expandPlanCommand will write the seed and rewrite the content.
return "", false
}
}
func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string {
repoRoot := git.FindRepoRoot(agent.Workspace)
if repoRoot == "" {
return "Workspace is not a git repository."
}
worktreesDir := filepath.Join(agent.Workspace, ".worktrees")
sub := "list"
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
switch sub {
case "", "list":
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
if err != nil {
return fmt.Sprintf("Error listing worktrees: %v", err)
}
if len(items) == 0 {
return "No active worktrees in workspace/.worktrees."
}
var sb strings.Builder
sb.WriteString("Active worktrees\n\n")
for _, wt := range items {
status := "clean"
if wt.HasUncommitted {
status = "dirty"
}
last := "(no commits)"
if wt.LastCommitHash != "" {
if wt.LastCommitAge != "" {
last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge)
} else {
last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject)
}
}
fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last)
}
sb.WriteString("\nCommands:\n")
sb.WriteString("/plan worktrees inspect <name>\n")
sb.WriteString("/plan worktrees merge <name>\n")
sb.WriteString("/plan worktrees dispose <name> [force]")
return sb.String()
case "inspect":
if len(args) < 2 {
return "Usage: /plan worktrees inspect <name>"
}
name := args[1]
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error inspecting worktree %q: %v", name, err)
}
statusOut, _ := git.WorktreeStatusShort(wt.Path)
diffOut, _ := git.WorktreeDiffStat(wt.Path)
logOut, _ := git.WorktreeRecentLog(wt.Path, 10)
if statusOut == "" {
statusOut = "(clean)"
}
var sb strings.Builder
fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted)
if wt.LastCommitHash != "" {
fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject)
if wt.LastCommitAge != "" {
fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge)
}
sb.WriteString("\n")
}
sb.WriteString("\nStatus:\n```\n")
sb.WriteString(statusOut)
sb.WriteString("\n```\n")
if diffOut != "" {
sb.WriteString("\nDiff (stat):\n```\n")
sb.WriteString(diffOut)
sb.WriteString("\n```\n")
}
if logOut != "" {
sb.WriteString("\nRecent commits:\n```\n")
sb.WriteString(logOut)
sb.WriteString("\n```")
}
return sb.String()
case "merge":
if len(args) < 2 {
return "Usage: /plan worktrees merge <name>"
}
name := args[1]
res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "")
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error merging worktree %q: %v", name, err)
}
if res.Conflict {
return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base)
}
if res.Merged {
return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base)
}
return fmt.Sprintf("No merge was performed for `%s`.", name)
case "dispose":
if len(args) < 2 {
return "Usage: /plan worktrees dispose <name> [force]"
}
name := args[1]
force := len(args) > 2 && strings.EqualFold(args[2], "force")
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
}
if wt.HasUncommitted && !force {
return fmt.Sprintf(
"Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.",
name,
name,
)
}
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "")
if err != nil {
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
}
parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)}
if res.AutoCommitted {
parts = append(parts, "Uncommitted changes were auto-committed.")
}
if res.CommitsAhead > 0 {
parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead))
}
if res.BranchDeleted {
parts = append(parts, "Branch was deleted (no unique commits).")
}
return strings.Join(parts, " ")
}
return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]"
}
// isPlanPreExecution returns true if the plan is in a pre-execution state
// (interviewing or review) where tool restrictions and iteration caps apply.
func isPlanPreExecution(status string) bool {
return status == "interviewing" || status == "review"
}
// interviewAllowedTools is the single source of truth for tool names that may
// be sent to the LLM (and subsequently invoked) during the interview phase.
// 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,
"appendfile": true,
"writefile": true,
"exec": true,
"logs": true,
}
// filterInterviewTools removes tool definitions that are not in the
// interviewAllowedTools whitelist, reducing token usage and preventing the
// LLM from attempting disallowed tool calls during the interview phase.
func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition {
filtered := make([]providers.ToolDefinition, 0, len(defs))
for _, d := range defs {
if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] {
filtered = append(filtered, d)
}
}
return filtered
}
// isToolAllowedDuringInterview checks whether a tool call is permitted while the
// plan is in a pre-execution state. Uses the shared interviewAllowedTools map for
// name-level gating, then applies argument-level constraints for write-type tools
// (MEMORY.md only) and exec (read-only commands only).
func isToolAllowedDuringInterview(toolName string, args map[string]any) bool {
norm := tools.NormalizeToolName(toolName)
if !interviewAllowedTools[norm] {
return false
}
// Argument-level constraints
switch norm {
case "editfile", "appendfile", "writefile":
path, _ := args["path"].(string)
return strings.HasSuffix(path, "MEMORY.md")
case "exec":
cmd, _ := args["command"].(string)
return isReadOnlyCommand(cmd)
}
return true
}
// isReadOnlyCommand returns true when cmd is a safe, read-only shell command
// that an LLM may run during the interview phase.
func isReadOnlyCommand(cmd string) bool {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return false
}
// Reject write operators anywhere in the command
for _, op := range []string{">", ">>", "| tee "} {
if strings.Contains(cmd, op) {
return false
}
}
// Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction)
if strings.Contains(cmd, "..") {
return false
}
// Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later)
for _, field := range strings.Fields(cmd) {
if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") {
return false
}
}
// Strip "cd /path &&" prefix (LLM habit)
if strings.HasPrefix(cmd, "cd ") {
if idx := strings.Index(cmd, "&&"); idx >= 0 {
cmd = strings.TrimSpace(cmd[idx+2:])
}
}
fields := strings.Fields(cmd)
if len(fields) == 0 {
return false
}
first := filepath.Base(fields[0])
switch first {
case "find", "ls", "cat", "head", "tail", "grep", "rg",
"tree", "wc", "file", "which", "pwd",
"uname", "df", "du", "stat", "realpath", "dirname",
"basename", "date":
return true
}
return false
}
// isWriteTool returns true if the tool can modify files.
func isWriteTool(name string) bool {
switch tools.NormalizeToolName(name) {
case "writefile", "editfile", "appendfile", "exec":
return true
}
return false
}
// expandPlanCommand detects "/plan <task>" (new plan start) and:
// - writes the interview seed to MEMORY.md
// - rewrites the message content for the LLM
// - returns a compact form for session history
//
// This follows the same pattern as expandSkillCommand: the message is
// rewritten before reaching the LLM, so the AI sees the task description
// while the system prompt contains the interview guide.
func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/plan ") {
return "", "", false
}
task := strings.TrimSpace(content[6:]) // len("/plan ") == 6
if task == "" {
return "", "", false
}
// Known subcommands are handled by handlePlanCommand (fast path).
firstWord := strings.Fields(task)[0]
switch firstWord {
case "clear", "done", "add", "start", "next", "worktrees":
return "", "", false
}
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", false
}
// If a plan is already active, don't expand — handleCommand will
// catch it and return the error on the fast path.
if agent.ContextBuilder.HasActivePlan() {
return "", "", false
}
// Write the interview seed
seed := BuildInterviewSeed(task, agent.Workspace)
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
return "", "", false
}
al.notifyStateChange()
// Expanded: the task description goes to LLM.
// The system prompt already contains the interview guide.
expanded = task
compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80))
return expanded, compact, true
}

393
pkg/agent/loop_session.go Normal file
View file

@ -0,0 +1,393 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// sessionSemaphore is a per-session mutex using a buffered channel.
type sessionSemaphore struct {
ch chan struct{}
}
func newSessionSemaphore() *sessionSemaphore {
s := &sessionSemaphore{ch: make(chan struct{}, 1)}
s.ch <- struct{}{} // initially unlocked
return s
}
func (al *AgentLoop) gcLoop() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
al.gcSessionLocks()
case <-al.done:
return
}
}
}
// gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map.
func (al *AgentLoop) gcSessionLocks() {
al.sessionLocks.Range(func(key, val any) bool {
sem := val.(*sessionSemaphore)
select {
case <-sem.ch:
// Was unlocked — safe to remove
al.sessionLocks.Delete(key)
default:
// Currently locked — in use, keep
}
return true
})
}
func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool {
val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore())
sem := val.(*sessionSemaphore)
select {
case <-sem.ch:
return true
case <-ctx.Done():
return false
}
}
// releaseSessionLock releases the per-session semaphore.
func (al *AgentLoop) releaseSessionLock(sessionKey string) {
if val, ok := al.sessionLocks.Load(sessionKey); ok {
sem := val.(*sessionSemaphore)
sem.ch <- struct{}{}
}
}
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history",
map[string]any{
"session_key": sessionKey,
"history_len": len(newHistory),
"token_estimate": tokenEstimate,
})
al.summarizeSession(agent, sessionKey)
}()
}
}
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 4 {
return
}
// Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
// Helper to find the mid-point of the conversation
mid := len(conversation) / 2
// New history structure:
// 1. System Prompt (with compression note appended)
// 2. Second half of conversation
// 3. Last message
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message
// Update session
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
return
}
toSummarize := history[:len(history)-4]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Multi-Part Summarization
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
)
resp, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil {
logger.ErrorCF("agent", "CompactOldTurns failed, falling back",
map[string]any{"error": err.Error()})
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey)
}
}
}
// summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(
ctx context.Context,
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
) (string, error) {
var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if agent.ContextBuilder.HasActivePlan() {
sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n")
}
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
prompt := sb.String()
response, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err != nil {
return "", err
}
return response.Content, nil
}
// estimateTokens estimates the number of tokens in a message list.
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
totalChars := 0
for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content)
}
// 2.5 chars per token = totalChars * 2 / 5
return totalChars * 2 / 5
}

317
pkg/agent/loop_streaming.go Normal file
View file

@ -0,0 +1,317 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/sipeed/picoclaw/pkg/utils"
)
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
if reasoningContent == "" || channelName == "" || channelID == "" {
return
}
// Check context cancellation before attempting to publish,
// since PublishOutbound's select may race between send and ctx.Done().
if ctx.Err() != nil {
return
}
// Use a short timeout so the goroutine does not block indefinitely when
// the outbound bus is full. Reasoning output is best-effort; dropping it
// is acceptable to avoid goroutine accumulation.
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
}); err != nil {
// Treat context.DeadlineExceeded / context.Canceled as expected
// (bus full under load, or parent canceled). Check the error
// itself rather than ctx.Err(), because pubCtx may time out
// (5 s) while the parent ctx is still active.
// Also treat ErrBusClosed as expected — it occurs during normal
// shutdown when the bus is closed before all goroutines finish.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
errors.Is(err, bus.ErrBusClosed) {
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
"channel": channelName,
"error": err.Error(),
})
} else {
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
"channel": channelName,
"error": err.Error(),
})
}
}
}
// streamingReasoningLines is the number of lines reserved for reasoning
// in the streaming display. The remaining lines go to content.
const streamingReasoningLines = 6
// buildStreamingDisplay builds a fixed-height status bubble for streaming.
//
// Layout when reasoning is active (reasoning only or both):
//
// 🧠 Thinking...
// ━━━━━━━━━━
// <reasoning tail — streamingReasoningLines lines>
// ━━━━━━━━━━
// <content tail — remaining lines> (or blank if content is empty)
// █
//
// Layout when no reasoning (content only):
//
// <content tail — streamingDisplayLines lines>
// █
func buildStreamingDisplay(content, reasoning string) string {
if reasoning == "" {
// No reasoning — full window for content.
return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589"
}
var sb strings.Builder
// Header
if content == "" {
sb.WriteString("\U0001f9e0 Thinking...\n")
} else {
sb.WriteString("\U0001f9e0 Thought, now responding...\n")
}
sb.WriteString(statusSeparator)
// Reasoning window
headerLines := 2 // header + separator
footerLines := 1 // separator before content
contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines
if contentLines < 3 {
contentLines = 3
}
rLines := streamingDisplayLines - headerLines - footerLines - contentLines
sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth))
sb.WriteByte('\n')
sb.WriteString(statusSeparator)
// Content window (may be blank padding if content hasn't started)
sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth))
sb.WriteString(" \u2589")
return sb.String()
}
// consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates
// content and tool calls, and runs repetition detection every checkInterval runes.
// If repetition is detected, cancelFn is called to abort the HTTP request and
// the function returns the partial response with detected=true.
func consumeStreamWithRepetitionDetection(
ch <-chan protocoltypes.StreamEvent,
cancelFn context.CancelFunc,
checkInterval int,
onChunk func(content, reasoning string),
) (*providers.LLMResponse, bool, error) {
var content strings.Builder
var reasoning strings.Builder
var toolCalls []streamToolCallAcc
var finishReason string
var usage *providers.UsageInfo
runesSinceLastCheck := 0
for ev := range ch {
if ev.Err != nil {
return nil, false, ev.Err
}
updated := false
if ev.ContentDelta != "" {
content.WriteString(ev.ContentDelta)
runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta)
updated = true
}
if ev.ReasoningDelta != "" {
reasoning.WriteString(ev.ReasoningDelta)
updated = true
}
if updated && onChunk != nil {
onChunk(content.String(), reasoning.String())
}
if ev.FinishReason != "" {
finishReason = ev.FinishReason
}
if ev.Usage != nil {
usage = ev.Usage
}
for _, tc := range ev.ToolCallDeltas {
for len(toolCalls) <= tc.Index {
toolCalls = append(toolCalls, streamToolCallAcc{})
}
if tc.ID != "" {
toolCalls[tc.Index].id = tc.ID
}
if tc.Name != "" {
toolCalls[tc.Index].name = tc.Name
}
toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta)
}
// Run repetition detection periodically on accumulated content.
if runesSinceLastCheck >= checkInterval && content.Len() > 2000 {
runesSinceLastCheck = 0
if utils.DetectRepetitionLoop(content.String()) {
cancelFn()
// Drain remaining events so the producer goroutine can exit.
for range ch {
}
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
return resp, true, nil
}
}
}
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
return resp, false, nil
}
// streamToolCallAcc accumulates streamed tool call fragments.
type streamToolCallAcc struct {
id string
name string
args strings.Builder
}
// buildAccumulatedResponse constructs an LLMResponse from accumulated stream data.
func buildAccumulatedResponse(
content, reasoning string,
toolCalls []streamToolCallAcc,
finishReason string,
usage *providers.UsageInfo,
) *providers.LLMResponse {
resp := &providers.LLMResponse{
Content: content,
Reasoning: reasoning,
FinishReason: finishReason,
Usage: usage,
}
for _, tc := range toolCalls {
arguments := make(map[string]any)
argStr := tc.args.String()
if argStr != "" {
if err := json.Unmarshal([]byte(argStr), &arguments); err != nil {
arguments["raw"] = argStr
}
}
resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{
ID: tc.id,
Name: tc.name,
Arguments: arguments,
})
}
return resp
}

701
pkg/agent/loop_task.go Normal file
View file

@ -0,0 +1,701 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/utils"
)
// activeTask tracks a running agent task for live status and intervention.
type activeTask struct {
Description string
Result string // LLM response summary for completion notification
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)
streamedChunks bool // true after onChunk fires at least once
messageContent string // last content sent by the message tool (for inclusion in completion)
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"
ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1"
}
// maxToolLogEntries limits the sliding window of tool log entries
// kept in memory and displayed in status messages.
const maxToolLogEntries = 5
// Task reminder constants and helpers.
const (
taskReminderMaxChars = 500
blockerMaxChars = 200
)
func shouldInjectReminder(iteration, interval int) bool {
if interval <= 0 {
return false
}
return iteration > 1 && iteration%interval == 0
}
func buildTaskReminder(userMessage string, lastBlocker string) providers.Message {
truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars)
var content string
if lastBlocker != "" {
truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars)
content = fmt.Sprintf(
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.",
truncatedTask,
truncatedBlocker,
)
} else {
content = fmt.Sprintf(
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nIf all steps of the original task are complete, move on. Otherwise, continue with the next step.",
truncatedTask,
)
}
return providers.Message{
Role: "user",
Content: content,
}
}
// cdPrefixPattern matches "cd /some/path && " at the start of a shell command.
// Group 1 captures the target directory path.
var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q.
// Only standalone flags are removed; flags whose value is the next positional
// argument (e.g. "-A 20") are kept because removing them would lose context.
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
// extractExecProjectDir extracts the basename of an exec cd target.
// Returns "" if the command has no cd prefix.
func extractExecProjectDir(args map[string]any) string {
cmd, _ := args["command"].(string)
if cmd == "" {
return ""
}
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
return ""
}
cdPath := strings.TrimRight(m[1], "/\\")
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
return cdPath[idx+1:]
}
if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 {
return cdPath[idx+1:]
}
return cdPath
}
// fileParentRelDir returns the parent directory of a file path, relative to
// workspace. Returns "" if the path is not under workspace or has no parent.
func fileParentRelDir(filePath, workspace string) string {
ws := strings.TrimRight(workspace, "/\\")
if ws == "" {
return ""
}
rest := strings.TrimPrefix(filePath, ws)
if rest == filePath {
return "" // not under workspace
}
rest = strings.TrimLeft(rest, "/\\")
// Remove the filename — keep only the directory part
if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 {
return rest[:idx]
}
return "" // file is directly under workspace, no meaningful dir
}
// commonDirPrefix computes the longest common directory prefix of two
// slash-separated paths. Returns "" if there is no common component.
func commonDirPrefix(a, b string) string {
partsA := strings.Split(a, "/")
partsB := strings.Split(b, "/")
n := len(partsA)
if len(partsB) < n {
n = len(partsB)
}
common := 0
for i := 0; i < n; i++ {
if partsA[i] != partsB[i] {
break
}
common = i + 1
}
if common == 0 {
return ""
}
return strings.Join(partsA[:common], "/")
}
// displayProjectDir returns the project directory name for status display.
// Prefers the authoritative exec-based projectDir; falls back to the
// basename of the file-based common directory.
func displayProjectDir(task *activeTask) string {
if task.projectDir != "" {
return task.projectDir
}
if task.fileCommonDir != "" {
dir := task.fileCommonDir
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
return dir[idx+1:]
}
return dir
}
return ""
}
// buildArgsSnippet produces a human-friendly snippet for the tool log.
// For exec: extracts the command and strips the leading "cd <workspace> && ".
// For file tools: extracts the path and strips the workspace prefix.
// Falls back to raw JSON truncation.
func buildArgsSnippet(toolName string, args map[string]any, workspace string) string {
switch toolName {
case "exec":
cmd, _ := args["command"].(string)
if cmd == "" {
break
}
cmd = cdPrefixPattern.ReplaceAllString(cmd, "")
cmd = optFlagPattern.ReplaceAllString(cmd, "")
return utils.Truncate(cmd, 80)
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
path, _ := args["path"].(string)
if path == "" {
break
}
if workspace != "" {
path = strings.TrimPrefix(path, workspace)
path = strings.TrimPrefix(path, "/")
}
// Prioritize filename: if path is too long, show "…/filename"
const maxPath = 60
if runes := []rune(path); len(runes) > maxPath {
// Find last slash to extract filename
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
filename := path[lastSlash:] // includes "/"
dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…"
if dirBudget > 0 {
dir := []rune(path[:lastSlash])
if len(dir) > dirBudget {
dir = dir[:dirBudget]
}
path = string(dir) + "\u2026" + filename
} else {
path = "\u2026" + filename
}
} else {
path = utils.Truncate(path, maxPath)
}
}
return path
}
// Default: raw JSON truncated
argsJSON, _ := json.Marshal(args)
return utils.Truncate(string(argsJSON), 80)
}
// maxEntryLineWidth is the max rune count for a single-line log entry.
// Telegram chat bubbles on mobile are roughly 40-45 chars wide.
const maxEntryLineWidth = 42
// isFileToolEntry returns true if the entry name contains a file-operation tool.
func isFileToolEntry(name string) bool {
for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} {
if strings.Contains(name, t) {
return true
}
}
return false
}
// formatCompactEntry formats a finished tool log entry as a fixed single line.
// The result marker (✓/✗) is always shown at the end regardless of truncation.
// File tools omit duration (always near-instant); paths truncate from the
// start so the filename is always visible.
func formatCompactEntry(entry toolLogEntry) string {
result := entry.Result
if result == "" {
result = "\u23F3" // ⏳
}
// File tools: strip duration, keep only marker (✓/✗/⏳)
isFile := isFileToolEntry(entry.Name)
if isFile {
if r := []rune(result); len(r) > 0 {
result = string(r[0:1]) // just the symbol
}
}
// Budget for ArgsSnip: total - name - " " - " " - result
nameLen := utf8.RuneCountInString(entry.Name)
resultLen := utf8.RuneCountInString(result)
argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen
args := entry.ArgsSnip
if args != "" && argsBudget > 3 {
argsRunes := []rune(args)
if len(argsRunes) > argsBudget {
// Paths: truncate from the start, keeping the filename visible
if strings.Contains(args, "/") {
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
} else {
args = string(argsRunes[:argsBudget-1]) + "\u2026"
}
}
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(args)
sb.WriteByte(' ')
sb.WriteString(result)
return sb.String()
}
// No room for args or args empty
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(result))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(result)
return sb.String()
}
// formatLatestEntry formats the latest entry command without its result marker.
// Since the result goes on the next line, the full width is available for the command.
func formatLatestEntry(entry toolLogEntry) string {
nameLen := utf8.RuneCountInString(entry.Name)
argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result)
args := entry.ArgsSnip
if args != "" && argsBudget > 3 {
argsRunes := []rune(args)
if len(argsRunes) > argsBudget {
if strings.Contains(args, "/") {
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
} else {
args = string(argsRunes[:argsBudget-1]) + "\u2026"
}
}
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(args))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(args)
return sb.String()
}
return entry.Name
}
// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space
// characters to just 2. e.g. "======" → "==", "---" → "--".
func compressRepeats(s string) string {
runes := []rune(s)
if len(runes) < 3 {
return s
}
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(runes) {
r := runes[i]
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) {
j := i + 1
for j < len(runes) && runes[j] == r {
j++
}
if j-i >= 3 {
sb.WriteRune(r)
sb.WriteRune(r)
i = j
continue
}
}
sb.WriteRune(r)
i++
}
return sb.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
)
// buildRichStatus builds a fixed-height terminal-like status display.
//
// Layout (always the same number of lines):
//
// 🔄 Task in progress (N/M) header
// 📁 workspace-path header
// ━━━━━━━━━━ separator
// [N] compact-past-1 ✓ Xs past (1 line each)
// [N] compact-past-2 ✗ Xs past
// [N] compact-past-3 ✓ Xs past
// [N] compact-past-4 ✓ Xs past
// [N] latest-command latest (no result, wider args)
// ⏳ latest result
// reserved
// ``` error fence
// err-line / placeholder error body (5 lines)
// ``` error fence
// ↩️ Reply to intervene footer (background only)
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
task.mu.Lock()
defer task.mu.Unlock()
var sb strings.Builder
// --- Header ---
sb.WriteString("\U0001F504 Task in progress (")
sb.WriteString(strconv.Itoa(task.Iteration))
sb.WriteByte('/')
sb.WriteString(strconv.Itoa(task.MaxIter))
sb.WriteString(")\n")
// Project directory: exec cd (authoritative) → file LCP → workspace basename
sb.WriteString("\U0001F4C1 ")
if dir := displayProjectDir(task); dir != "" {
sb.WriteString(dir)
} else if workspace != "" {
project := strings.TrimRight(workspace, "/\\")
if idx := strings.LastIndex(project, "/"); idx >= 0 {
project = project[idx+1:]
} else if idx := strings.LastIndex(project, "\\"); idx >= 0 {
project = project[idx+1:]
}
sb.WriteString(project)
}
sb.WriteByte('\n')
sb.WriteString(statusSeparator)
// --- Task entries (displayPastEntries + 2 lines for latest) ---
entries := task.toolLog
if len(entries) > maxToolLogEntries {
entries = entries[len(entries)-maxToolLogEntries:]
}
var pastEntries []toolLogEntry
var latest *toolLogEntry
if len(entries) > 0 {
latest = &entries[len(entries)-1]
if len(entries) > 1 {
start := len(entries) - 1 - displayPastEntries
if start < 0 {
start = 0
}
pastEntries = entries[start : len(entries)-1]
}
}
// Past entries: exactly displayPastEntries lines (pad if fewer)
for i := 0; i < displayPastEntries; i++ {
if i < len(pastEntries) {
sb.WriteString(formatCompactEntry(pastEntries[i]))
} else {
sb.WriteString("\u2800")
}
sb.WriteByte('\n')
}
// Latest entry: command on one line, result on next
if latest != nil {
sb.WriteString(formatLatestEntry(*latest))
sb.WriteByte('\n')
sb.WriteString(" ")
if latest.Result != "" {
sb.WriteString(latest.Result)
} else {
sb.WriteString("\u23F3")
}
sb.WriteByte('\n')
} else {
sb.WriteString("\u23F3 waiting...\n")
sb.WriteString("\u2800\n")
}
// Reserved (1 line)
sb.WriteString("\u2800\n")
// --- Error region (code fence, no separator) ---
sb.WriteString("```\n")
errEntry := task.lastError
if errEntry != nil {
sb.WriteString("\u274C ")
sb.WriteString(formatCompactEntry(*errEntry))
sb.WriteByte('\n')
var detailLines []string
if errEntry.ErrDetail != "" {
detailLines = strings.Split(errEntry.ErrDetail, "\n")
}
for i := 0; i < displayErrorLines-1; i++ {
if i < len(detailLines) {
line := compressRepeats(detailLines[i])
if runes := []rune(line); len(runes) > maxEntryLineWidth {
line = string(runes[:maxEntryLineWidth-1]) + "\u2026"
}
sb.WriteString(line)
} else {
sb.WriteString("\u2800")
}
sb.WriteByte('\n')
}
} else {
sb.WriteString("\u2714 No errors\n")
for i := 0; i < displayErrorLines-1; i++ {
sb.WriteString("\u2800\n")
}
}
sb.WriteString("```\n")
if isBackground {
sb.WriteString("\u21A9\uFE0F Reply to intervene")
}
return sb.String()
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
@ -140,16 +141,6 @@ type AgentConfig struct {
Subagents *SubagentsConfig `json:"subagents,omitempty"` Subagents *SubagentsConfig `json:"subagents,omitempty"`
} }
type SubagentsConfig struct {
Enabled bool `json:"enabled,omitempty"`
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
type PeerMatch struct {
Kind string `json:"kind"`
ID string `json:"id"`
}
type BindingMatch struct { type BindingMatch struct {
Channel string `json:"channel"` Channel string `json:"channel"`

14
pkg/config/config_ext.go Normal file
View file

@ -0,0 +1,14 @@
package config
// SubagentsConfig holds fork-specific subagent orchestration settings.
type SubagentsConfig struct {
Enabled bool `json:"enabled,omitempty"`
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
// PeerMatch identifies a peer by kind (direct/group) and ID.
type PeerMatch struct {
Kind string `json:"kind"`
ID string `json:"id"`
}

View file

@ -10,84 +10,6 @@ type Tool interface {
Execute(ctx context.Context, args map[string]any) *ToolResult Execute(ctx context.Context, args map[string]any) *ToolResult
} }
// --- Request-scoped tool context (channel / chatID) ---
//
// Carried via context.Value so that concurrent tool calls each receive
// their own immutable copy — no mutable state on singleton tool instances.
//
// Keys are unexported pointer-typed vars — guaranteed collision-free,
// and only accessible through the helper functions below.
type toolCtxKey struct{ name string }
var (
ctxKeyChannel = &toolCtxKey{"channel"}
ctxKeyChatID = &toolCtxKey{"chatID"}
)
// WithToolContext returns a child context carrying channel and chatID.
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
ctx = context.WithValue(ctx, ctxKeyChannel, channel)
ctx = context.WithValue(ctx, ctxKeyChatID, chatID)
return ctx
}
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
return v
}
// ToolChatID extracts the chatID from ctx, or "" if unset.
func ToolChatID(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChatID).(string)
return v
}
// AsyncCallback is a function type that async tools use to notify completion.
// When an async tool finishes its work, it calls this callback with the result.
//
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
// The result parameter contains the tool's execution result.
type AsyncCallback func(ctx context.Context, result *ToolResult)
// AsyncExecutor is an optional interface that tools can implement to support
// asynchronous execution with completion callbacks.
//
// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor
// receives the callback as a parameter of ExecuteAsync. This eliminates the
// data race where concurrent calls could overwrite each other's callbacks
// on a shared tool instance.
//
// This is useful for:
// - Long-running operations that shouldn't block the agent loop
// - Subagent spawns that complete independently
// - Background tasks that need to report results later
//
// Example:
//
// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
// go func() {
// result := t.runSubagent(ctx, args)
// if cb != nil { cb(ctx, result) }
// }()
// return AsyncResult("Subagent spawned, will report back")
// }
type AsyncExecutor interface {
Tool
// ExecuteAsync runs the tool asynchronously. The callback cb will be
// invoked (possibly from another goroutine) when the async operation
// completes. cb is guaranteed to be non-nil by the caller (registry).
ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
}
// StatusProvider is an optional interface that tools can implement
// to inject runtime status information into the system prompt.
// Return an empty string to inject nothing.
type StatusProvider interface {
RuntimeStatus() string
}
func ToolToSchema(tool Tool) map[string]any { func ToolToSchema(tool Tool) map[string]any {
return map[string]any{ return map[string]any{
"type": "function", "type": "function",

81
pkg/tools/base_ext.go Normal file
View file

@ -0,0 +1,81 @@
package tools
import "context"
// --- Request-scoped tool context (channel / chatID) ---
//
// Carried via context.Value so that concurrent tool calls each receive
// their own immutable copy — no mutable state on singleton tool instances.
//
// Keys are unexported pointer-typed vars — guaranteed collision-free,
// and only accessible through the helper functions below.
type toolCtxKey struct{ name string }
var (
ctxKeyChannel = &toolCtxKey{"channel"}
ctxKeyChatID = &toolCtxKey{"chatID"}
)
// WithToolContext returns a child context carrying channel and chatID.
func WithToolContext(ctx context.Context, channel, chatID string) context.Context {
ctx = context.WithValue(ctx, ctxKeyChannel, channel)
ctx = context.WithValue(ctx, ctxKeyChatID, chatID)
return ctx
}
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
return v
}
// ToolChatID extracts the chatID from ctx, or "" if unset.
func ToolChatID(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChatID).(string)
return v
}
// AsyncCallback is a function type that async tools use to notify completion.
// When an async tool finishes its work, it calls this callback with the result.
//
// The ctx parameter allows the callback to be canceled if the agent is shutting down.
// The result parameter contains the tool's execution result.
type AsyncCallback func(ctx context.Context, result *ToolResult)
// AsyncExecutor is an optional interface that tools can implement to support
// asynchronous execution with completion callbacks.
//
// Unlike the old AsyncTool pattern (SetCallback + Execute), AsyncExecutor
// receives the callback as a parameter of ExecuteAsync. This eliminates the
// data race where concurrent calls could overwrite each other's callbacks
// on a shared tool instance.
//
// This is useful for:
// - Long-running operations that shouldn't block the agent loop
// - Subagent spawns that complete independently
// - Background tasks that need to report results later
//
// Example:
//
// func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult {
// go func() {
// result := t.runSubagent(ctx, args)
// if cb != nil { cb(ctx, result) }
// }()
// return AsyncResult("Subagent spawned, will report back")
// }
type AsyncExecutor interface {
Tool
// ExecuteAsync runs the tool asynchronously. The callback cb will be
// invoked (possibly from another goroutine) when the async operation
// completes. cb is guaranteed to be non-nil by the caller (registry).
ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult
}
// StatusProvider is an optional interface that tools can implement
// to inject runtime status information into the system prompt.
// Return an empty string to inject nothing.
type StatusProvider interface {
RuntimeStatus() string
}

View file

@ -2,15 +2,9 @@ package tools
import ( import (
"context" "context"
"encoding/json"
"fmt"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
) )
// NormalizeToolName keeps only lowercase ASCII letters. // NormalizeToolName keeps only lowercase ASCII letters.
@ -79,107 +73,6 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string
return r.ExecuteWithContext(ctx, name, args, "", "", nil) return r.ExecuteWithContext(ctx, name, args, "", "", nil)
} }
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
// If the tool implements AsyncTool and a non-nil callback is provided,
// the callback will be set on the tool before execution.
func (r *ToolRegistry) ExecuteWithContext(
ctx context.Context,
name string,
args map[string]any,
channel, chatID string,
asyncCallback AsyncCallback,
) *ToolResult {
logger.InfoCF("tool", "Tool execution started",
map[string]any{
"tool": name,
"args": args,
})
tool, ok := r.Get(name)
if !ok {
available := strings.Join(r.List(), ", ")
logger.ErrorCF("tool", "Tool not found",
map[string]any{
"tool": name,
})
return ErrorResult(fmt.Sprintf(
"tool %q not found. Available tools: %s", name, available,
)).WithError(fmt.Errorf("tool not found"))
}
// If tool implements ContextualTool, set context
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
contextualTool.SetContext(channel, chatID)
}
// If tool implements AsyncTool and callback is provided, set callback
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
asyncTool.SetCallback(asyncCallback)
logger.DebugCF("tool", "Async callback injected",
map[string]any{
"tool": name,
})
}
start := time.Now()
result := tool.Execute(ctx, args)
duration := time.Since(start)
// Log based on result type
if result.IsError {
logger.ErrorCF("tool", "Tool execution failed",
map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
"error": result.ForLLM,
})
} else if result.Async {
logger.InfoCF("tool", "Tool started (async)",
map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
})
} else {
logger.InfoCF("tool", "Tool execution completed",
map[string]any{
"tool": name,
"duration_ms": duration.Milliseconds(),
"result_length": len(result.ForLLM),
})
}
return result
}
// sortedToolNames returns tool names in sorted order for deterministic iteration. // sortedToolNames returns tool names in sorted order for deterministic iteration.
// This is critical for KV cache stability: non-deterministic map iteration would // This is critical for KV cache stability: non-deterministic map iteration would
@ -216,62 +109,6 @@ func (r *ToolRegistry) GetDefinitions() []map[string]any {
return definitions return definitions
} }
// ToProviderDefs converts tool definitions to provider-compatible format.
// This is the format expected by LLM provider APIs.
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
sorted := r.sortedToolNames()
definitions := make([]providers.ToolDefinition, 0, len(sorted))
for _, name := range sorted {
tool := r.tools[name]
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
fn, ok := schema["function"].(map[string]any)
if !ok {
continue
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any)
paramsRaw := json.RawMessage(`{}`)
if len(params) > 0 {
if payload, err := json.Marshal(params); err == nil {
paramsRaw = json.RawMessage(payload)
}
}
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
Function: providers.ToolFunctionDefinition{
Name: name,
Description: desc,
Parameters: paramsRaw,
},
})
}
return definitions
}
// List returns a list of all registered tool names. // List returns a list of all registered tool names.
func (r *ToolRegistry) List() []string { func (r *ToolRegistry) List() []string {
@ -291,101 +128,3 @@ func (r *ToolRegistry) Count() int {
return len(r.tools) return len(r.tools)
} }
// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider.
// Returns empty string if no tool has status to report.
func (r *ToolRegistry) GetRuntimeStatus() string {
r.mu.RLock()
defer r.mu.RUnlock()
var parts []string
for _, tool := range r.tools {
if sp, ok := tool.(StatusProvider); ok {
if s := sp.RuntimeStatus(); s != "" {
parts = append(parts, s)
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n")
}
// buildParamHint extracts parameter names from a JSON schema and returns
// a hint string like "(task, label?, preset?)". Required params are bare,
// optional params have a trailing "?".
func buildParamHint(schema map[string]any) string {
props, _ := schema["properties"].(map[string]any)
if len(props) == 0 {
return ""
}
reqSlice, _ := schema["required"].([]string)
reqSet := make(map[string]bool, len(reqSlice))
for _, r := range reqSlice {
reqSet[r] = true
}
names := make([]string, 0, len(props))
for name := range props {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
// Required params first, then optional
for _, name := range names {
if reqSet[name] {
parts = append(parts, name)
}
}
for _, name := range names {
if !reqSet[name] {
parts = append(parts, name+"?")
}
}
return "(" + strings.Join(parts, ", ") + ")"
}
// GetSummaries returns human-readable summaries of all registered tools.
// Returns a slice of "- `name`(params) - description" strings.
func (r *ToolRegistry) GetSummaries() []string {
r.mu.RLock()
defer r.mu.RUnlock()
sorted := r.sortedToolNames()
summaries := make([]string, 0, len(sorted))
for _, name := range sorted {
tool := r.tools[name]
hint := buildParamHint(tool.Parameters())
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description()))
}
return summaries
}

267
pkg/tools/registry_ext.go Normal file
View file

@ -0,0 +1,267 @@
package tools
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// ExecuteWithContext executes a tool with channel/chatID context and optional async callback.
// If the tool implements AsyncTool and a non-nil callback is provided,
// the callback will be set on the tool before execution.
func (r *ToolRegistry) ExecuteWithContext(
ctx context.Context,
name string,
args map[string]any,
channel, chatID string,
asyncCallback AsyncCallback,
) *ToolResult {
logger.InfoCF("tool", "Tool execution started",
map[string]any{
"tool": name,
"args": args,
})
tool, ok := r.Get(name)
if !ok {
available := strings.Join(r.List(), ", ")
logger.ErrorCF("tool", "Tool not found",
map[string]any{
"tool": name,
})
return ErrorResult(fmt.Sprintf(
"tool %q not found. Available tools: %s", name, available,
)).WithError(fmt.Errorf("tool not found"))
}
// If tool implements ContextualTool, set context
if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" {
contextualTool.SetContext(channel, chatID)
}
// If tool implements AsyncTool and callback is provided, set callback
if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil {
asyncTool.SetCallback(asyncCallback)
logger.DebugCF("tool", "Async callback injected",
map[string]any{
"tool": name,
})
}
start := time.Now()
result := tool.Execute(ctx, args)
duration := time.Since(start)
// Log based on result type
if result.IsError {
logger.ErrorCF("tool", "Tool execution failed",
map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
"error": result.ForLLM,
})
} else if result.Async {
logger.InfoCF("tool", "Tool started (async)",
map[string]any{
"tool": name,
"duration": duration.Milliseconds(),
})
} else {
logger.InfoCF("tool", "Tool execution completed",
map[string]any{
"tool": name,
"duration_ms": duration.Milliseconds(),
"result_length": len(result.ForLLM),
})
}
return result
}
// ToProviderDefs converts tool definitions to provider-compatible format.
// This is the format expected by LLM provider APIs.
func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
sorted := r.sortedToolNames()
definitions := make([]providers.ToolDefinition, 0, len(sorted))
for _, name := range sorted {
tool := r.tools[name]
schema := ToolToSchema(tool)
// Safely extract nested values with type checks
fn, ok := schema["function"].(map[string]any)
if !ok {
continue
}
name, _ := fn["name"].(string)
desc, _ := fn["description"].(string)
params, _ := fn["parameters"].(map[string]any)
paramsRaw := json.RawMessage(`{}`)
if len(params) > 0 {
if payload, err := json.Marshal(params); err == nil {
paramsRaw = json.RawMessage(payload)
}
}
definitions = append(definitions, providers.ToolDefinition{
Type: "function",
Function: providers.ToolFunctionDefinition{
Name: name,
Description: desc,
Parameters: paramsRaw,
},
})
}
return definitions
}
// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider.
// Returns empty string if no tool has status to report.
func (r *ToolRegistry) GetRuntimeStatus() string {
r.mu.RLock()
defer r.mu.RUnlock()
var parts []string
for _, tool := range r.tools {
if sp, ok := tool.(StatusProvider); ok {
if s := sp.RuntimeStatus(); s != "" {
parts = append(parts, s)
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n")
}
// buildParamHint extracts parameter names from a JSON schema and returns
// a hint string like "(task, label?, preset?)". Required params are bare,
// optional params have a trailing "?".
func buildParamHint(schema map[string]any) string {
props, _ := schema["properties"].(map[string]any)
if len(props) == 0 {
return ""
}
reqSlice, _ := schema["required"].([]string)
reqSet := make(map[string]bool, len(reqSlice))
for _, r := range reqSlice {
reqSet[r] = true
}
names := make([]string, 0, len(props))
for name := range props {
names = append(names, name)
}
sort.Strings(names)
parts := make([]string, 0, len(names))
// Required params first, then optional
for _, name := range names {
if reqSet[name] {
parts = append(parts, name)
}
}
for _, name := range names {
if !reqSet[name] {
parts = append(parts, name+"?")
}
}
return "(" + strings.Join(parts, ", ") + ")"
}
// GetSummaries returns human-readable summaries of all registered tools.
// Returns a slice of "- `name`(params) - description" strings.
func (r *ToolRegistry) GetSummaries() []string {
r.mu.RLock()
defer r.mu.RUnlock()
sorted := r.sortedToolNames()
summaries := make([]string, 0, len(sorted))
for _, name := range sorted {
tool := r.tools[name]
hint := buildParamHint(tool.Parameters())
summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description()))
}
return summaries
}