refactor: normalize blank lines to match upstream style

Remove extra blank lines between statements that upstream has removed,
reducing merge conflict surface. Only blank-line-only differences are
affected — no behavioral changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-13 03:34:44 +09:00
parent 1c12de7630
commit 8eaf46e87e
24 changed files with 0 additions and 960 deletions

View file

@ -149,9 +149,7 @@ type ContextBuilder struct {
orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
// The cache auto-invalidates when workspace source files change (mtime check). // The cache auto-invalidates when workspace source files change (mtime check).
systemPromptMutex sync.RWMutex systemPromptMutex sync.RWMutex
@ -176,13 +174,11 @@ func getGlobalConfigDir() string {
if err != nil { if err != nil {
return "" return ""
} }
return filepath.Join(home, ".picoclaw") return filepath.Join(home, ".picoclaw")
} }
func NewContextBuilder(workspace string) *ContextBuilder { func NewContextBuilder(workspace string) *ContextBuilder {
// builtin skills: skills directory in current project // builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory // Use the skills/ directory under the current working directory
wd, _ := os.Getwd() wd, _ := os.Getwd()
@ -282,13 +278,9 @@ You are picoclaw, %s.
## Workspace ## Workspace
Your workspace is at: %s Your workspace is at: %s
- Memory: %s/memory/MEMORY.md - Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md - Skills: %s/skills/{skill-name}/SKILL.md
@ -299,12 +291,8 @@ Your workspace is at: %s
## Important Rules ## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
@ -414,7 +402,6 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
parts := []string{} parts := []string{}
// Core identity section // Core identity section
parts = append(parts, cb.getIdentity()) parts = append(parts, cb.getIdentity())
// Orchestration guidance — injected only when spawn tool is registered // Orchestration guidance — injected only when spawn tool is registered
@ -426,26 +413,18 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
} }
// Bootstrap files // Bootstrap files
bootstrapContent := cb.LoadBootstrapFiles() bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" { if bootstrapContent != "" {
parts = append(parts, bootstrapContent) parts = append(parts, bootstrapContent)
} }
// Skills - show summary, AI can read full content with read_file tool // Skills - show summary, AI can read full content with read_file tool
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" { if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills parts = append(parts, fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool.
%s`, skillsSummary)) %s`, skillsSummary))
} }
@ -464,75 +443,50 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
} }
// Memory context // Memory context
memoryContext := cb.memory.GetMemoryContext() memoryContext := cb.memory.GetMemoryContext()
if memoryContext != "" { if memoryContext != "" {
parts = append(parts, "# Memory\n\n"+memoryContext) parts = append(parts, "# Memory\n\n"+memoryContext)
} }
// Join with "---" separator // Join with "---" separator
return strings.Join(parts, "\n\n---\n\n") return strings.Join(parts, "\n\n---\n\n")
} }
// BuildSystemPromptWithCache returns the cached system prompt if available // BuildSystemPromptWithCache returns the cached system prompt if available
// and source files haven't changed, otherwise builds and caches it. // and source files haven't changed, otherwise builds and caches it.
// Source file changes are detected via mtime checks (cheap stat calls). // Source file changes are detected via mtime checks (cheap stat calls).
func (cb *ContextBuilder) BuildSystemPromptWithCache() string { func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
// Try read lock first — fast path when cache is valid // Try read lock first — fast path when cache is valid
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
result := cb.cachedSystemPrompt result := cb.cachedSystemPrompt
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
return result return result
} }
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
// Acquire write lock for building // Acquire write lock for building
cb.systemPromptMutex.Lock() cb.systemPromptMutex.Lock()
defer cb.systemPromptMutex.Unlock() defer cb.systemPromptMutex.Unlock()
// Double-check: another goroutine may have rebuilt while we waited // Double-check: another goroutine may have rebuilt while we waited
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
return cb.cachedSystemPrompt return cb.cachedSystemPrompt
} }
// Snapshot the baseline (existence + max mtime) BEFORE building the prompt. // Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
// This way cachedAt reflects the pre-build state: if a file is modified // This way cachedAt reflects the pre-build state: if a file is modified
// during BuildSystemPrompt, its new mtime will be > baseline.maxMtime, // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
// so the next sourceFilesChangedLocked check will correctly trigger a // so the next sourceFilesChangedLocked check will correctly trigger a
// rebuild. The alternative (baseline after build) risks caching stale // rebuild. The alternative (baseline after build) risks caching stale
// content with a too-new baseline, making the staleness invisible. // content with a too-new baseline, making the staleness invisible.
baseline := cb.buildCacheBaseline() baseline := cb.buildCacheBaseline()
prompt := cb.BuildSystemPrompt() prompt := cb.BuildSystemPrompt()
cb.cachedSystemPrompt = prompt cb.cachedSystemPrompt = prompt
cb.cachedAt = baseline.maxMtime cb.cachedAt = baseline.maxMtime
cb.existedAtCache = baseline.existed cb.existedAtCache = baseline.existed
logger.DebugCF("agent", "System prompt cached", logger.DebugCF("agent", "System prompt cached",
map[string]any{ map[string]any{
"length": len(prompt), "length": len(prompt),
}) })
@ -541,20 +495,14 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
} }
// InvalidateCache clears the cached system prompt. // InvalidateCache clears the cached system prompt.
// Normally not needed because the cache auto-invalidates via mtime checks, // Normally not needed because the cache auto-invalidates via mtime checks,
// but this is useful for tests or explicit reload commands. // but this is useful for tests or explicit reload commands.
func (cb *ContextBuilder) InvalidateCache() { func (cb *ContextBuilder) InvalidateCache() {
cb.systemPromptMutex.Lock() cb.systemPromptMutex.Lock()
defer cb.systemPromptMutex.Unlock() defer cb.systemPromptMutex.Unlock()
cb.cachedSystemPrompt = "" cb.cachedSystemPrompt = ""
cb.cachedAt = time.Time{} cb.cachedAt = time.Time{}
cb.existedAtCache = nil cb.existedAtCache = nil
logger.DebugCF("agent", "System prompt cache invalidated", nil) logger.DebugCF("agent", "System prompt cache invalidated", nil)
@ -607,9 +555,7 @@ func (cb *ContextBuilder) sourcePaths() []string {
} }
// cacheBaseline holds the file existence snapshot and the latest observed // cacheBaseline holds the file existence snapshot and the latest observed
// mtime across all tracked paths. Used as the cache reference point. // mtime across all tracked paths. Used as the cache reference point.
type cacheBaseline struct { type cacheBaseline struct {
existed map[string]bool existed map[string]bool
@ -635,9 +581,7 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
for _, p := range allPaths { for _, p := range allPaths {
info, err := os.Stat(p) info, err := os.Stat(p)
existed[p] = err == nil existed[p] = err == nil
if err == nil && info.ModTime().After(maxMtime) { if err == nil && info.ModTime().After(maxMtime) {
maxMtime = info.ModTime() maxMtime = info.ModTime()
} }
@ -660,17 +604,11 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
}) })
// If no tracked files exist yet (empty workspace), maxMtime is zero. // If no tracked files exist yet (empty workspace), maxMtime is zero.
// Use a very old non-zero time so that: // Use a very old non-zero time so that:
// 1. cachedAt.IsZero() won't trigger perpetual rebuilds. // 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
// 2. Any real file created afterwards has mtime > cachedAt, so it // 2. Any real file created afterwards has mtime > cachedAt, so it
// will be detected by fileChangedSince (unlike time.Now() which // will be detected by fileChangedSince (unlike time.Now() which
// could race with a file whose mtime <= Now). // could race with a file whose mtime <= Now).
if maxMtime.IsZero() { if maxMtime.IsZero() {
maxMtime = time.Unix(1, 0) maxMtime = time.Unix(1, 0)
} }
@ -679,19 +617,12 @@ func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
} }
// sourceFilesChangedLocked checks whether any workspace source file has been // sourceFilesChangedLocked checks whether any workspace source file has been
// modified, created, or deleted since the cache was last built. // modified, created, or deleted since the cache was last built.
// //
// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex. // IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the // Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
// lock itself (it would deadlock when called from BuildSystemPromptWithCache // lock itself (it would deadlock when called from BuildSystemPromptWithCache
// which already holds RLock or Lock). // which already holds RLock or Lock).
func (cb *ContextBuilder) sourceFilesChangedLocked() bool { func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
if cb.cachedAt.IsZero() { if cb.cachedAt.IsZero() {
return true return true
@ -737,55 +668,37 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
} }
// fileChangedSince returns true if a tracked source file has been modified, // fileChangedSince returns true if a tracked source file has been modified,
// newly created, or deleted since the cache was built. // newly created, or deleted since the cache was built.
// //
// Four cases: // Four cases:
// - existed at cache time, exists now -> check mtime // - existed at cache time, exists now -> check mtime
// - existed at cache time, gone now -> changed (deleted) // - existed at cache time, gone now -> changed (deleted)
// - absent at cache time, exists now -> changed (created) // - absent at cache time, exists now -> changed (created)
// - absent at cache time, gone now -> no change // - absent at cache time, gone now -> no change
func (cb *ContextBuilder) fileChangedSince(path string) bool { func (cb *ContextBuilder) fileChangedSince(path string) bool {
// Defensive: if existedAtCache was never initialized, treat as changed // Defensive: if existedAtCache was never initialized, treat as changed
// so the cache rebuilds rather than silently serving stale data. // so the cache rebuilds rather than silently serving stale data.
if cb.existedAtCache == nil { if cb.existedAtCache == nil {
return true return true
} }
existedBefore := cb.existedAtCache[path] existedBefore := cb.existedAtCache[path]
info, err := os.Stat(path) info, err := os.Stat(path)
existsNow := err == nil existsNow := err == nil
if existedBefore != existsNow { if existedBefore != existsNow {
return true // file was created or deleted return true // file was created or deleted
} }
if !existsNow { if !existsNow {
return false // didn't exist before, doesn't exist now return false // didn't exist before, doesn't exist now
} }
return info.ModTime().After(cb.cachedAt) return info.ModTime().After(cb.cachedAt)
} }
// errWalkStop is a sentinel error used to stop filepath.WalkDir early. // errWalkStop is a sentinel error used to stop filepath.WalkDir early.
// Using a dedicated error (instead of fs.SkipAll) makes the early-exit // Using a dedicated error (instead of fs.SkipAll) makes the early-exit
// intent explicit and avoids the nilerr linter warning that would fire // intent explicit and avoids the nilerr linter warning that would fire
// if the callback returned nil when its err parameter is non-nil. // if the callback returned nil when its err parameter is non-nil.
var errWalkStop = errors.New("walk stop") var errWalkStop = errors.New("walk stop")
// skillFilesModifiedSince recursively walks the skills directory and checks // skillFilesModifiedSince recursively walks the skills directory and checks
@ -935,28 +848,18 @@ func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo {
} }
// buildDynamicContext returns a short dynamic context string with per-request info. // buildDynamicContext returns a short dynamic context string with per-request info.
// This changes every request (time, session) so it is NOT part of the cached prompt. // This changes every request (time, session) so it is NOT part of the cached prompt.
// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
// - OpenAI / Codex: prompt_cache_key for prefix-based caching // - OpenAI / Codex: prompt_cache_key for prefix-based caching
// //
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
// See: https://platform.openai.com/docs/guides/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching
func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
now := time.Now().Format("2006-01-02 15:04 (Monday)") now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
if channel != "" && chatID != "" { if channel != "" && chatID != "" {
@ -968,97 +871,62 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
func (cb *ContextBuilder) BuildMessages( func (cb *ContextBuilder) BuildMessages(
history []providers.Message, history []providers.Message,
summary string, summary string,
currentMessage string, currentMessage string,
media []string, media []string,
channel, chatID string, channel, chatID string,
) []providers.Message { ) []providers.Message {
messages := []providers.Message{} messages := []providers.Message{}
// The static part (identity, bootstrap, skills, memory) is cached locally to // The static part (identity, bootstrap, skills, memory) is cached locally to
// avoid repeated file I/O and string building on every call (fixes issue #607). // avoid repeated file I/O and string building on every call (fixes issue #607).
// Dynamic parts (time, session, summary) are appended per request. // Dynamic parts (time, session, summary) are appended per request.
// Everything is sent as a single system message for provider compatibility: // Everything is sent as a single system message for provider compatibility:
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
// to the top-level "system" parameter in the Messages API request. A single // to the top-level "system" parameter in the Messages API request. A single
// contiguous system block makes this extraction straightforward. // contiguous system block makes this extraction straightforward.
// - Codex maps only the first system message to its instructions field. // - Codex maps only the first system message to its instructions field.
// - OpenAI-compat passes messages through as-is. // - OpenAI-compat passes messages through as-is.
staticPrompt := cb.BuildSystemPromptWithCache() staticPrompt := cb.BuildSystemPromptWithCache()
// Build short dynamic context (time, runtime, session) — changes per request // Build short dynamic context (time, runtime, session) — changes per request
dynamicCtx := cb.buildDynamicContext(channel, chatID) dynamicCtx := cb.buildDynamicContext(channel, chatID)
// Compose a single system message: static (cached) + dynamic + optional summary. // Compose a single system message: static (cached) + dynamic + optional summary.
// Keeping all system content in one message ensures every provider adapter can // Keeping all system content in one message ensures every provider adapter can
// extract it correctly (Anthropic adapter -> top-level system param, // extract it correctly (Anthropic adapter -> top-level system param,
// Codex -> instructions field). // Codex -> instructions field).
// //
// SystemParts carries the same content as structured blocks so that // SystemParts carries the same content as structured blocks so that
// cache-aware adapters (Anthropic) can set per-block cache_control. // cache-aware adapters (Anthropic) can set per-block cache_control.
// The static block is marked "ephemeral" — its prefix hash is stable // The static block is marked "ephemeral" — its prefix hash is stable
// across requests, enabling LLM-side KV cache reuse. // across requests, enabling LLM-side KV cache reuse.
stringParts := []string{staticPrompt, dynamicCtx} stringParts := []string{staticPrompt, dynamicCtx}
contentBlocks := []providers.ContentBlock{ contentBlocks := []providers.ContentBlock{
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
{Type: "text", Text: dynamicCtx}, {Type: "text", Text: dynamicCtx},
} }
if summary != "" { if summary != "" {
summaryText := fmt.Sprintf( summaryText := fmt.Sprintf(
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
summary) summary)
stringParts = append(stringParts, summaryText) stringParts = append(stringParts, summaryText)
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
} }
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
// Log system prompt summary for debugging (debug mode only). // Log system prompt summary for debugging (debug mode only).
// Read cachedSystemPrompt under lock to avoid a data race with // Read cachedSystemPrompt under lock to avoid a data race with
// concurrent InvalidateCache / BuildSystemPromptWithCache writes. // concurrent InvalidateCache / BuildSystemPromptWithCache writes.
cb.systemPromptMutex.RLock() cb.systemPromptMutex.RLock()
isCached := cb.cachedSystemPrompt != "" isCached := cb.cachedSystemPrompt != ""
cb.systemPromptMutex.RUnlock() cb.systemPromptMutex.RUnlock()
logger.DebugCF("agent", "System prompt built", logger.DebugCF("agent", "System prompt built",
map[string]any{ map[string]any{
"static_chars": len(staticPrompt), "static_chars": len(staticPrompt),
@ -1080,7 +948,6 @@ func (cb *ContextBuilder) BuildMessages(
} }
logger.DebugCF("agent", "System prompt preview", logger.DebugCF("agent", "System prompt preview",
map[string]any{ map[string]any{
"preview": preview, "preview": preview,
}) })
@ -1088,11 +955,8 @@ func (cb *ContextBuilder) BuildMessages(
history = sanitizeHistoryForProvider(history) history = sanitizeHistoryForProvider(history)
// Single system message containing all context — compatible with all providers. // Single system message containing all context — compatible with all providers.
// SystemParts enables cache-aware adapters to set per-block cache_control; // SystemParts enables cache-aware adapters to set per-block cache_control;
// Content is the concatenated fallback for adapters that don't read SystemParts. // Content is the concatenated fallback for adapters that don't read SystemParts.
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "system", Role: "system",
@ -1102,11 +966,9 @@ func (cb *ContextBuilder) BuildMessages(
}) })
// Add conversation history // Add conversation history
messages = append(messages, history...) messages = append(messages, history...)
// Add current user message // Add current user message
if strings.TrimSpace(currentMessage) != "" { if strings.TrimSpace(currentMessage) != "" {
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "user", Role: "user",
@ -1124,86 +986,58 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
} }
sanitized := make([]providers.Message, 0, len(history)) sanitized := make([]providers.Message, 0, len(history))
for _, msg := range history { for _, msg := range history {
switch msg.Role { switch msg.Role {
case "system": case "system":
// Drop system messages from history. BuildMessages always // Drop system messages from history. BuildMessages always
// constructs its own single system message (static + dynamic + // constructs its own single system message (static + dynamic +
// summary); extra system messages would break providers that // summary); extra system messages would break providers that
// only accept one (Anthropic, Codex). // only accept one (Anthropic, Codex).
logger.DebugCF("agent", "Dropping system message from history", map[string]any{}) logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
continue continue
case "tool": case "tool":
if len(sanitized) == 0 { if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
continue continue
} }
// Walk backwards to find the nearest assistant message, // Walk backwards to find the nearest assistant message,
// skipping over any preceding tool messages (multi-tool-call case). // skipping over any preceding tool messages (multi-tool-call case).
foundAssistant := false foundAssistant := false
for i := len(sanitized) - 1; i >= 0; i-- { for i := len(sanitized) - 1; i >= 0; i-- {
if sanitized[i].Role == "tool" { if sanitized[i].Role == "tool" {
continue continue
} }
if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 {
foundAssistant = true foundAssistant = true
} }
break break
} }
if !foundAssistant { if !foundAssistant {
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
continue continue
} }
sanitized = append(sanitized, msg) sanitized = append(sanitized, msg)
case "assistant": case "assistant":
if len(msg.ToolCalls) > 0 { if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 { if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
continue continue
} }
prev := sanitized[len(sanitized)-1] prev := sanitized[len(sanitized)-1]
if prev.Role != "user" && prev.Role != "tool" { if prev.Role != "user" && prev.Role != "tool" {
logger.DebugCF( logger.DebugCF(
"agent", "agent",
"Dropping assistant tool-call turn with invalid predecessor", "Dropping assistant tool-call turn with invalid predecessor",
map[string]any{"prev_role": prev.Role}, map[string]any{"prev_role": prev.Role},
) )
continue continue
} }
} }
sanitized = append(sanitized, msg) sanitized = append(sanitized, msg)
default: default:
sanitized = append(sanitized, msg) sanitized = append(sanitized, msg)
} }
} }
@ -1213,7 +1047,6 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
func (cb *ContextBuilder) AddToolResult( func (cb *ContextBuilder) AddToolResult(
messages []providers.Message, messages []providers.Message,
toolCallID, toolName, result string, toolCallID, toolName, result string,
) []providers.Message { ) []providers.Message {
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
@ -1223,15 +1056,12 @@ func (cb *ContextBuilder) AddToolResult(
ToolCallID: toolCallID, ToolCallID: toolCallID,
}) })
return messages return messages
} }
func (cb *ContextBuilder) AddAssistantMessage( func (cb *ContextBuilder) AddAssistantMessage(
messages []providers.Message, messages []providers.Message,
content string, content string,
toolCalls []map[string]any, toolCalls []map[string]any,
) []providers.Message { ) []providers.Message {
msg := providers.Message{ msg := providers.Message{
@ -1239,11 +1069,8 @@ func (cb *ContextBuilder) AddAssistantMessage(
Content: content, Content: content,
} }
// Always add assistant message, whether or not it has tool calls // Always add assistant message, whether or not it has tool calls
messages = append(messages, msg) messages = append(messages, msg)
return messages return messages
} }
@ -1376,16 +1203,12 @@ func (cb *ContextBuilder) GetPlanTaskName() string {
} }
// GetSkillsInfo returns information about loaded skills. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]any { func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills() allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills)) skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills { for _, s := range allSkills {
skillNames = append(skillNames, s.Name) skillNames = append(skillNames, s.Name)
} }
return map[string]any{ return map[string]any{
"total": len(allSkills), "total": len(allSkills),

View file

@ -81,22 +81,16 @@ type AgentInstance struct {
} }
// NewAgentInstance creates an agent instance from config. // NewAgentInstance creates an agent instance from config.
func NewAgentInstance( func NewAgentInstance(
agentCfg *config.AgentConfig, agentCfg *config.AgentConfig,
defaults *config.AgentDefaults, defaults *config.AgentDefaults,
cfg *config.Config, cfg *config.Config,
provider providers.LLMProvider, provider providers.LLMProvider,
) *AgentInstance { ) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults) workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755) os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults) model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults) fallbacks := resolveAgentFallbacks(agentCfg, defaults)
restrict := defaults.RestrictToWorkspace restrict := defaults.RestrictToWorkspace
@ -154,20 +148,14 @@ func NewAgentInstance(
contextBuilder := NewContextBuilder(workspace) contextBuilder := NewContextBuilder(workspace)
agentID := routing.DefaultAgentID agentID := routing.DefaultAgentID
agentName := "" agentName := ""
var subagents *config.SubagentsConfig var subagents *config.SubagentsConfig
var skillsFilter []string var skillsFilter []string
if agentCfg != nil { if agentCfg != nil {
agentID = routing.NormalizeAgentID(agentCfg.ID) agentID = routing.NormalizeAgentID(agentCfg.ID)
agentName = agentCfg.Name agentName = agentCfg.Name
subagents = agentCfg.Subagents subagents = agentCfg.Subagents
skillsFilter = agentCfg.Skills skillsFilter = agentCfg.Skills
} }
@ -182,7 +170,6 @@ func NewAgentInstance(
} }
maxIter := defaults.MaxToolIterations maxIter := defaults.MaxToolIterations
if maxIter == 0 { if maxIter == 0 {
maxIter = 20 maxIter = 20
} }
@ -194,42 +181,34 @@ func NewAgentInstance(
} }
maxTokens := defaults.MaxTokens maxTokens := defaults.MaxTokens
if maxTokens == 0 { if maxTokens == 0 {
maxTokens = 8192 maxTokens = 8192
} }
temperature := 0.7 temperature := 0.7
if defaults.Temperature != nil { if defaults.Temperature != nil {
temperature = *defaults.Temperature temperature = *defaults.Temperature
} }
// Resolve fallback candidates // Resolve fallback candidates
modelCfg := providers.ModelConfig{ modelCfg := providers.ModelConfig{
Primary: model, Primary: model,
Fallbacks: fallbacks, Fallbacks: fallbacks,
} }
resolveFromModelList := func(raw string) (string, bool) { resolveFromModelList := func(raw string) (string, bool) {
ensureProtocol := func(model string) string { ensureProtocol := func(model string) string {
model = strings.TrimSpace(model) model = strings.TrimSpace(model)
if model == "" { if model == "" {
return "" return ""
} }
if strings.Contains(model, "/") { if strings.Contains(model, "/") {
return model return model
} }
return "openai/" + model return "openai/" + model
} }
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
return "", false return "", false
} }
@ -241,17 +220,13 @@ func NewAgentInstance(
for i := range cfg.ModelList { for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model) fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" { if fullModel == "" {
continue continue
} }
if fullModel == raw { if fullModel == raw {
return ensureProtocol(fullModel), true return ensureProtocol(fullModel), true
} }
_, modelID := providers.ExtractProtocol(fullModel) _, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw { if modelID == raw {
return ensureProtocol(fullModel), true return ensureProtocol(fullModel), true
} }
@ -333,7 +308,6 @@ func NewAgentInstance(
} }
// resolveAgentWorkspace determines the workspace directory for an agent. // resolveAgentWorkspace determines the workspace directory for an agent.
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
return expandHome(strings.TrimSpace(agentCfg.Workspace)) return expandHome(strings.TrimSpace(agentCfg.Workspace))
@ -351,22 +325,18 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
} }
// resolveAgentModel resolves the primary model for an agent. // resolveAgentModel resolves the primary model for an agent.
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary) return strings.TrimSpace(agentCfg.Model.Primary)
} }
return defaults.GetModelName() return defaults.GetModelName()
} }
// resolveAgentFallbacks resolves the fallback models for an agent. // resolveAgentFallbacks resolves the fallback models for an agent.
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
return agentCfg.Model.Fallbacks return agentCfg.Model.Fallbacks
} }
return defaults.ModelFallbacks return defaults.ModelFallbacks
} }
@ -507,16 +477,12 @@ func expandHome(path string) string {
if path == "" { if path == "" {
return path return path
} }
if path[0] == '~' { if path[0] == '~' {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' { if len(path) > 1 && path[1] == '/' {
return home + path[1:] return home + path[1:]
} }
return home return home
} }
return path return path
} }

View file

@ -1,11 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent // PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT // License: MIT
// //
// Copyright (c) 2026 PicoClaw contributors // Copyright (c) 2026 PicoClaw contributors
package agent package agent
@ -92,7 +88,6 @@ type AgentLoop struct {
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
type processOptions struct { type processOptions struct {
SessionKey string // Session identifier for history/context SessionKey string // Session identifier for history/context
@ -123,9 +118,7 @@ const defaultResponse = "I've completed processing but have no response to give.
func NewAgentLoop( func NewAgentLoop(
cfg *config.Config, cfg *config.Config,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
provider providers.LLMProvider, provider providers.LLMProvider,
enableStats ...bool, enableStats ...bool,
@ -133,17 +126,12 @@ func NewAgentLoop(
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
// Set up shared fallback chain // Set up shared fallback chain
cooldown := providers.NewCooldownTracker() cooldown := providers.NewCooldownTracker()
fallbackChain := providers.NewFallbackChain(cooldown) fallbackChain := providers.NewFallbackChain(cooldown)
// Create state manager using default agent's workspace for channel recording // Create state manager using default agent's workspace for channel recording
defaultAgent := registry.GetDefaultAgent() defaultAgent := registry.GetDefaultAgent()
var stateManager *state.Manager var stateManager *state.Manager
if defaultAgent != nil { if defaultAgent != nil {
stateManager = state.NewManager(defaultAgent.Workspace) stateManager = state.NewManager(defaultAgent.Workspace)
} }
@ -224,21 +212,16 @@ func (al *AgentLoop) SetHeartbeatThreadUpdater(fn func(int)) {
} }
// registerSharedTools registers tools that are shared across all agents (web, message, spawn). // registerSharedTools registers tools that are shared across all agents (web, message, spawn).
func registerSharedTools( func registerSharedTools(
cfg *config.Config, cfg *config.Config,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
registry *AgentRegistry, registry *AgentRegistry,
provider providers.LLMProvider, provider providers.LLMProvider,
al *AgentLoop, al *AgentLoop,
) { ) {
for _, agentID := range registry.ListAgentIDs() { for _, agentID := range registry.ListAgentIDs() {
agent, ok := registry.GetAgent(agentID) agent, ok := registry.GetAgent(agentID)
if !ok { if !ok {
continue continue
} }
@ -469,9 +452,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
for al.running.Load() { for al.running.Load() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
default: default:
} }
@ -738,41 +719,29 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
} }
// inferMediaType determines the media type ("image", "audio", "video", "file") // inferMediaType determines the media type ("image", "audio", "video", "file")
// from a filename and MIME content type. // from a filename and MIME content type.
func inferMediaType(filename, contentType string) string { func inferMediaType(filename, contentType string) string {
ct := strings.ToLower(contentType) ct := strings.ToLower(contentType)
fn := strings.ToLower(filename) fn := strings.ToLower(filename)
if strings.HasPrefix(ct, "image/") { if strings.HasPrefix(ct, "image/") {
return "image" return "image"
} }
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
return "audio" return "audio"
} }
if strings.HasPrefix(ct, "video/") { if strings.HasPrefix(ct, "video/") {
return "video" return "video"
} }
// Fallback: infer from extension // Fallback: infer from extension
ext := filepath.Ext(fn) ext := filepath.Ext(fn)
switch ext { switch ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
return "image" return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
return "audio" return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv": case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video" return "video"
} }
@ -780,26 +749,20 @@ func inferMediaType(filename, contentType string) string {
} }
// RecordLastChannel records the last active channel for this workspace. // RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash. // This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error { func (al *AgentLoop) RecordLastChannel(channel string) error {
if al.state == nil { if al.state == nil {
return nil return nil
} }
return al.state.SetLastChannel(channel) return al.state.SetLastChannel(channel)
} }
// RecordLastChatID records the last active chat ID for this workspace. // RecordLastChatID records the last active chat ID for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash. // This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChatID(chatID string) error { func (al *AgentLoop) RecordLastChatID(chatID string) error {
if al.state == nil { if al.state == nil {
return nil return nil
} }
return al.state.SetLastChatID(chatID) return al.state.SetLastChatID(chatID)
} }
@ -823,7 +786,6 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
func (al *AgentLoop) ProcessDirectWithChannel( func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context, ctx context.Context,
content, sessionKey, channel, chatID string, content, sessionKey, channel, chatID string,
) (string, error) { ) (string, error) {
msg := bus.InboundMessage{ msg := bus.InboundMessage{
@ -846,12 +808,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
} }
// ProcessHeartbeat processes a heartbeat request without session history. // ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context. // Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
agent := al.registry.GetDefaultAgent() agent := al.registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
} }
@ -888,9 +848,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
// Add message preview to log (show full content for error messages) // Add message preview to log (show full content for error messages)
var logContent string var logContent string
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
logContent = msg.Content // Full content for errors logContent = msg.Content // Full content for errors
} else { } else {
@ -971,7 +929,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} }
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {
return al.processSystemMessage(ctx, msg) return al.processSystemMessage(ctx, msg)
} }
@ -1023,11 +980,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}) })
agent, ok := al.registry.GetAgent(route.AgentID) agent, ok := al.registry.GetAgent(route.AgentID)
if !ok { if !ok {
agent = al.registry.GetDefaultAgent() agent = al.registry.GetDefaultAgent()
} }
if agent == nil { if agent == nil {
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
} }
@ -1385,7 +1340,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
if !constants.IsInternalChannel(opts.Channel) { if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil { if err := al.RecordLastChannel(channelKey); err != nil {
logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
} }
@ -1487,12 +1441,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 2. Build messages (skip history for heartbeat) // 2. Build messages (skip history for heartbeat)
var history []providers.Message var history []providers.Message
var summary string var summary string
if !opts.NoHistory { if !opts.NoHistory {
history = agent.Sessions.GetHistory(opts.SessionKey) history = agent.Sessions.GetHistory(opts.SessionKey)
summary = agent.Sessions.GetSummary(opts.SessionKey) summary = agent.Sessions.GetSummary(opts.SessionKey)
// Sanitize history to remove orphaned tool calls (from crashes/session collisions) // Sanitize history to remove orphaned tool calls (from crashes/session collisions)
@ -1517,19 +1468,14 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
_ = agent.Sessions.Save(opts.SessionKey) _ = agent.Sessions.Save(opts.SessionKey)
} }
} }
messages := agent.ContextBuilder.BuildMessages( messages := agent.ContextBuilder.BuildMessages(
history, history,
summary, summary,
opts.UserMessage, opts.UserMessage,
nil, nil,
opts.Channel, opts.Channel,
opts.ChatID, opts.ChatID,
) )
@ -1855,9 +1801,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 9. Log response // 9. Log response
responsePreview := utils.Truncate(finalContent, 120) responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -1875,21 +1819,16 @@ func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string
if al.channelManager == nil { if al.channelManager == nil {
return "" return ""
} }
if ch, ok := al.channelManager.GetChannel(channelName); ok { if ch, ok := al.channelManager.GetChannel(channelName); ok {
return ch.ReasoningChannelID() return ch.ReasoningChannelID()
} }
return "" return ""
} }
func (al *AgentLoop) runLLMIteration( func (al *AgentLoop) runLLMIteration(
ctx context.Context, ctx context.Context,
agent *AgentInstance, agent *AgentInstance,
messages []providers.Message, messages []providers.Message,
opts processOptions, opts processOptions,
task *activeTask, task *activeTask,
@ -1897,7 +1836,6 @@ func (al *AgentLoop) runLLMIteration(
planSnapshot string, planSnapshot string,
) (string, int, error) { ) (string, int, error) {
iteration := 0 iteration := 0
var finalContent string var finalContent string
lastReminderIdx := -1 lastReminderIdx := -1
@ -1952,7 +1890,6 @@ func (al *AgentLoop) runLLMIteration(
} }
logger.DebugCF("agent", "LLM iteration", logger.DebugCF("agent", "LLM iteration",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -1962,7 +1899,6 @@ func (al *AgentLoop) runLLMIteration(
}) })
// Build tool definitions // Build tool definitions
providerToolDefs := agent.Tools.ToProviderDefs() providerToolDefs := agent.Tools.ToProviderDefs()
// Interview mode: strip tool definitions the LLM must not use, // Interview mode: strip tool definitions the LLM must not use,
@ -1974,9 +1910,7 @@ func (al *AgentLoop) runLLMIteration(
} }
// Log LLM request details // Log LLM request details
logger.DebugCF("agent", "LLM request", logger.DebugCF("agent", "LLM request",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -1996,9 +1930,7 @@ func (al *AgentLoop) runLLMIteration(
}) })
// Log full messages (detailed) // Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request", logger.DebugCF("agent", "Full LLM request",
map[string]any{ map[string]any{
"iteration": iteration, "iteration": iteration,
@ -2010,7 +1942,6 @@ func (al *AgentLoop) runLLMIteration(
// Call LLM with fallback chain if candidates are configured. // Call LLM with fallback chain if candidates are configured.
var response *providers.LLMResponse var response *providers.LLMResponse
var err error var err error
// Build onChunk callback for streaming preview. // Build onChunk callback for streaming preview.
@ -2169,11 +2100,9 @@ func (al *AgentLoop) runLLMIteration(
return doCall(ctx, p, model) return doCall(ctx, p, model)
}, },
) )
if fbErr != nil { if fbErr != nil {
return nil, fbErr return nil, fbErr
} }
if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
@ -2181,7 +2110,6 @@ func (al *AgentLoop) runLLMIteration(
map[string]any{"agent_id": agent.ID, "iteration": iteration}) map[string]any{"agent_id": agent.ID, "iteration": iteration})
} }
return fbResult.Response, nil return fbResult.Response, nil
} }
@ -2201,12 +2129,9 @@ func (al *AgentLoop) runLLMIteration(
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "") al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "")
// Retry loop for context/token errors // Retry loop for context/token errors
maxRetries := 2 maxRetries := 2
for retry := 0; retry <= maxRetries; retry++ { for retry := 0; retry <= maxRetries; retry++ {
response, err = callLLM() response, err = callLLM()
if err == nil { if err == nil {
break break
} }
@ -2214,40 +2139,25 @@ func (al *AgentLoop) runLLMIteration(
errMsg := strings.ToLower(err.Error()) errMsg := strings.ToLower(err.Error())
// Check if this is a network/HTTP timeout — not a context window error. // Check if this is a network/HTTP timeout — not a context window error.
isTimeoutError := errors.Is(err, context.DeadlineExceeded) || isTimeoutError := errors.Is(err, context.DeadlineExceeded) ||
strings.Contains(errMsg, "deadline exceeded") || strings.Contains(errMsg, "deadline exceeded") ||
strings.Contains(errMsg, "client.timeout") || strings.Contains(errMsg, "client.timeout") ||
strings.Contains(errMsg, "timed out") || strings.Contains(errMsg, "timed out") ||
strings.Contains(errMsg, "timeout exceeded") strings.Contains(errMsg, "timeout exceeded")
// Detect real context window / token limit errors, excluding network timeouts. // Detect real context window / token limit errors, excluding network timeouts.
isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") ||
strings.Contains(errMsg, "context window") || strings.Contains(errMsg, "context window") ||
strings.Contains(errMsg, "maximum context length") || strings.Contains(errMsg, "maximum context length") ||
strings.Contains(errMsg, "token limit") || strings.Contains(errMsg, "token limit") ||
strings.Contains(errMsg, "too many tokens") || strings.Contains(errMsg, "too many tokens") ||
strings.Contains(errMsg, "max_tokens") || strings.Contains(errMsg, "max_tokens") ||
strings.Contains(errMsg, "invalidparameter") || strings.Contains(errMsg, "invalidparameter") ||
strings.Contains(errMsg, "prompt is too long") || strings.Contains(errMsg, "prompt is too long") ||
strings.Contains(errMsg, "request too large")) strings.Contains(errMsg, "request too large"))
if isTimeoutError && retry < maxRetries { if isTimeoutError && retry < maxRetries {
backoff := time.Duration(retry+1) * 5 * time.Second backoff := time.Duration(retry+1) * 5 * time.Second
logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{
"error": err.Error(), "error": err.Error(),
@ -2255,9 +2165,7 @@ func (al *AgentLoop) runLLMIteration(
"backoff": backoff.String(), "backoff": backoff.String(),
}) })
time.Sleep(backoff) time.Sleep(backoff)
continue continue
} }
@ -2279,21 +2187,14 @@ func (al *AgentLoop) runLLMIteration(
} }
al.forceCompression(agent, opts.SessionKey) al.forceCompression(agent, opts.SessionKey)
newHistory := agent.Sessions.GetHistory(opts.SessionKey) newHistory := agent.Sessions.GetHistory(opts.SessionKey)
newSummary := agent.Sessions.GetSummary(opts.SessionKey) newSummary := agent.Sessions.GetSummary(opts.SessionKey)
messages = agent.ContextBuilder.BuildMessages( messages = agent.ContextBuilder.BuildMessages(
newHistory, newSummary, "", newHistory, newSummary, "",
nil, opts.Channel, opts.ChatID, nil, opts.Channel, opts.ChatID,
) )
continue continue
} }
break break
} }
@ -2317,7 +2218,6 @@ func (al *AgentLoop) runLLMIteration(
if err != nil { if err != nil {
logger.ErrorCF("agent", "LLM call failed", logger.ErrorCF("agent", "LLM call failed",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -2325,7 +2225,6 @@ func (al *AgentLoop) runLLMIteration(
"error": err.Error(), "error": err.Error(),
}) })
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
} }
@ -2347,7 +2246,6 @@ func (al *AgentLoop) runLLMIteration(
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response", logger.DebugCF("agent", "LLM response",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -2504,12 +2402,10 @@ func (al *AgentLoop) runLLMIteration(
"content_chars": len(finalContent), "content_chars": len(finalContent),
}) })
break break
} }
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls { for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
} }
@ -2563,15 +2459,11 @@ func (al *AgentLoop) runLLMIteration(
} }
// Log tool calls // Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls)) toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name) toolNames = append(toolNames, tc.Name)
} }
logger.InfoCF("agent", "LLM requested tool calls", logger.InfoCF("agent", "LLM requested tool calls",
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
@ -2685,7 +2577,6 @@ func (al *AgentLoop) runLLMIteration(
} }
// Build assistant message with tool calls // Build assistant message with tool calls
assistantMsg := providers.Message{ assistantMsg := providers.Message{
Role: "assistant", Role: "assistant",
@ -2693,14 +2584,10 @@ func (al *AgentLoop) runLLMIteration(
ReasoningContent: response.ReasoningContent, ReasoningContent: response.ReasoningContent,
} }
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
// Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
extraContent := tc.ExtraContent extraContent := tc.ExtraContent
thoughtSignature := "" thoughtSignature := ""
if tc.Function != nil { if tc.Function != nil {
thoughtSignature = tc.Function.ThoughtSignature thoughtSignature = tc.Function.ThoughtSignature
} }
@ -2709,7 +2596,6 @@ func (al *AgentLoop) runLLMIteration(
ID: tc.ID, ID: tc.ID,
Type: "function", Type: "function",
Name: tc.Name, Name: tc.Name,
Arguments: tc.Arguments, Arguments: tc.Arguments,
@ -2727,11 +2613,9 @@ func (al *AgentLoop) runLLMIteration(
ThoughtSignature: thoughtSignature, ThoughtSignature: thoughtSignature,
}) })
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
// Save assistant message with tool calls to session // Save assistant message with tool calls to session
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls // Execute tool calls
@ -2923,16 +2807,12 @@ func (al *AgentLoop) runLLMIteration(
if al.mediaStore != nil { if al.mediaStore != nil {
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
part.Filename = meta.Filename part.Filename = meta.Filename
part.ContentType = meta.ContentType part.ContentType = meta.ContentType
part.Type = inferMediaType(meta.Filename, meta.ContentType) part.Type = inferMediaType(meta.Filename, meta.ContentType)
} }
} }
parts = append(parts, part) parts = append(parts, part)
} }
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
Channel: opts.Channel, Channel: opts.Channel,
@ -2963,11 +2843,9 @@ func (al *AgentLoop) runLLMIteration(
ToolCallID: tc.ID, ToolCallID: tc.ID,
} }
messages = append(messages, toolResultMsg) messages = append(messages, toolResultMsg)
// Save tool result message to session // Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
} }

View file

@ -1,11 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent // PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT // License: MIT
// //
// Copyright (c) 2026 PicoClaw contributors // Copyright (c) 2026 PicoClaw contributors
package agent package agent
@ -24,11 +20,8 @@ import (
) )
// MemoryStore manages persistent memory for the agent. // MemoryStore manages persistent memory for the agent.
// - Long-term memory: memory/MEMORY.md // - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md // - Daily notes: memory/YYYYMM/YYYYMMDD.md
type MemoryStore struct { type MemoryStore struct {
workspace string workspace string
@ -82,16 +75,12 @@ type parsedPlanState struct {
} }
// NewMemoryStore creates a new MemoryStore with the given workspace path. // NewMemoryStore creates a new MemoryStore with the given workspace path.
// It ensures the memory directory exists. // It ensures the memory directory exists.
func NewMemoryStore(workspace string) *MemoryStore { func NewMemoryStore(workspace string) *MemoryStore {
memoryDir := filepath.Join(workspace, "memory") memoryDir := filepath.Join(workspace, "memory")
memoryFile := filepath.Join(memoryDir, "MEMORY.md") memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists // Ensure memory directory exists
os.MkdirAll(memoryDir, 0o755) os.MkdirAll(memoryDir, 0o755)
return &MemoryStore{ return &MemoryStore{
@ -104,14 +93,12 @@ func NewMemoryStore(workspace string) *MemoryStore {
} }
// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
func (ms *MemoryStore) getTodayFile() string { func (ms *MemoryStore) getTodayFile() string {
today := time.Now().Format("20060102") // YYYYMMDD today := time.Now().Format("20060102") // YYYYMMDD
monthDir := today[:6] // YYYYMM monthDir := today[:6] // YYYYMM
filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") filePath := filepath.Join(ms.memoryDir, monthDir, today+".md")
return filePath return filePath
} }
@ -318,10 +305,8 @@ func (ms *MemoryStore) ReadLongTerm() string {
} }
// WriteLongTerm writes content to the long-term memory file (MEMORY.md). // WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error { func (ms *MemoryStore) WriteLongTerm(content string) error {
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions. // Using 0o600 (owner read/write only) for secure default permissions.
if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil { if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil {
@ -346,71 +331,53 @@ func (ms *MemoryStore) ClearLongTerm() error {
} }
// ReadToday reads today's daily note. // ReadToday reads today's daily note.
// Returns empty string if the file doesn't exist. // Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadToday() string { func (ms *MemoryStore) ReadToday() string {
todayFile := ms.getTodayFile() todayFile := ms.getTodayFile()
if data, err := os.ReadFile(todayFile); err == nil { if data, err := os.ReadFile(todayFile); err == nil {
return string(data) return string(data)
} }
return "" return ""
} }
// AppendToday appends content to today's daily note. // AppendToday appends content to today's daily note.
// If the file doesn't exist, it creates a new file with a date header. // If the file doesn't exist, it creates a new file with a date header.
func (ms *MemoryStore) AppendToday(content string) error { func (ms *MemoryStore) AppendToday(content string) error {
todayFile := ms.getTodayFile() todayFile := ms.getTodayFile()
// Ensure month directory exists // Ensure month directory exists
monthDir := filepath.Dir(todayFile) monthDir := filepath.Dir(todayFile)
if err := os.MkdirAll(monthDir, 0o755); err != nil { if err := os.MkdirAll(monthDir, 0o755); err != nil {
return err return err
} }
var existingContent string var existingContent string
if data, err := os.ReadFile(todayFile); err == nil { if data, err := os.ReadFile(todayFile); err == nil {
existingContent = string(data) existingContent = string(data)
} }
var newContent string var newContent string
if existingContent == "" { if existingContent == "" {
// Add header for new day // Add header for new day
header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02")) header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02"))
newContent = header + content newContent = header + content
} else { } else {
// Append to existing content // Append to existing content
newContent = existingContent + "\n" + content newContent = existingContent + "\n" + content
} }
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
} }
// GetRecentDailyNotes returns daily notes from the last N days. // GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator. // Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string { func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
var sb strings.Builder var sb strings.Builder
first := true first := true
for i := range days { for i := range days {
date := time.Now().AddDate(0, 0, -i) date := time.Now().AddDate(0, 0, -i)
dateStr := date.Format("20060102") // YYYYMMDD dateStr := date.Format("20060102") // YYYYMMDD
monthDir := dateStr[:6] // YYYYMM monthDir := dateStr[:6] // YYYYMM
@ -421,9 +388,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
if !first { if !first {
sb.WriteString("\n\n---\n\n") sb.WriteString("\n\n---\n\n")
} }
sb.Write(data) sb.Write(data)
first = false first = false
} }
} }

View file

@ -11,7 +11,6 @@ import (
) )
// AgentRegistry manages multiple agent instances and routes messages to them. // AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct { type AgentRegistry struct {
agents map[string]*AgentInstance agents map[string]*AgentInstance
@ -21,10 +20,8 @@ type AgentRegistry struct {
} }
// NewAgentRegistry creates a registry from config, instantiating all agents. // NewAgentRegistry creates a registry from config, instantiating all agents.
func NewAgentRegistry( func NewAgentRegistry(
cfg *config.Config, cfg *config.Config,
provider providers.LLMProvider, provider providers.LLMProvider,
) *AgentRegistry { ) *AgentRegistry {
registry := &AgentRegistry{ registry := &AgentRegistry{
@ -34,31 +31,22 @@ func NewAgentRegistry(
} }
agentConfigs := cfg.Agents.List agentConfigs := cfg.Agents.List
if len(agentConfigs) == 0 { if len(agentConfigs) == 0 {
implicitAgent := &config.AgentConfig{ implicitAgent := &config.AgentConfig{
ID: "main", ID: "main",
Default: true, Default: true,
} }
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
registry.agents["main"] = instance registry.agents["main"] = instance
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
} else { } else {
for i := range agentConfigs { for i := range agentConfigs {
ac := &agentConfigs[i] ac := &agentConfigs[i]
id := routing.NormalizeAgentID(ac.ID) id := routing.NormalizeAgentID(ac.ID)
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
registry.agents[id] = instance registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent", logger.InfoCF("agent", "Registered agent",
map[string]any{ map[string]any{
"agent_id": id, "agent_id": id,
@ -75,66 +63,48 @@ func NewAgentRegistry(
} }
// GetAgent returns the agent instance for a given ID. // GetAgent returns the agent instance for a given ID.
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
id := routing.NormalizeAgentID(agentID) id := routing.NormalizeAgentID(agentID)
agent, ok := r.agents[id] agent, ok := r.agents[id]
return agent, ok return agent, ok
} }
// ResolveRoute determines which agent handles the message. // ResolveRoute determines which agent handles the message.
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
return r.resolver.ResolveRoute(input) return r.resolver.ResolveRoute(input)
} }
// ListAgentIDs returns all registered agent IDs. // ListAgentIDs returns all registered agent IDs.
func (r *AgentRegistry) ListAgentIDs() []string { func (r *AgentRegistry) ListAgentIDs() []string {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
ids := make([]string, 0, len(r.agents)) ids := make([]string, 0, len(r.agents))
for id := range r.agents { for id := range r.agents {
ids = append(ids, id) ids = append(ids, id)
} }
return ids return ids
} }
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. // CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
parent, ok := r.GetAgent(parentAgentID) parent, ok := r.GetAgent(parentAgentID)
if !ok { if !ok {
return false return false
} }
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return false return false
} }
targetNorm := routing.NormalizeAgentID(targetAgentID) targetNorm := routing.NormalizeAgentID(targetAgentID)
for _, allowed := range parent.Subagents.AllowAgents { for _, allowed := range parent.Subagents.AllowAgents {
if allowed == "*" { if allowed == "*" {
return true return true
} }
if routing.NormalizeAgentID(allowed) == targetNorm { if routing.NormalizeAgentID(allowed) == targetNorm {
return true return true
} }
} }
return false return false
} }
@ -152,19 +122,14 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
} }
// GetDefaultAgent returns the default agent instance. // GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock() r.mu.RLock()
defer r.mu.RUnlock() defer r.mu.RUnlock()
if agent, ok := r.agents["main"]; ok { if agent, ok := r.agents["main"]; ok {
return agent return agent
} }
for _, agent := range r.agents { for _, agent := range r.agents {
return agent return agent
} }
return nil return nil
} }

View file

@ -329,7 +329,6 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
case []string: case []string:
tool.InputSchema.Required = append([]string(nil), req...) tool.InputSchema.Required = append([]string(nil), req...)
} }
result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
} }
return result return result

View file

@ -63,11 +63,9 @@ func NewSessionManager(storage string) *SessionManager {
func (sm *SessionManager) GetOrCreate(key string) *Session { func (sm *SessionManager) GetOrCreate(key string) *Session {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if ok { if ok {
return session return session
} }
@ -81,7 +79,6 @@ func (sm *SessionManager) GetOrCreate(key string) *Session {
Updated: time.Now(), Updated: time.Now(),
} }
sm.sessions[key] = session sm.sessions[key] = session
return session return session
@ -96,16 +93,12 @@ func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
} }
// AddFullMessage adds a complete message with tool calls and tool call ID to the session. // AddFullMessage adds a complete message with tool calls and tool call ID to the session.
// This is used to save the full conversation flow including tool calls and tool results. // This is used to save the full conversation flow including tool calls and tool results.
func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
session, ok := sm.sessions[sessionKey] session, ok := sm.sessions[sessionKey]
if !ok { if !ok {
session = &Session{ session = &Session{
Key: sessionKey, Key: sessionKey,
@ -114,77 +107,61 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag
Created: time.Now(), Created: time.Now(),
} }
sm.sessions[sessionKey] = session sm.sessions[sessionKey] = session
} }
session.Messages = append(session.Messages, msg) session.Messages = append(session.Messages, msg)
session.Updated = time.Now() session.Updated = time.Now()
} }
func (sm *SessionManager) GetHistory(key string) []providers.Message { func (sm *SessionManager) GetHistory(key string) []providers.Message {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if !ok { if !ok {
return []providers.Message{} return []providers.Message{}
} }
history := make([]providers.Message, len(session.Messages)) history := make([]providers.Message, len(session.Messages))
copy(history, session.Messages) copy(history, session.Messages)
return history return history
} }
func (sm *SessionManager) GetSummary(key string) string { func (sm *SessionManager) GetSummary(key string) string {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if !ok { if !ok {
return "" return ""
} }
return session.Summary return session.Summary
} }
func (sm *SessionManager) SetSummary(key string, summary string) { func (sm *SessionManager) SetSummary(key string, summary string) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if ok { if ok {
session.Summary = summary session.Summary = summary
session.Updated = time.Now() session.Updated = time.Now()
} }
} }
func (sm *SessionManager) TruncateHistory(key string, keepLast int) { func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if !ok { if !ok {
return return
} }
if keepLast <= 0 { if keepLast <= 0 {
session.Messages = []providers.Message{} session.Messages = []providers.Message{}
session.Updated = time.Now() session.Updated = time.Now()
return return
} }
@ -193,7 +170,6 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
} }
session.Messages = session.Messages[len(session.Messages)-keepLast:] session.Messages = session.Messages[len(session.Messages)-keepLast:]
session.Updated = time.Now() session.Updated = time.Now()
} }
@ -234,14 +210,10 @@ func (sm *SessionManager) Save(key string) error {
} }
// Snapshot under read lock, then perform slow file I/O after unlock. // Snapshot under read lock, then perform slow file I/O after unlock.
sm.mu.RLock() sm.mu.RLock()
stored, ok := sm.sessions[key] stored, ok := sm.sessions[key]
if !ok { if !ok {
sm.mu.RUnlock() sm.mu.RUnlock()
return nil return nil
} }
@ -249,20 +221,15 @@ func (sm *SessionManager) Save(key string) error {
Key: stored.Key, Key: stored.Key,
Summary: stored.Summary, Summary: stored.Summary,
Created: stored.Created, Created: stored.Created,
Updated: stored.Updated, Updated: stored.Updated,
} }
if len(stored.Messages) > 0 { if len(stored.Messages) > 0 {
snapshot.Messages = make([]providers.Message, len(stored.Messages)) snapshot.Messages = make([]providers.Message, len(stored.Messages))
copy(snapshot.Messages, stored.Messages) copy(snapshot.Messages, stored.Messages)
} else { } else {
snapshot.Messages = []providers.Message{} snapshot.Messages = []providers.Message{}
} }
sm.mu.RUnlock() sm.mu.RUnlock()
data, err := json.MarshalIndent(snapshot, "", " ") data, err := json.MarshalIndent(snapshot, "", " ")
@ -271,16 +238,13 @@ func (sm *SessionManager) Save(key string) error {
} }
sessionPath := filepath.Join(sm.storage, filename+".json") sessionPath := filepath.Join(sm.storage, filename+".json")
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
if err != nil { if err != nil {
return err return err
} }
tmpPath := tmpFile.Name() tmpPath := tmpFile.Name()
cleanup := true cleanup := true
defer func() { defer func() {
if cleanup { if cleanup {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
@ -289,22 +253,17 @@ func (sm *SessionManager) Save(key string) error {
if _, err := tmpFile.Write(data); err != nil { if _, err := tmpFile.Write(data); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
return err return err
} }
if err := tmpFile.Chmod(0o644); err != nil { if err := tmpFile.Chmod(0o644); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
return err return err
} }
if err := tmpFile.Sync(); err != nil { if err := tmpFile.Sync(); err != nil {
_ = tmpFile.Close() _ = tmpFile.Close()
return err return err
} }
if err := tmpFile.Close(); err != nil { if err := tmpFile.Close(); err != nil {
return err return err
} }
@ -312,9 +271,7 @@ func (sm *SessionManager) Save(key string) error {
if err := os.Rename(tmpPath, sessionPath); err != nil { if err := os.Rename(tmpPath, sessionPath); err != nil {
return err return err
} }
cleanup = false cleanup = false
return nil return nil
} }
@ -334,14 +291,12 @@ func (sm *SessionManager) loadSessions() error {
} }
sessionPath := filepath.Join(sm.storage, file.Name()) sessionPath := filepath.Join(sm.storage, file.Name())
data, err := os.ReadFile(sessionPath) data, err := os.ReadFile(sessionPath)
if err != nil { if err != nil {
continue continue
} }
var session Session var session Session
if err := json.Unmarshal(data, &session); err != nil { if err := json.Unmarshal(data, &session); err != nil {
continue continue
} }
@ -459,25 +414,17 @@ func SanitizeHistory(history []providers.Message) ([]providers.Message, int) {
} }
// SetHistory updates the messages of a session. // SetHistory updates the messages of a session.
func (sm *SessionManager) SetHistory(key string, history []providers.Message) { func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
session, ok := sm.sessions[key] session, ok := sm.sessions[key]
if ok { if ok {
// Create a deep copy to strictly isolate internal state // Create a deep copy to strictly isolate internal state
// from the caller's slice. // from the caller's slice.
msgs := make([]providers.Message, len(history)) msgs := make([]providers.Message, len(history))
copy(msgs, history) copy(msgs, history)
session.Messages = msgs session.Messages = msgs
session.Updated = time.Now() session.Updated = time.Now()
} }
} }

View file

@ -14,13 +14,11 @@ import (
) )
// JobExecutor is the interface for executing cron jobs through the agent // JobExecutor is the interface for executing cron jobs through the agent
type JobExecutor interface { type JobExecutor interface {
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
} }
// CronTool provides scheduling capabilities for the agent // CronTool provides scheduling capabilities for the agent
type CronTool struct { type CronTool struct {
cronService *cron.CronService cronService *cron.CronService
@ -38,12 +36,9 @@ type CronTool struct {
} }
// NewCronTool creates a new CronTool // NewCronTool creates a new CronTool
// execTimeout: 0 means no timeout, >0 sets the timeout duration // execTimeout: 0 means no timeout, >0 sets the timeout duration
func NewCronTool( func NewCronTool(
cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool,
execTimeout time.Duration, config *config.Config, execTimeout time.Duration, config *config.Config,
) (*CronTool, error) { ) (*CronTool, error) {
execTool, err := NewExecToolWithConfig(workspace, restrict, config) execTool, err := NewExecToolWithConfig(workspace, restrict, config)
@ -52,7 +47,6 @@ func NewCronTool(
} }
execTool.SetTimeout(execTimeout) execTool.SetTimeout(execTimeout)
return &CronTool{ return &CronTool{
cronService: cronService, cronService: cronService,
@ -65,23 +59,19 @@ func NewCronTool(
} }
// Name returns the tool name // Name returns the tool name
func (t *CronTool) Name() string { func (t *CronTool) Name() string {
return "cron" return "cron"
} }
// Description returns the tool description // Description returns the tool description
func (t *CronTool) Description() string { func (t *CronTool) Description() string {
return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly."
} }
// Parameters returns the tool parameters schema // Parameters returns the tool parameters schema
func (t *CronTool) Parameters() map[string]any { func (t *CronTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
@ -90,13 +80,11 @@ func (t *CronTool) Parameters() map[string]any {
"description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.", "description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.",
}, },
"message": map[string]any{ "message": map[string]any{
"type": "string", "type": "string",
"description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.",
}, },
"command": map[string]any{ "command": map[string]any{
"type": "string", "type": "string",
@ -108,32 +96,27 @@ func (t *CronTool) Parameters() map[string]any {
"description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.",
}, },
"every_seconds": map[string]any{ "every_seconds": map[string]any{
"type": "integer", "type": "integer",
"description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.", "description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.",
}, },
"cron_expr": map[string]any{ "cron_expr": map[string]any{
"type": "string", "type": "string",
"description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.", "description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.",
}, },
"job_id": map[string]any{ "job_id": map[string]any{
"type": "string", "type": "string",
"description": "Job ID (for remove/enable/disable)", "description": "Job ID (for remove/enable/disable)",
}, },
"deliver": map[string]any{ "deliver": map[string]any{
"type": "boolean", "type": "boolean",
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true",
}, },
}, },
"required": []string{"action"}, "required": []string{"action"},
} }
} }
@ -151,10 +134,8 @@ func (t *CronTool) SetContext(channel, chatID string) {
} }
// Execute runs the tool with the given arguments // Execute runs the tool with the given arguments
func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, ok := args["action"].(string) action, ok := args["action"].(string)
if !ok { if !ok {
return ErrorResult("action is required") return ErrorResult("action is required")
} }
@ -165,23 +146,14 @@ func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult
return t.addJob(args) return t.addJob(args)
case "list": case "list":
return t.listJobs() return t.listJobs()
case "remove": case "remove":
return t.removeJob(args) return t.removeJob(args)
case "enable": case "enable":
return t.enableJob(args, true) return t.enableJob(args, true)
case "disable": case "disable":
return t.enableJob(args, false) return t.enableJob(args, false)
default: default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action)) return ErrorResult(fmt.Sprintf("unknown action: %s", action))
} }
} }
@ -200,7 +172,6 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
} }
message, ok := args["message"].(string) message, ok := args["message"].(string)
if !ok || message == "" { if !ok || message == "" {
return ErrorResult("message is required for add") return ErrorResult("message is required for add")
} }
@ -208,26 +179,19 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
var schedule cron.CronSchedule var schedule cron.CronSchedule
// Check for at_seconds (one-time), every_seconds (recurring), or cron_expr // Check for at_seconds (one-time), every_seconds (recurring), or cron_expr
atSeconds, hasAt := args["at_seconds"].(float64) atSeconds, hasAt := args["at_seconds"].(float64)
everySeconds, hasEvery := args["every_seconds"].(float64) everySeconds, hasEvery := args["every_seconds"].(float64)
cronExpr, hasCron := args["cron_expr"].(string) cronExpr, hasCron := args["cron_expr"].(string)
// Priority: at_seconds > every_seconds > cron_expr // Priority: at_seconds > every_seconds > cron_expr
if hasAt { if hasAt {
atMS := time.Now().UnixMilli() + int64(atSeconds)*1000 atMS := time.Now().UnixMilli() + int64(atSeconds)*1000
schedule = cron.CronSchedule{ schedule = cron.CronSchedule{
Kind: "at", Kind: "at",
AtMS: &atMS, AtMS: &atMS,
} }
} else if hasEvery { } else if hasEvery {
everyMS := int64(everySeconds) * 1000 everyMS := int64(everySeconds) * 1000
schedule = cron.CronSchedule{ schedule = cron.CronSchedule{
Kind: "every", Kind: "every",
@ -236,7 +200,6 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
} else if hasCron { } else if hasCron {
schedule = cron.CronSchedule{ schedule = cron.CronSchedule{
Kind: "cron", Kind: "cron",
Expr: cronExpr, Expr: cronExpr,
} }
} else { } else {
@ -244,9 +207,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
} }
// Read deliver parameter, default to true // Read deliver parameter, default to true
deliver := true deliver := true
if d, ok := args["deliver"].(bool); ok { if d, ok := args["deliver"].(bool); ok {
deliver = d deliver = d
} }
@ -266,21 +227,14 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
} }
// Truncate message for job name (max 30 chars) // Truncate message for job name (max 30 chars)
messagePreview := utils.Truncate(message, 30) messagePreview := utils.Truncate(message, 30)
job, err := t.cronService.AddJob( job, err := t.cronService.AddJob(
messagePreview, messagePreview,
schedule, schedule,
message, message,
deliver, deliver,
channel, channel,
chatID, chatID,
) )
if err != nil { if err != nil {
@ -289,9 +243,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult {
if command != "" { if command != "" {
job.Payload.Command = command job.Payload.Command = command
// Need to save the updated payload // Need to save the updated payload
t.cronService.UpdateJob(job) t.cronService.UpdateJob(job)
} }
@ -311,7 +263,6 @@ func (t *CronTool) listJobs() *ToolResult {
for _, j := range jobs { for _, j := range jobs {
var scheduleInfo string var scheduleInfo string
if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil { if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil {
scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000) scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000)
} else if j.Schedule.Kind == "cron" { } else if j.Schedule.Kind == "cron" {
@ -330,7 +281,6 @@ func (t *CronTool) listJobs() *ToolResult {
func (t *CronTool) removeJob(args map[string]any) *ToolResult { func (t *CronTool) removeJob(args map[string]any) *ToolResult {
jobID, ok := args["job_id"].(string) jobID, ok := args["job_id"].(string)
if !ok || jobID == "" { if !ok || jobID == "" {
return ErrorResult("job_id is required for remove") return ErrorResult("job_id is required for remove")
} }
@ -338,62 +288,49 @@ func (t *CronTool) removeJob(args map[string]any) *ToolResult {
if t.cronService.RemoveJob(jobID) { if t.cronService.RemoveJob(jobID) {
return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID))
} }
return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
} }
func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult {
jobID, ok := args["job_id"].(string) jobID, ok := args["job_id"].(string)
if !ok || jobID == "" { if !ok || jobID == "" {
return ErrorResult("job_id is required for enable/disable") return ErrorResult("job_id is required for enable/disable")
} }
job := t.cronService.EnableJob(jobID, enable) job := t.cronService.EnableJob(jobID, enable)
if job == nil { if job == nil {
return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) return ErrorResult(fmt.Sprintf("Job %s not found", jobID))
} }
status := "enabled" status := "enabled"
if !enable { if !enable {
status = "disabled" status = "disabled"
} }
return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status))
} }
// ExecuteJob executes a cron job through the agent // ExecuteJob executes a cron job through the agent
func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
// Get channel/chatID from job payload // Get channel/chatID from job payload
channel := job.Payload.Channel channel := job.Payload.Channel
chatID := job.Payload.To chatID := job.Payload.To
// Default values if not set // Default values if not set
if channel == "" { if channel == "" {
channel = "cli" channel = "cli"
} }
if chatID == "" { if chatID == "" {
chatID = "direct" chatID = "direct"
} }
// Execute command if present // Execute command if present
if job.Payload.Command != "" { if job.Payload.Command != "" {
args := map[string]any{ args := map[string]any{
"command": job.Payload.Command, "command": job.Payload.Command,
} }
result := t.execTool.Execute(ctx, args) result := t.execTool.Execute(ctx, args)
var output string var output string
if result.IsError { if result.IsError {
output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM)
} else { } else {
@ -401,9 +338,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
} }
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel, Channel: channel,
@ -411,17 +346,13 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
Content: output, Content: output,
}) })
return "ok" return "ok"
} }
// If deliver=true, send message directly without agent processing // If deliver=true, send message directly without agent processing
if job.Payload.Deliver { if job.Payload.Deliver {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel, Channel: channel,
@ -429,26 +360,18 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
Content: job.Payload.Message, Content: job.Payload.Message,
}) })
return "ok" return "ok"
} }
// For deliver=false, process through agent (for complex tasks) // For deliver=false, process through agent (for complex tasks)
sessionKey := fmt.Sprintf("cron-%s", job.ID) sessionKey := fmt.Sprintf("cron-%s", job.ID)
// Call agent with job's message // Call agent with job's message
response, err := t.executor.ProcessDirectWithChannel( response, err := t.executor.ProcessDirectWithChannel(
ctx, ctx,
job.Payload.Message, job.Payload.Message,
sessionKey, sessionKey,
channel, channel,
chatID, chatID,
) )
if err != nil { if err != nil {
@ -456,8 +379,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
} }
// Response is automatically sent via MessageBus by AgentLoop // Response is automatically sent via MessageBus by AgentLoop
_ = response // Will be sent by AgentLoop _ = response // Will be sent by AgentLoop
return "ok" return "ok"
} }

View file

@ -9,9 +9,7 @@ import (
) )
// EditFileTool edits a file by replacing old_text with new_text. // EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file. // The old_text must exist exactly in the file.
type EditFileTool struct { type EditFileTool struct {
fs fileSystem fs fileSystem
} }
@ -41,46 +39,39 @@ func (t *EditFileTool) Description() string {
func (t *EditFileTool) Parameters() map[string]any { func (t *EditFileTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "The file path to edit", "description": "The file path to edit",
}, },
"old_text": map[string]any{ "old_text": map[string]any{
"type": "string", "type": "string",
"description": "The exact text to find and replace", "description": "The exact text to find and replace",
}, },
"new_text": map[string]any{ "new_text": map[string]any{
"type": "string", "type": "string",
"description": "The text to replace with", "description": "The text to replace with",
}, },
}, },
"required": []string{"path", "old_text", "new_text"}, "required": []string{"path", "old_text", "new_text"},
} }
} }
func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
return ErrorResult("path is required") return ErrorResult("path is required")
} }
oldText, ok := args["old_text"].(string) oldText, ok := args["old_text"].(string)
if !ok { if !ok {
return ErrorResult("old_text is required") return ErrorResult("old_text is required")
} }
newText, ok := args["new_text"].(string) newText, ok := args["new_text"].(string)
if !ok { if !ok {
return ErrorResult("new_text is required") return ErrorResult("new_text is required")
} }
@ -88,7 +79,6 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return SilentResult(fmt.Sprintf("File edited: %s", path)) return SilentResult(fmt.Sprintf("File edited: %s", path))
} }
@ -119,34 +109,29 @@ func (t *AppendFileTool) Description() string {
func (t *AppendFileTool) Parameters() map[string]any { func (t *AppendFileTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "The file path to append to", "description": "The file path to append to",
}, },
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "The content to append", "description": "The content to append",
}, },
}, },
"required": []string{"path", "content"}, "required": []string{"path", "content"},
} }
} }
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
return ErrorResult("path is required") return ErrorResult("path is required")
} }
content, ok := args["content"].(string) content, ok := args["content"].(string)
if !ok { if !ok {
return ErrorResult("content is required") return ErrorResult("content is required")
} }
@ -154,14 +139,11 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return SilentResult(fmt.Sprintf("Appended to %s", path)) return SilentResult(fmt.Sprintf("Appended to %s", path))
} }
// editFile reads the file via sysFs, performs the replacement, and writes back. // editFile reads the file via sysFs, performs the replacement, and writes back.
// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. // It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
func editFile(sysFs fileSystem, path, oldText, newText string) error { func editFile(sysFs fileSystem, path, oldText, newText string) error {
content, err := sysFs.ReadFile(path) content, err := sysFs.ReadFile(path)
if err != nil { if err != nil {
@ -177,21 +159,17 @@ func editFile(sysFs fileSystem, path, oldText, newText string) error {
} }
// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. // appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
func appendFile(sysFs fileSystem, path, appendContent string) error { func appendFile(sysFs fileSystem, path, appendContent string) error {
content, err := sysFs.ReadFile(path) content, err := sysFs.ReadFile(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) { if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err return err
} }
newContent := append(content, []byte(appendContent)...) newContent := append(content, []byte(appendContent)...)
return sysFs.WriteFile(path, newContent) return sysFs.WriteFile(path, newContent)
} }
// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. // replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
contentStr := string(content) contentStr := string(content)
@ -200,12 +178,10 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error)
} }
count := strings.Count(contentStr, oldText) count := strings.Count(contentStr, oldText)
if count > 1 { if count > 1 {
return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
} }
newContent := strings.Replace(contentStr, oldText, newText, 1) newContent := strings.Replace(contentStr, oldText, newText, 1)
return []byte(newContent), nil return []byte(newContent), nil
} }

View file

@ -27,7 +27,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
} }
var absPath string var absPath string
if filepath.IsAbs(path) { if filepath.IsAbs(path) {
absPath = filepath.Clean(path) absPath = filepath.Clean(path)
} else { } else {
@ -43,9 +42,7 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
} }
var resolved string var resolved string
workspaceReal := absWorkspace workspaceReal := absWorkspace
if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved workspaceReal = resolved
} }
@ -56,7 +53,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
} }
} else if os.IsNotExist(err) { } else if os.IsNotExist(err) {
var parentResolved string var parentResolved string
if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) { if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace") return "", fmt.Errorf("access denied: symlink resolves outside workspace")
@ -79,7 +75,6 @@ func resolveExistingAncestor(path string) (string, error) {
} else if !os.IsNotExist(err) { } else if !os.IsNotExist(err) {
return "", err return "", err
} }
if filepath.Dir(current) == current { if filepath.Dir(current) == current {
return "", os.ErrNotExist return "", os.ErrNotExist
} }
@ -88,7 +83,6 @@ func resolveExistingAncestor(path string) (string, error) {
func isWithinWorkspace(candidate, workspace string) bool { func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
return err == nil && filepath.IsLocal(rel) return err == nil && filepath.IsLocal(rel)
} }
@ -119,7 +113,6 @@ func (t *ReadFileTool) Description() string {
func (t *ReadFileTool) Parameters() map[string]any { func (t *ReadFileTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
@ -127,14 +120,12 @@ func (t *ReadFileTool) Parameters() map[string]any {
"description": "Path to the file to read", "description": "Path to the file to read",
}, },
}, },
"required": []string{"path"}, "required": []string{"path"},
} }
} }
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
return ErrorResult("path is required") return ErrorResult("path is required")
} }
@ -174,34 +165,29 @@ func (t *WriteFileTool) Description() string {
func (t *WriteFileTool) Parameters() map[string]any { func (t *WriteFileTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
"description": "Path to the file to write", "description": "Path to the file to write",
}, },
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "Content to write to the file", "description": "Content to write to the file",
}, },
}, },
"required": []string{"path", "content"}, "required": []string{"path", "content"},
} }
} }
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
return ErrorResult("path is required") return ErrorResult("path is required")
} }
content, ok := args["content"].(string) content, ok := args["content"].(string)
if !ok { if !ok {
return ErrorResult("content is required") return ErrorResult("content is required")
} }
@ -240,7 +226,6 @@ func (t *ListDirTool) Description() string {
func (t *ListDirTool) Parameters() map[string]any { func (t *ListDirTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"path": map[string]any{ "path": map[string]any{
"type": "string", "type": "string",
@ -248,14 +233,12 @@ func (t *ListDirTool) Parameters() map[string]any {
"description": "Path to list", "description": "Path to list",
}, },
}, },
"required": []string{"path"}, "required": []string{"path"},
} }
} }
func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string) path, ok := args["path"].(string)
if !ok { if !ok {
path = "." path = "."
} }
@ -264,13 +247,11 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
if err != nil { if err != nil {
return ErrorResult(err.Error()) return ErrorResult(err.Error())
} }
return formatDirEntries(entries) return formatDirEntries(entries)
} }
func formatDirEntries(entries []os.DirEntry) *ToolResult { func formatDirEntries(entries []os.DirEntry) *ToolResult {
var result strings.Builder var result strings.Builder
for _, entry := range entries { for _, entry := range entries {
if entry.IsDir() { if entry.IsDir() {
result.WriteString("DIR: ") result.WriteString("DIR: ")
@ -282,24 +263,18 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult {
result.WriteByte('\n') result.WriteByte('\n')
} }
return NewToolResult(result.String()) return NewToolResult(result.String())
} }
// fileSystem abstracts reading, writing, and listing files, allowing both // fileSystem abstracts reading, writing, and listing files, allowing both
// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. // unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
type fileSystem interface { type fileSystem interface {
ReadFile(path string) ([]byte, error) ReadFile(path string) ([]byte, error)
WriteFile(path string, data []byte) error WriteFile(path string, data []byte) error
ReadDir(path string) ([]os.DirEntry, error) ReadDir(path string) ([]os.DirEntry, error)
} }
// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. // hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
type hostFs struct{} type hostFs struct{}
func (h *hostFs) ReadFile(path string) ([]byte, error) { func (h *hostFs) ReadFile(path string) ([]byte, error) {
@ -308,14 +283,11 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read file: file not found: %w", err) return nil, fmt.Errorf("failed to read file: file not found: %w", err)
} }
if os.IsPermission(err) { if os.IsPermission(err) {
return nil, fmt.Errorf("failed to read file: access denied: %w", err) return nil, fmt.Errorf("failed to read file: access denied: %w", err)
} }
return nil, fmt.Errorf("failed to read file: %w", err) return nil, fmt.Errorf("failed to read file: %w", err)
} }
return content, nil return content, nil
} }
@ -330,14 +302,11 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
func (h *hostFs) WriteFile(path string, data []byte) error { func (h *hostFs) WriteFile(path string, data []byte) error {
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions. // Using 0o600 (owner read/write only) for secure default permissions.
return fileutil.WriteFileAtomic(path, data, 0o600) return fileutil.WriteFileAtomic(path, data, 0o600)
} }
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
type sandboxFs struct { type sandboxFs struct {
workspace string workspace string
} }
@ -351,7 +320,6 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
if err != nil { if err != nil {
return fmt.Errorf("failed to open workspace: %w", err) return fmt.Errorf("failed to open workspace: %w", err)
} }
defer root.Close() defer root.Close()
relPath, err := getSafeRelPath(r.workspace, path) relPath, err := getSafeRelPath(r.workspace, path)
@ -364,37 +332,28 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string)
func (r *sandboxFs) ReadFile(path string) ([]byte, error) { func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
var content []byte var content []byte
err := r.execute(path, func(root *os.Root, relPath string) error { err := r.execute(path, func(root *os.Root, relPath string) error {
fileContent, err := root.ReadFile(relPath) fileContent, err := root.ReadFile(relPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("failed to read file: file not found: %w", err) return fmt.Errorf("failed to read file: file not found: %w", err)
} }
// os.Root returns "escapes from parent" for paths outside the root // os.Root returns "escapes from parent" for paths outside the root
if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
strings.Contains(err.Error(), "permission denied") { strings.Contains(err.Error(), "permission denied") {
return fmt.Errorf("failed to read file: access denied: %w", err) return fmt.Errorf("failed to read file: access denied: %w", err)
} }
return fmt.Errorf("failed to read file: %w", err) return fmt.Errorf("failed to read file: %w", err)
} }
content = fileContent content = fileContent
return nil return nil
}) })
return content, err return content, err
} }
func (r *sandboxFs) WriteFile(path string, data []byte) error { func (r *sandboxFs) WriteFile(path string, data []byte) error {
return r.execute(path, func(root *os.Root, relPath string) error { return r.execute(path, func(root *os.Root, relPath string) error {
dir := filepath.Dir(relPath) dir := filepath.Dir(relPath)
if dir != "." && dir != "/" { if dir != "." && dir != "/" {
if err := root.MkdirAll(dir, 0o755); err != nil { if err := root.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create parent directories: %w", err) return fmt.Errorf("failed to create parent directories: %w", err)
@ -402,55 +361,42 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
} }
// Use atomic write pattern with explicit sync for flash storage reliability. // Use atomic write pattern with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions. // Using 0o600 (owner read/write only) for secure default permissions.
tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil { if err != nil {
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to open temp file: %w", err) return fmt.Errorf("failed to open temp file: %w", err)
} }
if _, err := tmpFile.Write(data); err != nil { if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close() tmpFile.Close()
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to write temp file: %w", err) return fmt.Errorf("failed to write temp file: %w", err)
} }
// CRITICAL: Force sync to storage medium before rename. // CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached. // This ensures data is physically written to disk, not just cached.
if err := tmpFile.Sync(); err != nil { if err := tmpFile.Sync(); err != nil {
tmpFile.Close() tmpFile.Close()
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to sync temp file: %w", err) return fmt.Errorf("failed to sync temp file: %w", err)
} }
if err := tmpFile.Close(); err != nil { if err := tmpFile.Close(); err != nil {
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err) return fmt.Errorf("failed to close temp file: %w", err)
} }
if err := root.Rename(tmpRelPath, relPath); err != nil { if err := root.Rename(tmpRelPath, relPath); err != nil {
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to rename temp file over target: %w", err) return fmt.Errorf("failed to rename temp file over target: %w", err)
} }
// Sync directory to ensure rename is durable // Sync directory to ensure rename is durable
if dirFile, err := root.Open("."); err == nil { if dirFile, err := root.Open("."); err == nil {
_ = dirFile.Sync() _ = dirFile.Sync()
dirFile.Close() dirFile.Close()
} }
@ -460,33 +406,26 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
var entries []os.DirEntry var entries []os.DirEntry
err := r.execute(path, func(root *os.Root, relPath string) error { err := r.execute(path, func(root *os.Root, relPath string) error {
dirEntries, err := fs.ReadDir(root.FS(), relPath) dirEntries, err := fs.ReadDir(root.FS(), relPath)
if err != nil { if err != nil {
return err return err
} }
entries = dirEntries entries = dirEntries
return nil return nil
}) })
return entries, err return entries, err
} }
// Helper to get a safe relative path for os.Root usage // Helper to get a safe relative path for os.Root usage
func getSafeRelPath(workspace, path string) (string, error) { func getSafeRelPath(workspace, path string) (string, error) {
if workspace == "" { if workspace == "" {
return "", fmt.Errorf("workspace is not defined") return "", fmt.Errorf("workspace is not defined")
} }
rel := filepath.Clean(path) rel := filepath.Clean(path)
if filepath.IsAbs(rel) { if filepath.IsAbs(rel) {
var err error var err error
rel, err = filepath.Rel(workspace, rel) rel, err = filepath.Rel(workspace, rel)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to calculate relative path: %w", err) return "", fmt.Errorf("failed to calculate relative path: %w", err)

View file

@ -10,7 +10,6 @@ import (
) )
// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. // I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
type I2CTool struct{} type I2CTool struct{}
func NewI2CTool() *I2CTool { func NewI2CTool() *I2CTool {
@ -28,7 +27,6 @@ func (t *I2CTool) Description() string {
func (t *I2CTool) Parameters() map[string]any { func (t *I2CTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
@ -37,25 +35,21 @@ func (t *I2CTool) Parameters() map[string]any {
"description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
}, },
"bus": map[string]any{ "bus": map[string]any{
"type": "string", "type": "string",
"description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
}, },
"address": map[string]any{ "address": map[string]any{
"type": "integer", "type": "integer",
"description": "7-bit I2C device address (0x03-0x77). Required for read/write.", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
}, },
"register": map[string]any{ "register": map[string]any{
"type": "integer", "type": "integer",
"description": "Register address to read from or write to. If set, sends register byte before read/write.", "description": "Register address to read from or write to. If set, sends register byte before read/write.",
}, },
"data": map[string]any{ "data": map[string]any{
"type": "array", "type": "array",
@ -63,20 +57,17 @@ func (t *I2CTool) Parameters() map[string]any {
"description": "Bytes to write (0-255 each). Required for write action.", "description": "Bytes to write (0-255 each). Required for write action.",
}, },
"length": map[string]any{ "length": map[string]any{
"type": "integer", "type": "integer",
"description": "Number of bytes to read (1-256). Default: 1. Used with read action.", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
}, },
"confirm": map[string]any{ "confirm": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Must be true for write operations. Safety guard to prevent accidental writes.", "description": "Must be true for write operations. Safety guard to prevent accidental writes.",
}, },
}, },
"required": []string{"action"}, "required": []string{"action"},
} }
} }
@ -87,36 +78,25 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult
} }
action, ok := args["action"].(string) action, ok := args["action"].(string)
if !ok { if !ok {
return ErrorResult("action is required") return ErrorResult("action is required")
} }
switch action { switch action {
case "detect": case "detect":
return t.detect() return t.detect()
case "scan": case "scan":
return t.scan(args) return t.scan(args)
case "read": case "read":
return t.readDevice(args) return t.readDevice(args)
case "write": case "write":
return t.writeDevice(args) return t.writeDevice(args)
default: default:
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
} }
} }
// detect lists available I2C buses by globbing /dev/i2c-* // detect lists available I2C buses by globbing /dev/i2c-*
func (t *I2CTool) detect() *ToolResult { func (t *I2CTool) detect() *ToolResult {
matches, err := filepath.Glob("/dev/i2c-*") matches, err := filepath.Glob("/dev/i2c-*")
if err != nil { if err != nil {
@ -136,9 +116,7 @@ func (t *I2CTool) detect() *ToolResult {
} }
buses := make([]busInfo, 0, len(matches)) buses := make([]busInfo, 0, len(matches))
re := regexp.MustCompile(`/dev/i2c-(\d+)`) re := regexp.MustCompile(`/dev/i2c-(\d+)`)
for _, m := range matches { for _, m := range matches {
if sub := re.FindStringSubmatch(m); sub != nil { if sub := re.FindStringSubmatch(m); sub != nil {
buses = append(buses, busInfo{Path: m, Bus: sub[1]}) buses = append(buses, busInfo{Path: m, Bus: sub[1]})
@ -146,62 +124,44 @@ func (t *I2CTool) detect() *ToolResult {
} }
result, _ := json.MarshalIndent(buses, "", " ") result, _ := json.MarshalIndent(buses, "", " ")
return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
} }
// Helper functions for I2C operations (used by platform-specific implementations) // Helper functions for I2C operations (used by platform-specific implementations)
// isValidBusID checks that a bus identifier is a simple number (prevents path injection) // isValidBusID checks that a bus identifier is a simple number (prevents path injection)
// //
//nolint:unused // Used by i2c_linux.go //nolint:unused // Used by i2c_linux.go
func isValidBusID(id string) bool { func isValidBusID(id string) bool {
matched, _ := regexp.MatchString(`^\d+$`, id) matched, _ := regexp.MatchString(`^\d+$`, id)
return matched return matched
} }
// parseI2CAddress extracts and validates an I2C address from args // parseI2CAddress extracts and validates an I2C address from args
// //
//nolint:unused // Used by i2c_linux.go //nolint:unused // Used by i2c_linux.go
func parseI2CAddress(args map[string]any) (int, *ToolResult) { func parseI2CAddress(args map[string]any) (int, *ToolResult) {
addrFloat, ok := args["address"].(float64) addrFloat, ok := args["address"].(float64)
if !ok { if !ok {
return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
} }
addr := int(addrFloat) addr := int(addrFloat)
if addr < 0x03 || addr > 0x77 { if addr < 0x03 || addr > 0x77 {
return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
} }
return addr, nil return addr, nil
} }
// parseI2CBus extracts and validates an I2C bus from args // parseI2CBus extracts and validates an I2C bus from args
// //
//nolint:unused // Used by i2c_linux.go //nolint:unused // Used by i2c_linux.go
func parseI2CBus(args map[string]any) (string, *ToolResult) { func parseI2CBus(args map[string]any) (string, *ToolResult) {
bus, ok := args["bus"].(string) bus, ok := args["bus"].(string)
if !ok || bus == "" { if !ok || bus == "" {
return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
} }
if !isValidBusID(bus) { if !isValidBusID(bus) {
return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
} }
return bus, nil return bus, nil
} }

View file

@ -112,7 +112,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
} }
var found []deviceEntry var found []deviceEntry
// Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07
for addr := 0x08; addr <= 0x77; addr++ { for addr := 0x08; addr <= 0x77; addr++ {
// Set slave address — EBUSY means a kernel driver owns this address // Set slave address — EBUSY means a kernel driver owns this address
@ -142,7 +141,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult {
"devices": found, "devices": found,
"count": len(found), "count": len(found),
}, "", " ") }, "", " ")
return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
} }
@ -213,7 +211,6 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
"hex": hexBytes, "hex": hexBytes,
"length": n, "length": n,
}, "", " ") }, "", " ")
return SilentResult(string(result)) return SilentResult(string(result))
} }

View file

@ -32,27 +32,23 @@ func (t *MessageTool) Description() string {
func (t *MessageTool) Parameters() map[string]any { func (t *MessageTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"content": map[string]any{ "content": map[string]any{
"type": "string", "type": "string",
"description": "The message content to send", "description": "The message content to send",
}, },
"channel": map[string]any{ "channel": map[string]any{
"type": "string", "type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)", "description": "Optional: target channel (telegram, whatsapp, etc.)",
}, },
"chat_id": map[string]any{ "chat_id": map[string]any{
"type": "string", "type": "string",
"description": "Optional: target chat/user ID", "description": "Optional: target chat/user ID",
}, },
}, },
"required": []string{"content"}, "required": []string{"content"},
} }
} }
@ -66,7 +62,6 @@ func (t *MessageTool) SetContext(channel, chatID string) {
} }
// HasSentInRound returns true if the message tool sent a message during the current round. // HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool { func (t *MessageTool) HasSentInRound() bool {
return t.sentInRound return t.sentInRound
} }
@ -77,19 +72,16 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) {
func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
content, ok := args["content"].(string) content, ok := args["content"].(string)
if !ok { if !ok {
return &ToolResult{ForLLM: "content is required", IsError: true} return &ToolResult{ForLLM: "content is required", IsError: true}
} }
channel, _ := args["channel"].(string) channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string) chatID, _ := args["chat_id"].(string)
if channel == "" { if channel == "" {
channel = t.defaultChannel channel = t.defaultChannel
} }
if chatID == "" { if chatID == "" {
chatID = t.defaultChatID chatID = t.defaultChatID
} }
@ -115,10 +107,8 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
t.sentInRound = true t.sentInRound = true
// Silent: user already received the message directly // Silent: user already received the message directly
return &ToolResult{ return &ToolResult{
ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID),
Silent: true, Silent: true,
} }
} }

View file

@ -291,27 +291,22 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
if config != nil { if config != nil {
execConfig := config.Tools.Exec execConfig := config.Tools.Exec
enableDenyPatterns := execConfig.EnableDenyPatterns enableDenyPatterns := execConfig.EnableDenyPatterns
if enableDenyPatterns { if enableDenyPatterns {
denyPatterns = append(denyPatterns, defaultDenyPatterns...) denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 { if len(execConfig.CustomDenyPatterns) > 0 {
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
for _, pattern := range execConfig.CustomDenyPatterns { for _, pattern := range execConfig.CustomDenyPatterns {
re, err := regexp.Compile(pattern) re, err := regexp.Compile(pattern)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err)
} }
denyPatterns = append(denyPatterns, re) denyPatterns = append(denyPatterns, re)
} }
} }
} else { } else {
// If deny patterns are disabled, we won't add any patterns, allowing all commands. // If deny patterns are disabled, we won't add any patterns, allowing all commands.
fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.")
} }
} else { } else {
@ -350,14 +345,12 @@ func (t *ExecTool) Description() string {
func (t *ExecTool) Parameters() map[string]any { func (t *ExecTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"command": map[string]any{ "command": map[string]any{
"type": "string", "type": "string",
"description": "The shell command to execute", "description": "The shell command to execute",
}, },
"working_dir": map[string]any{ "working_dir": map[string]any{
"type": "string", "type": "string",
@ -420,7 +413,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
if err != nil { if err != nil {
return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
} }
cwd = resolvedWD cwd = resolvedWD
} else { } else {
cwd = wd cwd = wd
@ -429,7 +421,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
if cwd == "" { if cwd == "" {
wd, err := os.Getwd() wd, err := os.Getwd()
if err == nil { if err == nil {
cwd = wd cwd = wd
} }
@ -450,27 +441,21 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolResult { func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolResult {
// timeout == 0 means no timeout // timeout == 0 means no timeout
var cmdCtx context.Context var cmdCtx context.Context
var cancel context.CancelFunc var cancel context.CancelFunc
if t.timeout > 0 { if t.timeout > 0 {
cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) cmdCtx, cancel = context.WithTimeout(ctx, t.timeout)
} else { } else {
cmdCtx, cancel = context.WithCancel(ctx) cmdCtx, cancel = context.WithCancel(ctx)
} }
defer cancel() defer cancel()
var cmd *exec.Cmd var cmd *exec.Cmd
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command)
} else { } else {
cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) cmd = exec.CommandContext(cmdCtx, "sh", "-c", command)
} }
if cwd != "" { if cwd != "" {
cmd.Dir = cwd cmd.Dir = cwd
} }
@ -478,9 +463,7 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
prepareCommandForTermination(cmd) prepareCommandForTermination(cmd)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
@ -488,29 +471,21 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
} }
done := make(chan error, 1) done := make(chan error, 1)
go func() { go func() {
done <- cmd.Wait() done <- cmd.Wait()
}() }()
var err error var err error
select { select {
case err = <-done: case err = <-done:
case <-cmdCtx.Done(): case <-cmdCtx.Done():
_ = terminateProcessTree(cmd) _ = terminateProcessTree(cmd)
select { select {
case err = <-done: case err = <-done:
case <-time.After(2 * time.Second): case <-time.After(2 * time.Second):
if cmd.Process != nil { if cmd.Process != nil {
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
} }
err = <-done err = <-done
} }
} }
@ -528,12 +503,10 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
if err != nil { if err != nil {
if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) {
msg := fmt.Sprintf("Command timed out after %v", t.timeout) msg := fmt.Sprintf("Command timed out after %v", t.timeout)
return &ToolResult{ return &ToolResult{
ForLLM: msg, ForLLM: msg,
ForUser: msg, ForUser: msg,
IsError: true, IsError: true,
} }
} }
@ -548,7 +521,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
} }
maxLen := 10000 maxLen := 10000
if len(output) > maxLen { if len(output) > maxLen {
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
} }
@ -558,7 +530,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
ForLLM: output, ForLLM: output,
ForUser: output, ForUser: output,
IsError: true, IsError: true,
} }
} }
@ -567,7 +538,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe
ForLLM: output, ForLLM: output,
ForUser: output, ForUser: output,
IsError: false, IsError: false,
} }
} }
@ -971,7 +941,6 @@ func (t *ExecTool) Shutdown() {
func (t *ExecTool) guardCommand(command, cwd string) string { func (t *ExecTool) guardCommand(command, cwd string) string {
cmd := strings.TrimSpace(command) cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd) lower := strings.ToLower(cmd)
for _, pattern := range t.denyPatterns { for _, pattern := range t.denyPatterns {

View file

@ -35,7 +35,6 @@ func terminateProcessTree(cmd *exec.Cmd) error {
// Fallback kill on the shell process itself. // Fallback kill on the shell process itself.
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
return nil return nil
} }

View file

@ -17,14 +17,11 @@ func terminateProcessTree(cmd *exec.Cmd) error {
} }
pid := cmd.Process.Pid pid := cmd.Process.Pid
if pid <= 0 { if pid <= 0 {
return nil return nil
} }
_ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run()
_ = cmd.Process.Kill() _ = cmd.Process.Kill()
return nil return nil
} }

View file

@ -16,11 +16,8 @@ import (
) )
// InstallSkillTool allows the LLM agent to install skills from registries. // InstallSkillTool allows the LLM agent to install skills from registries.
// It shares the same RegistryManager that FindSkillsTool uses, // It shares the same RegistryManager that FindSkillsTool uses,
// so all registries configured in config are available for installation. // so all registries configured in config are available for installation.
type InstallSkillTool struct { type InstallSkillTool struct {
registryMgr *skills.RegistryManager registryMgr *skills.RegistryManager
@ -30,11 +27,8 @@ type InstallSkillTool struct {
} }
// NewInstallSkillTool creates a new InstallSkillTool. // NewInstallSkillTool creates a new InstallSkillTool.
// registryMgr is the shared registry manager (same instance as FindSkillsTool). // registryMgr is the shared registry manager (same instance as FindSkillsTool).
// workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/.
func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool {
return &InstallSkillTool{ return &InstallSkillTool{
registryMgr: registryMgr, registryMgr: registryMgr,
@ -56,110 +50,86 @@ func (t *InstallSkillTool) Description() string {
func (t *InstallSkillTool) Parameters() map[string]any { func (t *InstallSkillTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"slug": map[string]any{ "slug": map[string]any{
"type": "string", "type": "string",
"description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')",
}, },
"version": map[string]any{ "version": map[string]any{
"type": "string", "type": "string",
"description": "Specific version to install (optional, defaults to latest)", "description": "Specific version to install (optional, defaults to latest)",
}, },
"registry": map[string]any{ "registry": map[string]any{
"type": "string", "type": "string",
"description": "Registry to install from (required, e.g., 'clawhub')", "description": "Registry to install from (required, e.g., 'clawhub')",
}, },
"force": map[string]any{ "force": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Force reinstall if skill already exists (default false)", "description": "Force reinstall if skill already exists (default false)",
}, },
}, },
"required": []string{"slug", "registry"}, "required": []string{"slug", "registry"},
} }
} }
func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
// Install lock to prevent concurrent directory operations. // Install lock to prevent concurrent directory operations.
// Ideally this should be done at a `slug` level, currently, its at a `workspace` level. // Ideally this should be done at a `slug` level, currently, its at a `workspace` level.
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
// Validate slug // Validate slug
slug, _ := args["slug"].(string) slug, _ := args["slug"].(string)
if err := utils.ValidateSkillIdentifier(slug); err != nil { if err := utils.ValidateSkillIdentifier(slug); err != nil {
return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error()))
} }
// Validate registry // Validate registry
registryName, _ := args["registry"].(string) registryName, _ := args["registry"].(string)
if err := utils.ValidateSkillIdentifier(registryName); err != nil { if err := utils.ValidateSkillIdentifier(registryName); err != nil {
return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error()))
} }
version, _ := args["version"].(string) version, _ := args["version"].(string)
force, _ := args["force"].(bool) force, _ := args["force"].(bool)
// Check if already installed. // Check if already installed.
skillsDir := filepath.Join(t.workspace, "skills") skillsDir := filepath.Join(t.workspace, "skills")
targetDir := filepath.Join(skillsDir, slug) targetDir := filepath.Join(skillsDir, slug)
if !force { if !force {
if _, err := os.Stat(targetDir); err == nil { if _, err := os.Stat(targetDir); err == nil {
return ErrorResult( return ErrorResult(
fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir),
) )
} }
} else { } else {
// Force: remove existing if present. // Force: remove existing if present.
os.RemoveAll(targetDir) os.RemoveAll(targetDir)
} }
// Resolve which registry to use. // Resolve which registry to use.
registry := t.registryMgr.GetRegistry(registryName) registry := t.registryMgr.GetRegistry(registryName)
if registry == nil { if registry == nil {
return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
} }
// Ensure skills directory exists. // Ensure skills directory exists.
if err := os.MkdirAll(skillsDir, 0o755); err != nil { if err := os.MkdirAll(skillsDir, 0o755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err))
} }
// Download and install (handles metadata, version resolution, extraction). // Download and install (handles metadata, version resolution, extraction).
result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir) result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir)
if err != nil { if err != nil {
// Clean up partial install. // Clean up partial install.
rmErr := os.RemoveAll(targetDir) rmErr := os.RemoveAll(targetDir)
if rmErr != nil { if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install", logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
@ -168,18 +138,14 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(), "error": rmErr.Error(),
}) })
} }
return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err))
} }
// Moderation: block malware. // Moderation: block malware.
if result.IsMalwareBlocked { if result.IsMalwareBlocked {
rmErr := os.RemoveAll(targetDir) rmErr := os.RemoveAll(targetDir)
if rmErr != nil { if rmErr != nil {
logger.ErrorCF("tool", "Failed to remove partial install", logger.ErrorCF("tool", "Failed to remove partial install",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
@ -188,15 +154,12 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"error": rmErr.Error(), "error": rmErr.Error(),
}) })
} }
return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug))
} }
// Write origin metadata. // Write origin metadata.
if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil {
logger.ErrorCF("tool", "Failed to write origin metadata", logger.ErrorCF("tool", "Failed to write origin metadata",
map[string]any{ map[string]any{
"tool": "install_skill", "tool": "install_skill",
@ -210,33 +173,26 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To
"version": result.Version, "version": result.Version,
}) })
_ = err _ = err
} }
// Build result with moderation warning if suspicious. // Build result with moderation warning if suspicious.
var output string var output string
if result.IsSuspicious { if result.IsSuspicious {
output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug)
} }
output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n",
slug, result.Version, registry.Name(), targetDir) slug, result.Version, registry.Name(), targetDir)
if result.Summary != "" { if result.Summary != "" {
output += fmt.Sprintf("Description: %s\n", result.Summary) output += fmt.Sprintf("Description: %s\n", result.Summary)
} }
output += "\nThe skill is now available and can be loaded in the current session." output += "\nThe skill is now available and can be loaded in the current session."
return SilentResult(output) return SilentResult(output)
} }
// originMeta tracks which registry a skill was installed from. // originMeta tracks which registry a skill was installed from.
type originMeta struct { type originMeta struct {
Version int `json:"version"` Version int `json:"version"`
@ -268,6 +224,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
} }
// Use unified atomic write utility with explicit sync for flash storage reliability. // Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
} }

View file

@ -9,7 +9,6 @@ import (
) )
// FindSkillsTool allows the LLM agent to search for installable skills from registries. // FindSkillsTool allows the LLM agent to search for installable skills from registries.
type FindSkillsTool struct { type FindSkillsTool struct {
registryMgr *skills.RegistryManager registryMgr *skills.RegistryManager
@ -17,11 +16,8 @@ type FindSkillsTool struct {
} }
// NewFindSkillsTool creates a new FindSkillsTool. // NewFindSkillsTool creates a new FindSkillsTool.
// registryMgr is the shared registry manager (built from config in createToolRegistry). // registryMgr is the shared registry manager (built from config in createToolRegistry).
// cache is the search cache for deduplicating similar queries. // cache is the search cache for deduplicating similar queries.
func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool {
return &FindSkillsTool{ return &FindSkillsTool{
registryMgr: registryMgr, registryMgr: registryMgr,
@ -41,14 +37,12 @@ func (t *FindSkillsTool) Description() string {
func (t *FindSkillsTool) Parameters() map[string]any { func (t *FindSkillsTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"query": map[string]any{ "query": map[string]any{
"type": "string", "type": "string",
"description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')",
}, },
"limit": map[string]any{ "limit": map[string]any{
"type": "integer", "type": "integer",
@ -59,32 +53,26 @@ func (t *FindSkillsTool) Parameters() map[string]any {
"maximum": 20.0, "maximum": 20.0,
}, },
}, },
"required": []string{"query"}, "required": []string{"query"},
} }
} }
func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string) query, ok := args["query"].(string)
query = strings.ToLower(strings.TrimSpace(query)) query = strings.ToLower(strings.TrimSpace(query))
if !ok || query == "" { if !ok || query == "" {
return ErrorResult("query is required and must be a non-empty string") return ErrorResult("query is required and must be a non-empty string")
} }
limit := 5 limit := 5
if l, ok := args["limit"].(float64); ok { if l, ok := args["limit"].(float64); ok {
li := int(l) li := int(l)
if li >= 1 && li <= 20 { if li >= 1 && li <= 20 {
limit = li limit = li
} }
} }
// Check cache first. // Check cache first.
if t.cache != nil { if t.cache != nil {
if cached, hit := t.cache.Get(query); hit { if cached, hit := t.cache.Get(query); hit {
return SilentResult(formatSearchResults(query, cached, true)) return SilentResult(formatSearchResults(query, cached, true))
@ -92,14 +80,12 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool
} }
// Search all registries. // Search all registries.
results, err := t.registryMgr.SearchAll(ctx, query, limit) results, err := t.registryMgr.SearchAll(ctx, query, limit)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("skill search failed: %v", err)) return ErrorResult(fmt.Sprintf("skill search failed: %v", err))
} }
// Cache the results. // Cache the results.
if t.cache != nil && len(results) > 0 { if t.cache != nil && len(results) > 0 {
t.cache.Put(query, results) t.cache.Put(query, results)
} }
@ -113,36 +99,27 @@ func formatSearchResults(query string, results []skills.SearchResult, cached boo
} }
var sb strings.Builder var sb strings.Builder
source := "" source := ""
if cached { if cached {
source = " (cached)" source = " (cached)"
} }
sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source)) sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source))
for i, r := range results { for i, r := range results {
sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug)) sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug))
if r.Version != "" { if r.Version != "" {
sb.WriteString(fmt.Sprintf(" v%s", r.Version)) sb.WriteString(fmt.Sprintf(" v%s", r.Version))
} }
sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName)) sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName))
if r.DisplayName != "" && r.DisplayName != r.Slug { if r.DisplayName != "" && r.DisplayName != r.Slug {
sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName)) sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName))
} }
if r.Summary != "" { if r.Summary != "" {
sb.WriteString(fmt.Sprintf(" %s\n", r.Summary)) sb.WriteString(fmt.Sprintf(" %s\n", r.Summary))
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
sb.WriteString("Use install_skill with the slug to install a skill.") sb.WriteString("Use install_skill with the slug to install a skill.")
return sb.String() return sb.String()
} }

View file

@ -45,20 +45,17 @@ func (t *SpawnTool) Description() string {
func (t *SpawnTool) Parameters() map[string]any { func (t *SpawnTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"task": map[string]any{ "task": map[string]any{
"type": "string", "type": "string",
"description": "The task for subagent to complete", "description": "The task for subagent to complete",
}, },
"label": map[string]any{ "label": map[string]any{
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
"agent_id": map[string]any{ "agent_id": map[string]any{
"type": "string", "type": "string",
@ -73,7 +70,6 @@ func (t *SpawnTool) Parameters() map[string]any {
"description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)",
}, },
}, },
"required": []string{"task"}, "required": []string{"task"},
} }
} }
@ -90,7 +86,6 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) {
func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok || strings.TrimSpace(task) == "" { if !ok || strings.TrimSpace(task) == "" {
return ErrorResult( return ErrorResult(
@ -101,7 +96,6 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
} }
label, _ := args["label"].(string) label, _ := args["label"].(string)
agentID, _ := args["agent_id"].(string) agentID, _ := args["agent_id"].(string)
preset, _ := args["preset"].(string) preset, _ := args["preset"].(string)
@ -141,6 +135,5 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul
} }
// Return AsyncResult since the task runs in background // Return AsyncResult since the task runs in background
return AsyncResult(result) return AsyncResult(result)
} }

View file

@ -10,7 +10,6 @@ import (
) )
// SPITool provides SPI bus interaction for high-speed peripheral communication. // SPITool provides SPI bus interaction for high-speed peripheral communication.
type SPITool struct{} type SPITool struct{}
func NewSPITool() *SPITool { func NewSPITool() *SPITool {
@ -28,7 +27,6 @@ func (t *SPITool) Description() string {
func (t *SPITool) Parameters() map[string]any { func (t *SPITool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"action": map[string]any{ "action": map[string]any{
"type": "string", "type": "string",
@ -37,31 +35,26 @@ func (t *SPITool) Parameters() map[string]any {
"description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
}, },
"device": map[string]any{ "device": map[string]any{
"type": "string", "type": "string",
"description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
}, },
"speed": map[string]any{ "speed": map[string]any{
"type": "integer", "type": "integer",
"description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
}, },
"mode": map[string]any{ "mode": map[string]any{
"type": "integer", "type": "integer",
"description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.",
}, },
"bits": map[string]any{ "bits": map[string]any{
"type": "integer", "type": "integer",
"description": "Bits per word. Default: 8.", "description": "Bits per word. Default: 8.",
}, },
"data": map[string]any{ "data": map[string]any{
"type": "array", "type": "array",
@ -69,20 +62,17 @@ func (t *SPITool) Parameters() map[string]any {
"description": "Bytes to send (0-255 each). Required for transfer action.", "description": "Bytes to send (0-255 each). Required for transfer action.",
}, },
"length": map[string]any{ "length": map[string]any{
"type": "integer", "type": "integer",
"description": "Number of bytes to read (1-4096). Required for read action.", "description": "Number of bytes to read (1-4096). Required for read action.",
}, },
"confirm": map[string]any{ "confirm": map[string]any{
"type": "boolean", "type": "boolean",
"description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
}, },
}, },
"required": []string{"action"}, "required": []string{"action"},
} }
} }
@ -93,32 +83,23 @@ func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult
} }
action, ok := args["action"].(string) action, ok := args["action"].(string)
if !ok { if !ok {
return ErrorResult("action is required") return ErrorResult("action is required")
} }
switch action { switch action {
case "list": case "list":
return t.list() return t.list()
case "transfer": case "transfer":
return t.transfer(args) return t.transfer(args)
case "read": case "read":
return t.readDevice(args) return t.readDevice(args)
default: default:
return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action))
} }
} }
// list finds available SPI devices by globbing /dev/spidev* // list finds available SPI devices by globbing /dev/spidev*
func (t *SPITool) list() *ToolResult { func (t *SPITool) list() *ToolResult {
matches, err := filepath.Glob("/dev/spidev*") matches, err := filepath.Glob("/dev/spidev*")
if err != nil { if err != nil {
@ -138,9 +119,7 @@ func (t *SPITool) list() *ToolResult {
} }
devices := make([]devInfo, 0, len(matches)) devices := make([]devInfo, 0, len(matches))
re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`)
for _, m := range matches { for _, m := range matches {
if sub := re.FindStringSubmatch(m); sub != nil { if sub := re.FindStringSubmatch(m); sub != nil {
devices = append(devices, devInfo{Path: m, Device: sub[1]}) devices = append(devices, devInfo{Path: m, Device: sub[1]})
@ -148,58 +127,45 @@ func (t *SPITool) list() *ToolResult {
} }
result, _ := json.MarshalIndent(devices, "", " ") result, _ := json.MarshalIndent(devices, "", " ")
return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
} }
// Helper function for SPI operations (used by platform-specific implementations) // Helper function for SPI operations (used by platform-specific implementations)
// parseSPIArgs extracts and validates common SPI parameters // parseSPIArgs extracts and validates common SPI parameters
// //
//nolint:unused // Used by spi_linux.go //nolint:unused // Used by spi_linux.go
func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
dev, ok := args["device"].(string) dev, ok := args["device"].(string)
if !ok || dev == "" { if !ok || dev == "" {
return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)"
} }
matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev)
if !matched { if !matched {
return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")"
} }
speed = 1000000 // default 1 MHz speed = 1000000 // default 1 MHz
if s, ok := args["speed"].(float64); ok { if s, ok := args["speed"].(float64); ok {
if s < 1 || s > 125000000 { if s < 1 || s > 125000000 {
return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz"
} }
speed = uint32(s) speed = uint32(s)
} }
mode = 0 mode = 0
if m, ok := args["mode"].(float64); ok { if m, ok := args["mode"].(float64); ok {
if int(m) < 0 || int(m) > 3 { if int(m) < 0 || int(m) > 3 {
return "", 0, 0, 0, "mode must be 0-3" return "", 0, 0, 0, "mode must be 0-3"
} }
mode = uint8(m) mode = uint8(m)
} }
bits = 8 bits = 8
if b, ok := args["bits"].(float64); ok { if b, ok := args["bits"].(float64); ok {
if int(b) < 1 || int(b) > 32 { if int(b) < 1 || int(b) > 32 {
return "", 0, 0, 0, "bits must be between 1 and 32" return "", 0, 0, 0, "bits must be between 1 and 32"
} }
bits = uint8(b) bits = uint8(b)
} }

View file

@ -140,7 +140,6 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult {
"received": intBytes, "received": intBytes,
"hex": hexBytes, "hex": hexBytes,
}, "", " ") }, "", " ")
return SilentResult(string(result)) return SilentResult(string(result))
} }
@ -196,6 +195,5 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult {
"hex": hexBytes, "hex": hexBytes,
"length": len(rxBuf), "length": len(rxBuf),
}, "", " ") }, "", " ")
return SilentResult(string(result)) return SilentResult(string(result))
} }

View file

@ -174,7 +174,6 @@ type SubagentManager struct {
func NewSubagentManager( func NewSubagentManager(
provider providers.LLMProvider, provider providers.LLMProvider,
defaultModel, workspace string, defaultModel, workspace string,
bus *bus.MessageBus, bus *bus.MessageBus,
@ -211,30 +210,20 @@ func NewSubagentManager(
} }
// SetLLMOptions sets max tokens and temperature for subagent LLM calls. // SetLLMOptions sets max tokens and temperature for subagent LLM calls.
func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
sm.maxTokens = maxTokens sm.maxTokens = maxTokens
sm.hasMaxTokens = true sm.hasMaxTokens = true
sm.temperature = temperature sm.temperature = temperature
sm.hasTemperature = true sm.hasTemperature = true
} }
// SetTools sets the tool registry for subagent execution. // SetTools sets the tool registry for subagent execution.
// If not set, subagent will have access to the provided tools. // If not set, subagent will have access to the provided tools.
func (sm *SubagentManager) SetTools(tools *ToolRegistry) { func (sm *SubagentManager) SetTools(tools *ToolRegistry) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
sm.tools = tools sm.tools = tools
} }
@ -251,12 +240,9 @@ func (sm *SubagentManager) SetSessionRecorder(r SessionRecorder, conductorSessio
} }
// RegisterTool registers a tool for subagent execution. // RegisterTool registers a tool for subagent execution.
func (sm *SubagentManager) RegisterTool(tool Tool) { func (sm *SubagentManager) RegisterTool(tool Tool) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
sm.tools.Register(tool) sm.tools.Register(tool)
} }
@ -268,11 +254,9 @@ func (sm *SubagentManager) Spawn(
callback AsyncCallback, callback AsyncCallback,
) (string, error) { ) (string, error) {
sm.mu.Lock() sm.mu.Lock()
defer sm.mu.Unlock() defer sm.mu.Unlock()
taskID := fmt.Sprintf("subagent-%d", sm.nextID) taskID := fmt.Sprintf("subagent-%d", sm.nextID)
sm.nextID++ sm.nextID++
subagentTask := &SubagentTask{ subagentTask := &SubagentTask{
@ -338,7 +322,6 @@ func (sm *SubagentManager) Spawn(
if label != "" { if label != "" {
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
} }
return fmt.Sprintf("Spawned subagent for task: %s", task), nil return fmt.Sprintf("Spawned subagent for task: %s", task), nil
} }
@ -447,9 +430,7 @@ func (sm *SubagentManager) finishTask(
callback AsyncCallback, callback AsyncCallback,
) { ) {
sm.mu.Lock() sm.mu.Lock()
var result *ToolResult var result *ToolResult
defer func() { defer func() {
sm.mu.Unlock() sm.mu.Unlock()
@ -460,14 +441,12 @@ func (sm *SubagentManager) finishTask(
if err != nil { if err != nil {
task.Status = "failed" task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err) task.Result = fmt.Sprintf("Error: %v", err)
gcReason := "failed" gcReason := "failed"
if ctx.Err() != nil { if ctx.Err() != nil {
task.Status = "canceled" task.Status = "canceled"
task.Result = "Task canceled during execution" task.Result = "Task canceled during execution"
gcReason = "canceled" gcReason = "canceled"
@ -492,7 +471,6 @@ func (sm *SubagentManager) finishTask(
} }
} else { } else {
task.Status = "completed" task.Status = "completed"
task.Result = loopResult.Content task.Result = loopResult.Content
task.CompletedAt = time.Now().UnixMilli() task.CompletedAt = time.Now().UnixMilli()
@ -523,14 +501,12 @@ func (sm *SubagentManager) finishTask(
"Subagent '%s' completed (iterations: %d, tool calls: %d): %s", "Subagent '%s' completed (iterations: %d, tool calls: %d): %s",
task.Label, task.Label,
loopResult.Iterations, loopResult.Iterations,
loopResult.ToolCalls, loopResult.ToolCalls,
loopResult.Content, loopResult.Content,
), ),
ForUser: loopResult.Content, ForUser: loopResult.Content,
} }
} }
@ -1006,34 +982,25 @@ func (sm *SubagentManager) CancelTask(taskID string) {
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
task, ok := sm.tasks[taskID] task, ok := sm.tasks[taskID]
return task, ok return task, ok
} }
func (sm *SubagentManager) ListTasks() []*SubagentTask { func (sm *SubagentManager) ListTasks() []*SubagentTask {
sm.mu.RLock() sm.mu.RLock()
defer sm.mu.RUnlock() defer sm.mu.RUnlock()
tasks := make([]*SubagentTask, 0, len(sm.tasks)) tasks := make([]*SubagentTask, 0, len(sm.tasks))
for _, task := range sm.tasks { for _, task := range sm.tasks {
tasks = append(tasks, task) tasks = append(tasks, task)
} }
return tasks return tasks
} }
// SubagentTool executes a subagent task synchronously and returns the result. // SubagentTool executes a subagent task synchronously and returns the result.
// Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion
// and returns the result directly in the ToolResult. // and returns the result directly in the ToolResult.
type SubagentTool struct { type SubagentTool struct {
manager *SubagentManager manager *SubagentManager
@ -1063,21 +1030,18 @@ func (t *SubagentTool) Description() string {
func (t *SubagentTool) Parameters() map[string]any { func (t *SubagentTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"task": map[string]any{ "task": map[string]any{
"type": "string", "type": "string",
"description": "The task for subagent to complete", "description": "The task for subagent to complete",
}, },
"label": map[string]any{ "label": map[string]any{
"type": "string", "type": "string",
"description": "Optional short label for the task (for display)", "description": "Optional short label for the task (for display)",
}, },
}, },
"required": []string{"task"}, "required": []string{"task"},
} }
} }
@ -1090,7 +1054,6 @@ func (t *SubagentTool) SetContext(channel, chatID string) {
func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
task, ok := args["task"].(string) task, ok := args["task"].(string)
if !ok { if !ok {
return ErrorResult( return ErrorResult(
@ -1108,14 +1071,12 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
// Build messages for subagent // Build messages for subagent
messages := []providers.Message{ messages := []providers.Message{
{ {
Role: "system", Role: "system",
Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.",
}, },
{ {
Role: "user", Role: "user",
@ -1124,34 +1085,22 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
// Use RunToolLoop to execute with tools (same as async SpawnTool) // Use RunToolLoop to execute with tools (same as async SpawnTool)
sm := t.manager sm := t.manager
sm.mu.RLock() sm.mu.RLock()
tools := sm.tools tools := sm.tools
maxIter := sm.maxIterations maxIter := sm.maxIterations
maxTokens := sm.maxTokens maxTokens := sm.maxTokens
temperature := sm.temperature temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature hasTemperature := sm.hasTemperature
sm.mu.RUnlock() sm.mu.RUnlock()
var llmOptions map[string]any var llmOptions map[string]any
if hasMaxTokens || hasTemperature { if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{} llmOptions = map[string]any{}
if hasMaxTokens { if hasMaxTokens {
llmOptions["max_tokens"] = maxTokens llmOptions["max_tokens"] = maxTokens
} }
if hasTemperature { if hasTemperature {
llmOptions["temperature"] = temperature llmOptions["temperature"] = temperature
} }
@ -1173,19 +1122,14 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
// ForUser: Brief summary for user (truncated if too long) // ForUser: Brief summary for user (truncated if too long)
userContent := loopResult.Content userContent := loopResult.Content
maxUserLen := 500 maxUserLen := 500
if len(userContent) > maxUserLen { if len(userContent) > maxUserLen {
userContent = userContent[:maxUserLen] + "..." userContent = userContent[:maxUserLen] + "..."
} }
// ForLLM: Full execution details // ForLLM: Full execution details
labelStr := label labelStr := label
if labelStr == "" { if labelStr == "" {
labelStr = "(unnamed)" labelStr = "(unnamed)"
} }

View file

@ -1,11 +1,7 @@
// PicoClaw - Ultra-lightweight personal AI agent // PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT // License: MIT
// //
// Copyright (c) 2026 PicoClaw contributors // Copyright (c) 2026 PicoClaw contributors
package tools package tools
@ -22,7 +18,6 @@ import (
) )
// ToolLoopConfig configures the tool execution loop. // ToolLoopConfig configures the tool execution loop.
type ToolLoopConfig struct { type ToolLoopConfig struct {
Provider providers.LLMProvider Provider providers.LLMProvider
@ -48,7 +43,6 @@ type ToolLoopConfig struct {
} }
// ToolLoopResult contains the result of running the tool loop. // ToolLoopResult contains the result of running the tool loop.
type ToolLoopResult struct { type ToolLoopResult struct {
Content string Content string
@ -60,16 +54,11 @@ type ToolLoopResult struct {
} }
// RunToolLoop executes the LLM + tool call iteration loop. // RunToolLoop executes the LLM + tool call iteration loop.
// This is the core agent logic that can be reused by both main agent and subagents. // This is the core agent logic that can be reused by both main agent and subagents.
func RunToolLoop( func RunToolLoop(
ctx context.Context, ctx context.Context,
config ToolLoopConfig, config ToolLoopConfig,
messages []providers.Message, messages []providers.Message,
channel, chatID string, channel, chatID string,
) (*ToolLoopResult, error) { ) (*ToolLoopResult, error) {
reporter := config.Reporter reporter := config.Reporter
@ -90,7 +79,6 @@ func RunToolLoop(
iteration++ iteration++
logger.DebugCF("toolloop", "LLM iteration", logger.DebugCF("toolloop", "LLM iteration",
map[string]any{ map[string]any{
"iteration": iteration, "iteration": iteration,
@ -98,17 +86,13 @@ func RunToolLoop(
}) })
// 1. Build tool definitions // 1. Build tool definitions
var providerToolDefs []providers.ToolDefinition var providerToolDefs []providers.ToolDefinition
if config.Tools != nil { if config.Tools != nil {
providerToolDefs = config.Tools.ToProviderDefs() providerToolDefs = config.Tools.ToProviderDefs()
} }
// 2. Set default LLM options // 2. Set default LLM options
llmOpts := config.LLMOptions llmOpts := config.LLMOptions
if llmOpts == nil { if llmOpts == nil {
llmOpts = map[string]any{} llmOpts = map[string]any{}
} }
@ -120,48 +104,37 @@ func RunToolLoop(
response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts)
if err != nil { if err != nil {
logger.ErrorCF("toolloop", "LLM call failed", logger.ErrorCF("toolloop", "LLM call failed",
map[string]any{ map[string]any{
"iteration": iteration, "iteration": iteration,
"error": err.Error(), "error": err.Error(),
}) })
return nil, fmt.Errorf("LLM call failed: %w", err) return nil, fmt.Errorf("LLM call failed: %w", err)
} }
// 4. If no tool calls, we're done // 4. If no tool calls, we're done
if len(response.ToolCalls) == 0 { if len(response.ToolCalls) == 0 {
finalContent = response.Content finalContent = response.Content
logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)", logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)",
map[string]any{ map[string]any{
"iteration": iteration, "iteration": iteration,
"content_chars": len(finalContent), "content_chars": len(finalContent),
}) })
break break
} }
normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls { for _, tc := range response.ToolCalls {
normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
} }
// 5. Log tool calls // 5. Log tool calls
toolNames := make([]string, 0, len(normalizedToolCalls)) toolNames := make([]string, 0, len(normalizedToolCalls))
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name) toolNames = append(toolNames, tc.Name)
} }
logger.InfoCF("toolloop", "LLM requested tool calls", logger.InfoCF("toolloop", "LLM requested tool calls",
map[string]any{ map[string]any{
"tools": toolNames, "tools": toolNames,
@ -171,13 +144,11 @@ func RunToolLoop(
}) })
// 6. Build assistant message with tool calls // 6. Build assistant message with tool calls
assistantMsg := providers.Message{ assistantMsg := providers.Message{
Role: "assistant", Role: "assistant",
Content: response.Content, Content: response.Content,
} }
for _, tc := range normalizedToolCalls { for _, tc := range normalizedToolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID, ID: tc.ID,
@ -187,7 +158,6 @@ func RunToolLoop(
Name: tc.Name, Name: tc.Name,
Arguments: tc.Arguments, Arguments: tc.Arguments,
Function: &providers.FunctionCall{ Function: &providers.FunctionCall{
Name: tc.Name, Name: tc.Name,
@ -195,7 +165,6 @@ func RunToolLoop(
}, },
}) })
} }
messages = append(messages, assistantMsg) messages = append(messages, assistantMsg)
// 7. Execute tool calls (hook: toolcall per tool) // 7. Execute tool calls (hook: toolcall per tool)

View file

@ -30,7 +30,6 @@ const (
) )
// Pre-compiled regexes for HTML text extraction // Pre-compiled regexes for HTML text extraction
var ( var (
reScript = regexp.MustCompile(`<script[\s\S]*?</script>`) reScript = regexp.MustCompile(`<script[\s\S]*?</script>`)
@ -39,7 +38,6 @@ var (
reTags = regexp.MustCompile(`<[^>]+>`) reTags = regexp.MustCompile(`<[^>]+>`)
reWhitespace = regexp.MustCompile(`[^\S\n]+`) reWhitespace = regexp.MustCompile(`[^\S\n]+`)
reBlankLines = regexp.MustCompile(`\n{3,}`) reBlankLines = regexp.MustCompile(`\n{3,}`)
// DuckDuckGo result extraction // DuckDuckGo result extraction
@ -50,11 +48,9 @@ var (
) )
// createHTTPClient creates an HTTP client with optional proxy support // createHTTPClient creates an HTTP client with optional proxy support
func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) {
client := &http.Client{ client := &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: &http.Transport{ Transport: &http.Transport{
MaxIdleConns: 10, MaxIdleConns: 10,
@ -71,26 +67,18 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid proxy URL: %w", err) return nil, fmt.Errorf("invalid proxy URL: %w", err)
} }
scheme := strings.ToLower(proxy.Scheme) scheme := strings.ToLower(proxy.Scheme)
switch scheme { switch scheme {
case "http", "https", "socks5", "socks5h": case "http", "https", "socks5", "socks5h":
default: default:
return nil, fmt.Errorf( return nil, fmt.Errorf(
"unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)",
proxy.Scheme, proxy.Scheme,
) )
} }
if proxy.Host == "" { if proxy.Host == "" {
return nil, fmt.Errorf("invalid proxy URL: missing host") return nil, fmt.Errorf("invalid proxy URL: missing host")
} }
client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy)
} else { } else {
client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment
@ -151,7 +139,6 @@ type BraveSearchProvider struct {
func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count) url.QueryEscape(query), count)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
@ -224,7 +211,6 @@ type TavilySearchProvider struct {
func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
searchURL := p.baseURL searchURL := p.baseURL
if searchURL == "" { if searchURL == "" {
searchURL = "https://api.tavily.com/search" searchURL = "https://api.tavily.com/search"
} }
@ -326,7 +312,6 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@ -339,15 +324,11 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
// Simple regex based extraction for DDG HTML // Simple regex based extraction for DDG HTML
// Strategy: Find all result containers or key anchors directly // Strategy: Find all result containers or key anchors directly
// Try finding the result links directly first, as they are the most critical // Try finding the result links directly first, as they are the most critical
// Pattern: <a class="result__a" href="...">Title</a> // Pattern: <a class="result__a" href="...">Title</a>
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content // The previous regex was a bit strict. Let's make it more flexible for attributes order/content
matches := reDDGLink.FindAllStringSubmatch(html, count+5) matches := reDDGLink.FindAllStringSubmatch(html, count+5)
if len(matches) == 0 { if len(matches) == 0 {
@ -362,17 +343,13 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
for i := range maxItems { for i := range maxItems {
urlStr := matches[i][1] urlStr := matches[i][1]
title := stripTags(matches[i][2]) title := stripTags(matches[i][2])
title = strings.TrimSpace(title) title = strings.TrimSpace(title)
// URL decoding if needed // URL decoding if needed
if strings.Contains(urlStr, "uddg=") { if strings.Contains(urlStr, "uddg=") {
if u, err := url.QueryUnescape(urlStr); err == nil { if u, err := url.QueryUnescape(urlStr); err == nil {
_, after, ok := strings.Cut(u, "uddg=") _, after, ok := strings.Cut(u, "uddg=")
if ok { if ok {
urlStr = after urlStr = after
} }
@ -382,7 +359,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
snippet := "" snippet := ""
// Attempt to attach snippet if available and index aligns // Attempt to attach snippet if available and index aligns
if i < len(snippetMatches) { if i < len(snippetMatches) {
snippet = stripTags(snippetMatches[i][1]) snippet = stripTags(snippetMatches[i][1])
@ -447,7 +423,6 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+p.apiKey) req.Header.Set("Authorization", "Bearer "+p.apiKey)
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
@ -456,7 +431,6 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
if err != nil { if err != nil {
return "", fmt.Errorf("request failed: %w", err) return "", fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@ -569,7 +543,6 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err)
} }
provider = &TavilySearchProvider{ provider = &TavilySearchProvider{
apiKey: opts.TavilyAPIKey, apiKey: opts.TavilyAPIKey,
@ -590,7 +563,6 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err)
} }
provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client}
providerName = "duckduckgo" providerName = "duckduckgo"
@ -622,14 +594,12 @@ func (t *WebSearchTool) Description() string {
func (t *WebSearchTool) Parameters() map[string]any { func (t *WebSearchTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"query": map[string]any{ "query": map[string]any{
"type": "string", "type": "string",
"description": "Search query", "description": "Search query",
}, },
"count": map[string]any{ "count": map[string]any{
"type": "integer", "type": "integer",
@ -640,20 +610,17 @@ func (t *WebSearchTool) Parameters() map[string]any {
"maximum": 10.0, "maximum": 10.0,
}, },
}, },
"required": []string{"query"}, "required": []string{"query"},
} }
} }
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
query, ok := args["query"].(string) query, ok := args["query"].(string)
if !ok { if !ok {
return ErrorResult("query is required") return ErrorResult("query is required")
} }
count := t.maxResults count := t.maxResults
if c, ok := args["count"].(float64); ok { if c, ok := args["count"].(float64); ok {
if int(c) > 0 && int(c) <= 10 { if int(c) > 0 && int(c) <= 10 {
count = int(c) count = int(c)
@ -692,7 +659,6 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error)
if maxChars <= 0 { if maxChars <= 0 {
maxChars = defaultMaxChars maxChars = defaultMaxChars
} }
client, err := createHTTPClient(proxy, fetchTimeout) client, err := createHTTPClient(proxy, fetchTimeout)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err)
@ -726,14 +692,12 @@ func (t *WebFetchTool) Description() string {
func (t *WebFetchTool) Parameters() map[string]any { func (t *WebFetchTool) Parameters() map[string]any {
return map[string]any{ return map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "properties": map[string]any{
"url": map[string]any{ "url": map[string]any{
"type": "string", "type": "string",
"description": "URL to fetch", "description": "URL to fetch",
}, },
"maxChars": map[string]any{ "maxChars": map[string]any{
"type": "integer", "type": "integer",
@ -742,14 +706,12 @@ func (t *WebFetchTool) Parameters() map[string]any {
"minimum": 100.0, "minimum": 100.0,
}, },
}, },
"required": []string{"url"}, "required": []string{"url"},
} }
} }
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
urlStr, ok := args["url"].(string) urlStr, ok := args["url"].(string)
if !ok { if !ok {
return ErrorResult("url is required") return ErrorResult("url is required")
} }
@ -768,7 +730,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
maxChars := t.maxChars maxChars := t.maxChars
if mc, ok := args["maxChars"].(float64); ok { if mc, ok := args["maxChars"].(float64); ok {
if int(mc) > 100 { if int(mc) > 100 {
maxChars = int(mc) maxChars = int(mc)
@ -781,7 +742,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
req.Header.Set("User-Agent", userAgent) req.Header.Set("User-Agent", userAgent)
resp, err := t.client.Do(req) resp, err := t.client.Do(req)
if err != nil { if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err)) return ErrorResult(fmt.Sprintf("request failed: %v", err))
@ -802,12 +762,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if strings.Contains(contentType, "application/json") { if strings.Contains(contentType, "application/json") {
var jsonData any var jsonData any
if err := json.Unmarshal(body, &jsonData); err == nil { if err := json.Unmarshal(body, &jsonData); err == nil {
formatted, _ := json.MarshalIndent(jsonData, "", " ") formatted, _ := json.MarshalIndent(jsonData, "", " ")
text = string(formatted) text = string(formatted)
extractor = "json" extractor = "json"
} else { } else {
text = bodyStr text = bodyStr
@ -827,7 +784,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
} }
truncated := len(text) > maxChars truncated := len(text) > maxChars
if truncated { if truncated {
text = text[:maxChars] text = text[:maxChars]
} }
@ -838,7 +794,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
"status": resp.StatusCode, "status": resp.StatusCode,
"extractor": extractor, "extractor": extractor,
"truncated": truncated, "truncated": truncated,
"length": len(text), "length": len(text),
@ -852,13 +807,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
ForLLM: fmt.Sprintf( ForLLM: fmt.Sprintf(
"Fetched %d bytes from %s (extractor: %s, truncated: %v)", "Fetched %d bytes from %s (extractor: %s, truncated: %v)",
len(text), len(text),
urlStr, urlStr,
extractor, extractor,
truncated, truncated,
), ),
@ -868,15 +819,12 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
func (t *WebFetchTool) extractText(htmlContent string) string { func (t *WebFetchTool) extractText(htmlContent string) string {
result := reScript.ReplaceAllLiteralString(htmlContent, "") result := reScript.ReplaceAllLiteralString(htmlContent, "")
result = reStyle.ReplaceAllLiteralString(result, "") result = reStyle.ReplaceAllLiteralString(result, "")
result = reTags.ReplaceAllLiteralString(result, "") result = reTags.ReplaceAllLiteralString(result, "")
result = strings.TrimSpace(result) result = strings.TrimSpace(result)
result = reWhitespace.ReplaceAllString(result, " ") result = reWhitespace.ReplaceAllString(result, " ")
result = reBlankLines.ReplaceAllString(result, "\n\n") result = reBlankLines.ReplaceAllString(result, "\n\n")
lines := strings.Split(result, "\n") lines := strings.Split(result, "\n")
@ -885,7 +833,6 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
for _, line := range lines { for _, line := range lines {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
if line != "" { if line != "" {
if sb.Len() > 0 { if sb.Len() > 0 {
sb.WriteByte('\n') sb.WriteByte('\n')