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:
parent
1c12de7630
commit
8eaf46e87e
24 changed files with 0 additions and 960 deletions
|
|
@ -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),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue