picoclaw/pkg/agent/loop_commands.go
dj-oyu c11a43e766 feat: implement upstream event/steering/interrupt features
Wire upstream test expectations into fork's loop architecture:

- /switch model rebuilds provider candidates for immediate routing
- Publish ReasoningContent to configured reasoning channel
- Auto-continue with late steering messages in Run() loop
- Direct response refresh when Steer() arrives mid-turn
- Preserve steering media refs in session, resolve for provider
- Graceful interrupt: skip remaining tools, terminal no-tool LLM call
- Hard interrupt: cancel context, rollback session history

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 00:31:40 +09:00

726 lines
15 KiB
Go

package agent
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/stats"
)
// buildCommandsRuntime constructs a commands.Runtime wired to the current
// agent and loop state. This is the upstream pattern for providing runtime
// dependencies to command handlers.
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey string) *commands.Runtime {
return &commands.Runtime{
Config: al.GetConfig(),
GetModelInfo: func() (string, string) {
if agent == nil {
return "unknown", "unknown"
}
prov := resolvedCandidateProvider(agent.Candidates, "")
if prov == "" {
prov, _ = providers.ExtractProtocol(agent.Model)
}
return agent.Model, prov
},
ListAgentIDs: func() []string {
return al.GetRegistry().ListAgentIDs()
},
ListDefinitions: func() []commands.Definition {
return al.cmdRegistry.Definitions()
},
GetEnabledChannels: func() []string {
if al.channelManager == nil {
return nil
}
return al.channelManager.GetEnabledChannels()
},
SwitchModel: func(value string) (string, error) {
if agent == nil {
return "", fmt.Errorf("no default agent configured")
}
// Validate model exists in model_list
cfg := al.GetConfig()
if cfg != nil && len(cfg.ModelList) > 0 {
found := false
for _, m := range cfg.ModelList {
if m.ModelName == value {
found = true
break
}
}
if !found {
return "", fmt.Errorf("model %q not found in model_list or providers", value)
}
}
old := agent.Model
agent.Model = value
// Rebuild candidates so subsequent LLM calls use the new
// model's provider/endpoint instead of the old one.
agent.Candidates = resolveModelCandidates(
cfg,
cfg.Agents.Defaults.Provider,
value,
nil,
)
return old, nil
},
SwitchChannel: func(value string) error {
if al.channelManager == nil {
return fmt.Errorf("channel manager not initialized")
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Errorf("channel '%s' not found or not enabled", value)
}
return nil
},
ClearHistory: func() error {
if agent == nil || sessionKey == "" {
return fmt.Errorf("no active session")
}
agent.Sessions.SetHistory(sessionKey, nil)
agent.Sessions.SetSummary(sessionKey, "")
return agent.Sessions.Save(sessionKey)
},
ReloadConfig: func() error {
if al.reloadFunc != nil {
return al.reloadFunc()
}
return fmt.Errorf("reload not available")
},
}
}
// handleCommand processes slash commands. It first tries the upstream
// commands.Executor (for /show, /list, /switch, /check, /clear, /reload, etc.),
// then falls back to fork-specific commands (/session, /skills, /plan, /heartbeat).
func (al *AgentLoop) handleCommand(
ctx context.Context,
msg bus.InboundMessage,
agent *AgentInstance,
sessionKey string,
) (string, bool) {
content := strings.TrimSpace(msg.Content)
if !commands.HasCommandPrefix(content) {
return "", false
}
// Build a reply collector — the Executor calls req.Reply with the response.
var response string
replyFn := func(text string) error {
response = text
return nil
}
rt := al.buildCommandsRuntime(agent, sessionKey)
exec := commands.NewExecutor(al.cmdRegistry, rt)
result := exec.Execute(ctx, commands.Request{
Channel: msg.Channel,
ChatID: msg.ChatID,
SenderID: msg.SenderID,
Text: content,
Reply: replyFn,
})
if result.Outcome == commands.OutcomeHandled {
if result.Err != nil {
return fmt.Sprintf("Command error: %v", result.Err), true
}
return response, true
}
// Fallback: fork-specific commands not in the upstream registry
parts := strings.Fields(content)
if len(parts) == 0 {
return "", false
}
cmd := parts[0]
args := parts[1:]
switch cmd {
case "/use":
if len(args) == 0 {
return "Usage: /use <skill> [message]", true
}
skillName := args[0]
// If we reached here, expandForkCommands didn't expand (either skill not found or no message)
if agent != nil {
if _, found := agent.ContextBuilder.LoadSkill(skillName); found {
// Skill exists but no message → arm for next message.
// Use channel:chatID as key since session key may not be resolved yet.
armKey := msg.Channel + ":" + msg.ChatID
if sessionKey != "" {
armKey = sessionKey
}
al.pendingSkills.Store(armKey, skillName)
return fmt.Sprintf("Skill %q is armed for your next message.", skillName), true
}
}
return fmt.Sprintf("Unknown skill: %s", skillName), 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
}
_, found := agent.ContextBuilder.LoadSkill(skillName)
if !found {
return "", "", false
}
// If no user message, don't expand — let it fall through to /use arming handler
if userMessage == "" {
return "", "", false
}
tag := fmt.Sprintf("[Skill: %s]", skillName)
// The skill content will be injected into the system prompt via ForcedSkills/BuildMessages.
expanded = userMessage
// Build compact form: skill name tag + user message only (for history)
compactForm := tag
if userMessage != "" {
compactForm = tag + "\n" + userMessage
}
return expanded, 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()
}