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
JavaScript
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