UNPKG

erosolar-cli

Version:

Unified AI agent framework for the command line - Multi-provider support with schema-driven tools, code intelligence, and transparent reasoning

459 lines 18.2 kB
import { randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { createNodeRuntime } from '../runtime/node.js'; import { loadToolSettings } from '../core/preferences.js'; import { buildEnabledToolSet, evaluateToolPermissions } from '../capabilities/toolRegistry.js'; import { AgentRegistry } from './agentRegistry.js'; export const BUILTIN_AGENT_DEFINITIONS = [ { name: 'general-purpose', description: 'Complete research, editing, and implementation tasks end-to-end.', summary: 'Autonomous agent for multi-step work.', prompt: [ 'Own the entire task autonomously. Narrate your plan, gather context with filesystem/search tools, and make changes when necessary.', 'Always cite the evidence, commands, and files you touched. Include TODOs or risks that need human review.', ].join('\n'), model: 'sonnet', source: 'builtin', }, { name: 'explore', description: 'Fast, read-only agent for mapping codebases and answering structural questions.', summary: 'Read/search focused, no edits.', prompt: [ 'Operate in read-only mode. Do not edit files or run mutating commands.', 'Prioritize read/search/glob tools before editing. Call out every directory or file you investigated.', 'Return a crisp summary of what you learned plus direct file references so the parent agent can follow up.', ].join('\n'), tools: ['read', 'read_file', 'read_files', 'list_files', 'search_files', 'glob_search', 'grep_search', 'find_definition', 'context_snapshot'], model: 'haiku', source: 'builtin', }, { name: 'plan', description: 'Break down complex efforts into actionable steps and identify risks or dependencies.', summary: 'Planning-first, read/search only.', prompt: [ 'Produce a numbered plan with estimates, dependency notes, and explicit testing checkpoints.', 'If the task mentions code changes, suggest which files/modules should be edited and why before any implementation occurs.', 'Do not modify files directly; focus on analysis and recommendations.', ].join('\n'), tools: ['read', 'read_file', 'read_files', 'list_files', 'search_files', 'glob_search', 'grep_search', 'find_definition', 'context_snapshot'], model: 'sonnet', source: 'builtin', }, ]; const MODEL_ID_LOOKUP = { sonnet: { provider: 'anthropic', model: 'claude-sonnet-4-5-20250929' }, opus: { provider: 'anthropic', model: 'claude-opus-4-20250514' }, haiku: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001' }, }; const TASK_STORE_DIR = join(homedir(), '.erosolar', 'tasks'); export class TaskRunner { context; registry; snapshots = new TaskSnapshotStore(); constructor(context, registry) { this.context = context; this.registry = registry ?? new AgentRegistry({ workingDir: context.workingDir, builtIns: BUILTIN_AGENT_DEFINITIONS, }); } async runTask(options, callbacks) { this.registry.refresh(); const definition = this.registry.resolve(options.subagentType); if (!definition) { const available = this.registry.list().map((agent) => agent.name).join(', '); throw new Error(`Subagent "${options.subagentType}" is not defined. Available agents: ${available || 'none found'}`); } const { allowedPluginIds } = this.resolveToolPermissions(); const adapterOptions = allowedPluginIds.size ? { filter: (plugin) => allowedPluginIds.has(plugin.id), } : undefined; const runtime = await createNodeRuntime({ profile: this.context.profile, workspaceContext: this.context.workspaceContext, workingDir: this.context.workingDir, env: this.context.env, adapterOptions, }); try { const session = runtime.session; const preferredModel = options.model ?? definition.model; const selection = this.buildModelSelection(session.profileConfig, preferredModel); let { runtime: toolRuntime, allowedTools, missingTools } = this.buildToolRuntime(session.toolRuntime, definition.tools); // Wrap with progress tracking if callbacks provided if (callbacks) { toolRuntime = new TrackedToolRuntime(toolRuntime, callbacks); } const systemPrompt = this.composeSystemPrompt(session.profileConfig.systemPrompt, definition, options.description, { thoroughness: options.thoroughness, allowedTools, missingTools, }); let finalMetadata = null; const agent = session.createAgent({ provider: selection.provider, model: selection.model, temperature: selection.temperature, maxTokens: selection.maxTokens, systemPrompt, }, { onAssistantMessage: (_content, metadata) => { // Report tokens for parallel agent display if (callbacks?.onTokens && metadata.usage) { const totalTokens = (metadata.usage.inputTokens || 0) + (metadata.usage.outputTokens || 0); if (totalTokens > 0) { callbacks.onTokens(totalTokens); } } if (metadata.isFinal) { finalMetadata = metadata; } }, }, toolRuntime); const resumeSnapshot = options.resumeId ? await this.snapshots.load(options.resumeId) : null; if (options.resumeId && !resumeSnapshot) { throw new Error(`Resume id "${options.resumeId}" was not found. Call Task without resume to start a new agent.`); } if (resumeSnapshot) { agent.loadHistory(resumeSnapshot.history); } const startedAt = Date.now(); const reply = await agent.send(options.prompt, false); const durationMs = Date.now() - startedAt; const history = agent.getHistory(); const resumeId = options.resumeId ?? this.snapshots.createId(); await this.snapshots.save({ id: resumeId, profile: this.context.profile, description: options.description, subagentType: definition.name, history, createdAt: resumeSnapshot?.createdAt ?? new Date().toISOString(), updatedAt: new Date().toISOString(), }); const parsed = extractResponseSections(reply); const usageLine = formatUsage(extractUsage(finalMetadata)); const durationLine = `Duration: ${formatDuration(durationMs)}${usageLine ? ` | ${usageLine}` : ''}`; const lines = [ `Task "${options.description}" completed by ${definition.name} agent (${selection.model})`, `${durationLine} | Resume ID: ${resumeId}`, ]; if (definition.summary) { lines.push(`Agent focus: ${definition.summary}`); } if (parsed.thinking) { lines.push('', 'Key reasoning:', parsed.thinking); } lines.push('', parsed.response || '(no response returned)'); return { output: lines.join('\n').trim() }; } finally { await runtime.host.dispose(); } } buildToolRuntime(baseRuntime, requestedTools) { if (!requestedTools?.length) { return { runtime: baseRuntime, allowedTools: [], missingTools: [] }; } const available = baseRuntime.listProviderTools(); const { allowedNames, missing } = resolveAllowedToolNames(requestedTools, available); if (!allowedNames.size) { return { runtime: baseRuntime, allowedTools: [], missingTools: missing }; } const runtime = new RestrictedToolRuntime(baseRuntime, allowedNames); return { runtime, allowedTools: Array.from(allowedNames), missingTools: missing }; } resolveToolPermissions() { const settings = loadToolSettings(); const selection = buildEnabledToolSet(settings); const summary = evaluateToolPermissions(selection); return { allowedPluginIds: summary.allowedPluginIds, }; } buildModelSelection(profile, preferred) { const normalized = preferred?.trim().toLowerCase(); if (normalized && normalized !== 'inherit' && MODEL_ID_LOOKUP[normalized]) { const mapping = MODEL_ID_LOOKUP[normalized]; return { provider: mapping.provider, model: mapping.model, temperature: profile.temperature, maxTokens: profile.maxTokens, }; } return { provider: profile.provider, model: profile.model, temperature: profile.temperature, maxTokens: profile.maxTokens, }; } composeSystemPrompt(basePrompt, definition, description, extras) { const toolLine = extras.allowedTools.length ? `Allowed tools for this subagent: ${extras.allowedTools.join(', ')}.` : 'This subagent inherits all tools available to the parent agent.'; const missingLine = extras.missingTools.length ? `Requested but unavailable tools: ${extras.missingTools.join(', ')}.` : null; const thoroughnessLine = extras.thoroughness ? `Requested exploration thoroughness: ${extras.thoroughness.replace('_', ' ')}.` : null; const permissionLine = definition.permissionMode ? `Permission mode: ${definition.permissionMode}` : null; const skillsLine = definition.skills && definition.skills.length ? `Auto-load skills: ${definition.skills.join(', ')}` : null; const agentMeta = [ `Subagent: ${definition.name}`, `Purpose: ${definition.summary || definition.description}`, toolLine, missingLine, permissionLine, skillsLine, thoroughnessLine, ] .filter(Boolean) .join('\n'); const lines = [ basePrompt.trim(), '', 'You are an autonomous sub-agent launched via the Task tool. Operate independently and return a single comprehensive report to the parent agent.', `Task summary: ${description}`, agentMeta, '', 'Execution rules:', '- Use ONLY the tools listed above. Do not request tools that are unavailable.', '- Keep your own context focused; do not bloat the response with unnecessary content.', '- Always cite the evidence, commands, and files you touched so the parent agent can verify your work.', '- If you are read-only, explicitly state any edits you avoided and the files you inspected.', definition.prompt, '', 'When you finish:', '- Provide a concise summary with actionable next steps.', '- Mention any remaining risks, TODOs, or follow-ups.', '- Include file paths, commands, or test names you touched so the operator can verify your work.', ]; return lines.join('\n').trim(); } } const BASELINE_ALLOWED_TOOLS = ['context_snapshot', 'capabilities_overview', 'profile_details']; /** * Wraps a tool runtime to track progress for parallel agent display */ class TrackedToolRuntime { base; callbacks; constructor(base, callbacks) { this.base = base; this.callbacks = callbacks; } listProviderTools() { return this.base.listProviderTools(); } async execute(call, context) { this.callbacks.onToolStart?.(call.name); try { const result = await this.base.execute(call, context); this.callbacks.onToolComplete?.(); return result; } catch (error) { this.callbacks.onToolComplete?.(); throw error; } } registerSuite(suite) { this.base.registerSuite(suite); } unregisterSuite(id) { this.base.unregisterSuite(id); } clearCache() { this.base.clearCache(); } getCacheStats() { return this.base.getCacheStats(); } clearToolHistory() { this.base.clearToolHistory(); } getToolHistory() { return this.base.getToolHistory(); } } class RestrictedToolRuntime { base; allowed; constructor(base, allowed) { this.base = base; this.allowed = allowed; } listProviderTools() { return this.base.listProviderTools().filter((tool) => this.allowed.has(tool.name)); } async execute(call, context) { if (!this.allowed.has(call.name)) { const allowedList = Array.from(this.allowed).join(', '); return `Tool "${call.name}" is not allowed for this agent. Allowed tools: ${allowedList || 'none'}.`; } return this.base.execute(call, context); } registerSuite(suite) { this.base.registerSuite(suite); } unregisterSuite(id) { this.base.unregisterSuite(id); } clearCache() { this.base.clearCache(); } getCacheStats() { return this.base.getCacheStats(); } clearToolHistory() { this.base.clearToolHistory(); } getToolHistory() { return this.base.getToolHistory(); } } function resolveAllowedToolNames(requested, available) { const normalizedAvailable = new Map(); for (const tool of available) { normalizedAvailable.set(normalizeToolName(tool.name), tool.name); } const allowed = new Set(); const missing = []; // Always allow baseline runtime metadata tools for (const name of BASELINE_ALLOWED_TOOLS) { const normalized = normalizeToolName(name); if (normalizedAvailable.has(normalized)) { allowed.add(normalizedAvailable.get(normalized)); } else { allowed.add(name); } } for (const entry of requested) { const normalized = normalizeToolName(entry); if (!normalized) { continue; } const exact = normalizedAvailable.get(normalized); if (exact) { allowed.add(exact); continue; } const fuzzy = Array.from(normalizedAvailable.entries()).find(([candidate]) => candidate.includes(normalized) || normalized.includes(candidate)); if (fuzzy) { allowed.add(fuzzy[1]); continue; } missing.push(entry); } return { allowedNames: allowed, missing }; } function normalizeToolName(value) { return value.replace(/[^a-z0-9]/gi, '').toLowerCase(); } class TaskSnapshotStore { async load(id) { try { const file = join(TASK_STORE_DIR, `${sanitizeId(id)}.json`); const content = await readFile(file, 'utf8'); const parsed = JSON.parse(content); if (!parsed || typeof parsed !== 'object') { return null; } return parsed; } catch { return null; } } async save(snapshot) { await mkdir(TASK_STORE_DIR, { recursive: true }); const normalized = { ...snapshot, history: snapshot.history ?? [], createdAt: snapshot.createdAt, updatedAt: snapshot.updatedAt, }; const file = join(TASK_STORE_DIR, `${sanitizeId(snapshot.id)}.json`); await writeFile(file, JSON.stringify(normalized, null, 2), 'utf8'); } createId() { return `task_${randomUUID()}`; } } function sanitizeId(value) { return value.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'task'; } function extractResponseSections(content) { if (!content) { return { thinking: null, response: '' }; } const thinkingMatch = /<thinking>([\s\S]*?)<\/thinking>/i.exec(content); const responseMatch = /<response>([\s\S]*?)<\/response>/i.exec(content); const thinking = thinkingMatch?.[1]?.trim() ?? null; if (responseMatch?.[1]) { return { thinking, response: responseMatch[1].trim(), }; } if (thinkingMatch?.[0]) { const remaining = content.replace(thinkingMatch[0], '').trim(); return { thinking, response: remaining, }; } return { thinking: null, response: content.trim() }; } function formatDuration(ms) { if (!Number.isFinite(ms)) { return 'unknown duration'; } if (ms < 1000) { return `${ms}ms`; } const seconds = ms / 1000; if (seconds < 60) { return `${seconds.toFixed(1)}s`; } const minutes = Math.floor(seconds / 60); const remaining = Math.round(seconds % 60); return `${minutes}m ${remaining}s`; } function extractUsage(metadata) { return metadata?.usage ?? null; } function formatUsage(usage) { if (!usage) { return ''; } const parts = []; if (typeof usage.inputTokens === 'number') { parts.push(`in ${usage.inputTokens}`); } if (typeof usage.outputTokens === 'number') { parts.push(`out ${usage.outputTokens}`); } if (!parts.length && typeof usage.totalTokens === 'number') { parts.push(`total ${usage.totalTokens}`); } return parts.length ? `Tokens ${parts.join(' / ')}` : ''; } //# sourceMappingURL=taskRunner.js.map