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:
parent
7bfd5cc658
commit
77c4f69d2e
14 changed files with 3786 additions and 3706 deletions
3357
pkg/agent/loop.go
3357
pkg/agent/loop.go
File diff suppressed because it is too large
Load diff
717
pkg/agent/loop_commands.go
Normal file
717
pkg/agent/loop_commands.go
Normal 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
281
pkg/agent/loop_info.go
Normal 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
340
pkg/agent/loop_orch.go
Normal 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
674
pkg/agent/loop_plan.go
Normal 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
393
pkg/agent/loop_session.go
Normal 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
317
pkg/agent/loop_streaming.go
Normal 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
701
pkg/agent/loop_task.go
Normal 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()
|
||||||
|
}
|
||||||
|
|
@ -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
14
pkg/config/config_ext.go
Normal 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"`
|
||||||
|
}
|
||||||
|
|
@ -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
81
pkg/tools/base_ext.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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
267
pkg/tools/registry_ext.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue