Add files via upload

This commit is contained in:
pliny authored and GitHub committed 2026-07-02 13:56:02 -04:00
commit 5bec530a89
52 files changed
+33300

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
/**
* T3MP3ST Agent Loop
*
* ReAct (Reason + Act) agent that connects LLM tool calling to Arsenal execution.
* This is the core autonomous loop: the LLM decides which tool to use,
* Arsenal executes it, and results are fed back for the next decision.
*/
import { EventEmitter } from 'eventemitter3';
import type { LLMBackbone } from '../llm/index.js';
import type { Arsenal } from '../arsenal/index.js';
import type {
LLMMessage,
LLMToolDefinition,
LLMToolCall,
ToolResult,
ToolFinding,
Severity,
Target,
Task,
} from '../types/index.js';
// =============================================================================
// TYPES
// =============================================================================
export interface AgentLoopOptions {
/** Max ReAct iterations before forcing a conclusion (default: 15) */
maxIterations?: number;
/** Max total tokens to spend (default: 50000) */
maxTokens?: number;
/** Tool categories to expose (default: all) */
toolCategories?: string[];
/** Whether to include detailed tool output in context (default: true) */
verboseToolOutput?: boolean;
/** Max characters per tool result before truncation (default: 4000) */
maxToolOutputLength?: number;
}
export interface AgentStep {
iteration: number;
type: 'tool_call' | 'observation' | 'reasoning';
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: ToolResult;
content?: string;
timestamp: number;
}
export interface AgentResult {
success: boolean;
/** Final summary from the LLM */
summary: string;
/** All steps taken during execution */
steps: AgentStep[];
/** Findings extracted from tool results */
findings: ToolFinding[];
/** Total iterations used */
iterations: number;
/** Total tokens consumed */
tokensUsed: number;
/** Duration in ms */
durationMs: number;
/** Whether the agent hit the iteration limit */
hitLimit: boolean;
}
export interface AgentEvents {
'agent:step': AgentStep;
'agent:tool_call': { name: string; args: Record<string, unknown> };
'agent:tool_result': { name: string; result: ToolResult };
'agent:thinking': { content: string };
'agent:complete': AgentResult;
'agent:error': { error: Error; step: number };
}
// =============================================================================
// AGENT LOOP
// =============================================================================
export class AgentLoop extends EventEmitter<AgentEvents> {
private llm: LLMBackbone;
private arsenal: Arsenal;
private options: Required<AgentLoopOptions>;
constructor(llm: LLMBackbone, arsenal: Arsenal, options?: AgentLoopOptions) {
super();
this.llm = llm;
this.arsenal = arsenal;
this.options = {
maxIterations: options?.maxIterations ?? 15,
maxTokens: options?.maxTokens ?? 50000,
toolCategories: options?.toolCategories ?? [],
verboseToolOutput: options?.verboseToolOutput ?? true,
maxToolOutputLength: options?.maxToolOutputLength ?? 4000,
};
}
/**
* Run the ReAct loop for a task
*/
async run(
task: Task,
systemPrompt: string,
target?: Target,
sourceContext?: string
): Promise<AgentResult> {
const startTime = Date.now();
const steps: AgentStep[] = [];
const allFindings: ToolFinding[] = [];
let tokensUsed = 0;
let hitLimit = false;
// anti-stall: dedup identical tool calls + detect runs of no-new-findings (ported from the hunter)
const seenCalls = new Map<string, string>();
let noProgress = 0;
// Get tool definitions from Arsenal
const toolDefs = this.arsenal.getToolDefinitions(
this.options.toolCategories.length > 0 ? this.options.toolCategories : undefined
);
// Build initial messages
const messages: LLMMessage[] = [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: this.buildTaskPrompt(task, target, toolDefs, sourceContext) },
];
for (let i = 0; i < this.options.maxIterations; i++) {
try {
// Ask the LLM what to do next
const response = await this.llm.chatWithTools(messages, toolDefs, {
maxTokens: 4096,
temperature: 0.3, // Lower temperature for tool-using tasks
});
tokensUsed += response.usage?.totalTokens || 0;
// If the LLM wants to call tools
if (response.toolCalls?.length) {
// Record the assistant's reasoning
if (response.content) {
const thinkStep: AgentStep = {
iteration: i,
type: 'reasoning',
content: response.content,
timestamp: Date.now(),
};
steps.push(thinkStep);
this.emit('agent:thinking', { content: response.content });
}
// Add assistant message to context (with tool calls)
messages.push({
role: 'assistant',
content: response.content || '',
toolCalls: response.toolCalls,
});
// Execute each tool call
const findingsBefore = allFindings.length;
for (const toolCall of response.toolCalls) {
// ANTI-STALL: a byte-identical repeat is almost always wasted — steer instead of re-running.
const callHash = `${toolCall.name}:${JSON.stringify(toolCall.arguments || {})}`;
let toolStep: AgentStep;
if (seenCalls.has(callHash)) {
const dup: ToolResult = {
success: false,
error: `Duplicate call — you already ran ${toolCall.name} with these exact arguments. ` +
`Prior result: ${seenCalls.get(callHash)}. Do NOT repeat it — change the arguments, pick a different tool, or move to your final debrief.`,
};
toolStep = { iteration: i, type: 'tool_call', toolName: toolCall.name, toolArgs: toolCall.arguments, toolResult: dup, timestamp: Date.now() };
} else {
toolStep = await this.executeTool(toolCall, target, i);
seenCalls.set(callHash, String(toolStep.toolResult?.output || toolStep.toolResult?.error || 'no output').replace(/\s+/g, ' ').slice(0, 160));
}
steps.push(toolStep);
// Collect findings — tag with REAL tool provenance (the output that backs them)
if (toolStep.toolResult?.findings) {
const out = String(toolStep.toolResult.output ?? '').slice(0, 4000);
for (const tf of toolStep.toolResult.findings) {
allFindings.push({ ...tf, provenance: 'tool', toolName: toolCall.name, toolOutput: out || tf.details });
}
}
// Build tool result content
const resultContent = this.formatToolResult(toolStep.toolResult);
// Add tool result to context
messages.push({
role: 'tool',
content: resultContent,
toolCallId: toolCall.id,
name: toolCall.name,
});
}
// ANTI-STALL: after a run of iterations with no new findings, steer (once) toward a new
// vector or a final debrief — don't let the agent grind the budget on a dead approach.
noProgress = allFindings.length > findingsBefore ? 0 : noProgress + 1;
if (noProgress >= 4 && i < this.options.maxIterations - 2) {
messages.push({
role: 'user',
content: '[System: 4 iterations with no new findings. Either pursue a GENUINELY different vector/tool/argument now, or produce your final debrief if the surface is exhausted. Do not keep repeating the current approach.]',
});
noProgress = 0;
}
} else {
// LLM finished reasoning — this is the final answer.
const finalStep: AgentStep = {
iteration: i,
type: 'reasoning',
content: response.content,
timestamp: Date.now(),
};
steps.push(finalStep);
// Parse the structured debrief block so the model's OWN analysis is captured —
// previously only tool-emitted findings were kept and the final report was dropped.
for (const f of this.parseFinalFindings(response.content || '')) {
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
}
const result: AgentResult = {
success: true,
summary: response.content,
steps,
findings: allFindings,
iterations: i + 1,
tokensUsed,
durationMs: Date.now() - startTime,
hitLimit: false,
};
this.emit('agent:complete', result);
return result;
}
// Check token budget
if (tokensUsed >= this.options.maxTokens) {
hitLimit = true;
break;
}
} catch (error) {
this.emit('agent:error', { error: error as Error, step: i });
// On error, try to continue with a reduced context
const errMsg = error instanceof Error ? error.message : String(error);
messages.push({
role: 'user',
content: `[System: Tool execution error: ${errMsg}. Please continue with available information or try a different approach.]`,
});
}
}
// Hit iteration or token limit — ask LLM for a final summary
hitLimit = true;
messages.push({
role: 'user',
content: 'You have reached the maximum number of steps. Please provide a final summary of everything you discovered, including all findings and recommendations.',
});
let summary = 'Agent reached iteration limit without producing a final summary.';
try {
const finalResponse = await this.llm.chat(messages, { maxTokens: 2048 });
summary = finalResponse.content;
tokensUsed += finalResponse.usage?.totalTokens || 0;
// capture the structured debrief from the limit-summary too (same contract as the clean finish)
for (const f of this.parseFinalFindings(summary)) {
if (!allFindings.some((x) => x.title === f.title)) allFindings.push(f);
}
} catch {
// Use what we have
}
const result: AgentResult = {
success: allFindings.length > 0 || steps.some(s => s.toolResult?.success),
summary,
steps,
findings: allFindings,
iterations: this.options.maxIterations,
tokensUsed,
durationMs: Date.now() - startTime,
hitLimit,
};
this.emit('agent:complete', result);
return result;
}
/**
* Execute a single tool call via the Arsenal
*/
private async executeTool(
toolCall: LLMToolCall,
target: Target | undefined,
iteration: number
): Promise<AgentStep> {
this.emit('agent:tool_call', { name: toolCall.name, args: toolCall.arguments });
let toolResult: ToolResult;
try {
toolResult = await this.arsenal.execute(toolCall.name, {
target,
parameters: toolCall.arguments,
});
} catch (err) {
// A bad/hallucinated tool name must NOT crash the loop. Return the callable set so the
// model self-corrects in-place instead of dying or looping on a tool that doesn't exist.
const available = this.arsenal
.getToolDefinitions(this.options.toolCategories.length ? this.options.toolCategories : undefined)
.map((t) => t.name);
toolResult = {
success: false,
error: `Tool "${toolCall.name}" is not available. Callable tools this run: ${available.join(', ') || '(none)'}. ` +
`Use one of these EXACT names — do not invent tools; if none fit, describe the capability you need in prose.`,
};
}
this.emit('agent:tool_result', { name: toolCall.name, result: toolResult });
return {
iteration,
type: 'tool_call',
toolName: toolCall.name,
toolArgs: toolCall.arguments,
toolResult,
timestamp: Date.now(),
};
}
/**
* Build the initial task prompt with target context and prior intel
*/
private buildTaskPrompt(task: Task, target?: Target, tools?: LLMToolDefinition[], sourceContext?: string): string {
const parts: string[] = [];
parts.push(`## MISSION TASK: ${task.name}`);
parts.push(`**Phase**: ${task.phase} | **Priority**: ${task.priority}/10`);
parts.push(`\n### Objective`);
parts.push(task.description);
// White-box source excerpt (security-prioritized) — provided by the large-repo
// analysis pipeline. Optional + backward-compatible: absent/empty keeps the
// original black-box prompt. When present, give the model the real source so it
// can ground findings in code rather than probing blind.
if (sourceContext && sourceContext.trim().length > 0) {
parts.push(`\n### White-box source (security-prioritized excerpt)`);
parts.push(`The following is a security-prioritized excerpt of the target's own source code. Use it to locate and confirm vulnerabilities against the actual implementation — but only report a finding as verified when a tool result backs it.`);
parts.push(sourceContext);
}
if (target) {
parts.push(`\n### Target Intelligence`);
parts.push(`- **Address**: ${target.address}`);
parts.push(`- **Type**: ${target.type} | **Zone**: ${target.zone} | **Status**: ${target.status}`);
// Prior recon intel: discovered services
if (target.services?.length) {
parts.push(`\n**Discovered Services** (from prior recon):`);
for (const svc of target.services) {
const version = svc.version ? ` v${svc.version}` : '';
const banner = svc.banner ? ` [${svc.banner.slice(0, 60)}]` : '';
parts.push(` - ${svc.port}/${svc.protocol}: ${svc.name}${version}${banner}`);
}
parts.push(`Use this intel to focus your efforts. These are confirmed services.`);
}
// Prior vuln intel: discovered vulnerabilities
if (target.vulnerabilities?.length) {
parts.push(`\n**Known Vulnerabilities** (from prior scanning):`);
for (const vuln of target.vulnerabilities.slice(0, 10)) {
const cves = vuln.cve?.length ? ` (${vuln.cve.join(', ')})` : '';
parts.push(` - [${vuln.severity.toUpperCase()}] ${vuln.name}${cves}`);
if (vuln.description) parts.push(` ${vuln.description.slice(0, 200)}`);
}
if (target.vulnerabilities.length > 10) {
parts.push(` ... and ${target.vulnerabilities.length - 10} more`);
}
parts.push(`Use this intel to prioritize your testing.`);
}
// Prior credential intel
if (target.credentials?.length) {
parts.push(`\n**Harvested Credentials** (from prior phases):`);
for (const cred of target.credentials.slice(0, 5)) {
parts.push(` - ${cred.username || 'unknown'}:*** (${cred.type}) via ${cred.source}`);
}
}
}
if (tools?.length) {
parts.push(`\n### Arsenal (${tools.length} tools available)`);
parts.push(`You have the following tools available via function calling. Select the most effective tool for each step of your assessment:`);
// Group tools by rough function
const toolList = tools.map(t => ` - **${t.name}**: ${t.description}`);
parts.push(toolList.join('\n'));
parts.push(`\n### Standing Orders`);
parts.push(`1. Call tools via function calling — do NOT fabricate results`);
parts.push(`2. Analyze each result before deciding the next action`);
parts.push(`3. Report findings immediately as you discover them`);
parts.push(`4. When finished, END your final message with a single fenced \`\`\`json block:\n` +
` {"findings":[{"title":"…","severity":"critical|high|medium|low|info","details":"… cite the tool output that evidences it …","cvss":0.0,"cve":["…"],"remediation":"…"}],"abstained":false}\n` +
` This block is the ONLY finding channel the harness records — anything described only in prose is dropped. Emit [] findings + "abstained":true if you found nothing real.`);
}
return parts.join('\n');
}
/**
* Format a tool result for the LLM context
*/
private formatToolResult(result?: ToolResult): string {
if (!result) return 'No result returned.';
const parts: string[] = [];
parts.push(`Success: ${result.success}`);
if (result.output) {
let output = result.output;
// ALWAYS cap tool output (verbose just gets a larger budget). Previously this was gated on
// !verboseToolOutput, which defaults TRUE — so by default nothing was capped and large tool
// results flooded the context window. Keep HEAD+TAIL: flags/results often land at the end.
const cap = this.options.verboseToolOutput ? this.options.maxToolOutputLength * 4 : this.options.maxToolOutputLength;
if (output.length > cap) {
const head = Math.floor(cap * 0.7);
const tail = cap - head;
output = output.slice(0, head) + `\n… (truncated ${output.length - cap} chars) …\n` + output.slice(-tail);
}
parts.push(output);
}
if (result.error) {
parts.push(`Error: ${result.error}`);
}
if (result.findings?.length) {
parts.push('\nFindings:');
for (const f of result.findings) {
parts.push(` - [${f.severity.toUpperCase()}] ${f.title}: ${f.details}`);
}
}
return parts.join('\n');
}
/**
* Parse the model's final debrief block into structured findings. The operator is told to
* end its last message with a fenced ```json {findings:[…]} block; this is the ONLY prose→data
* channel the harness honors (no substring guessing). Returns [] if there's no valid block.
*/
private parseFinalFindings(content: string): ToolFinding[] {
if (!content) return [];
const SEV = new Set(['critical', 'high', 'medium', 'low', 'info']);
const blocks = [...content.matchAll(/```(?:json)?\s*([\s\S]*?)```/g)].map((m) => m[1]);
const candidates = blocks.length ? blocks.reverse() : [content];
for (const c of candidates) {
const start = c.indexOf('{');
const end = c.lastIndexOf('}');
if (start === -1 || end <= start) continue;
try {
const obj = JSON.parse(c.slice(start, end + 1));
if (!obj || !Array.isArray(obj.findings)) continue;
return obj.findings.filter((f: any) => f && f.title).map((f: any) => ({
title: String(f.title).slice(0, 200),
severity: (SEV.has(String(f.severity).toLowerCase()) ? String(f.severity).toLowerCase() : 'info') as Severity,
details: String(f.details ?? f.evidence ?? f.evidence_ref ?? '').slice(0, 4000),
cvss: typeof f.cvss === 'number' ? f.cvss : undefined,
cve: Array.isArray(f.cve) ? f.cve.map(String) : undefined,
remediation: f.remediation ? String(f.remediation) : undefined,
// Model-asserted in the debrief — NO tool provenance. The gate downgrades these.
provenance: 'model' as const,
}));
} catch { /* try the next candidate block */ }
}
return [];
}
}
// =============================================================================
// FACTORY
// =============================================================================
export function createAgentLoop(
llm: LLMBackbone,
arsenal: Arsenal,
options?: AgentLoopOptions
): AgentLoop {
return new AgentLoop(llm, arsenal, options);
}
/**
* Run a one-shot agent task — convenience function
*/
export async function runAgentTask(
llm: LLMBackbone,
arsenal: Arsenal,
task: Task,
systemPrompt: string,
target?: Target,
options?: AgentLoopOptions
): Promise<AgentResult> {
const agent = createAgentLoop(llm, arsenal, options);
return agent.run(task, systemPrompt, target);
}
+287
View File
@@ -0,0 +1,287 @@
// =============================================================================
// LOCAL AGENT CONNECTORS — "bring your own already-authed agent"
// =============================================================================
// Detect + connect agent CLIs that already live, authenticated, on the operator's machine
// (Claude Code, Codex, Hermes) and drive them headlessly as t3mp3st operators.
//
// SECURITY POSTURE (important):
// - We NEVER read, print, log, or transmit credential contents. Auth is detected purely by the
// PRESENCE of the CLI's own auth artifact (a file path or a macOS keychain item) — never its bytes.
// - We do NOT enter or store any key. The CLIs are already logged in by the user; we only invoke them.
// - A "ping"/"dispatch" spawns the user's own CLI as a child process with a prompt and captures its
// stdout (the model's reply to OUR prompt — not secrets). Everything is local + user-initiated.
// =============================================================================
import { execFile, execFileSync, spawn } from 'child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs';
import { homedir, tmpdir } from 'os';
import { join } from 'path';
// t3mp3st injects its OWN provider keys (from .env) into the server process. If we let those leak into
// a spawned CLI, the CLI uses t3mp3st's key instead of the user's native login → 401. The entire point
// of this feature is "use the agent you already authed", so we strip these before spawning so each CLI
// falls back to its own auth (keychain / ~/.codex/auth.json / ~/.hermes/.env).
const PROVIDER_ENV_TO_STRIP = [
'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_MODEL',
'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_API_BASE', 'OPENAI_ORGANIZATION',
'OPENROUTER_API_KEY',
];
const HOME = homedir();
const expand = (p: string): string => (p.startsWith('~') ? HOME + p.slice(1) : p);
export type LocalAgentId = 'claude' | 'codex' | 'hermes';
interface AgentSpec {
id: LocalAgentId;
label: string;
vendor: string;
bin: string;
blurb: string;
/** how we drive it as a one-shot, non-interactive operator */
invokeHint: string;
versionArgs: string[];
parseVersion: (out: string) => string;
/** any-of: presence ⇒ authed. PRESENCE ONLY — contents are never read. */
authArtifacts: string[];
/** macOS keychain fallback service name */
keychainService?: string;
/** build the argv for a headless one-shot prompt */
oneShot: (prompt: string, model?: string) => string[];
}
const SPECS: AgentSpec[] = [
{
id: 'claude',
label: 'Claude Code',
vendor: 'Anthropic',
bin: 'claude',
blurb: 'Anthropic agentic CLI',
invokeHint: 'claude -p "<prompt>"',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.claude/.credentials.json', '~/.claude.json'],
keychainService: 'Claude Code-credentials',
oneShot: (p, m) => ['-p', p, '--output-format', 'text', ...(m ? ['--model', m] : [])],
},
{
id: 'codex',
label: 'Codex',
vendor: 'OpenAI',
bin: 'codex',
blurb: 'OpenAI Codex CLI',
invokeHint: 'codex exec "<prompt>"',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.codex/auth.json', '~/.config/codex/auth.json'],
oneShot: (p, m) => ['exec', ...(m ? ['-m', m] : []), p],
},
{
id: 'hermes',
label: 'Hermes',
vendor: 'Hermes Agent',
bin: 'hermes',
blurb: 'Hermes Agent — tool-calling AI',
invokeHint: 'hermes -z "<prompt>" (--yolo only if T3MP3ST_HERMES_YOLO=1)',
versionArgs: ['--version'],
parseVersion: (o) => (o.match(/v([\d]+\.[\d]+(\.[\d]+)?)/)?.[1]) || (o.match(/[\d]+\.[\d]+(\.[\d]+)?/) || ['?'])[0],
authArtifacts: ['~/.hermes/.env'],
oneShot: (p, m) => ['-z', p, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(m ? ['-m', m] : [])],
},
];
export function getSpec(id: string): AgentSpec | undefined {
return SPECS.find((s) => s.id === id);
}
/**
* B-06 — Hermes '--yolo' auto-approves EVERY tool call with no confirmation:
* powerful but dangerous (unattended command exec with no gate). It is OFF by
* default; the operator must explicitly opt in with T3MP3ST_HERMES_YOLO=1. Safe
* mode (the default) runs Hermes without the flag so it honors its own approval
* prompts. Applies to both the one-shot backbone call and the chat path.
*/
export function hermesYoloEnabled(): boolean {
return /^(1|true|yes|on)$/i.test((process.env.T3MP3ST_HERMES_YOLO || '').trim());
}
export interface AgentDetection {
id: LocalAgentId;
label: string;
vendor: string;
bin: string;
blurb: string;
invokeHint: string;
installed: boolean;
path?: string;
version?: string;
authed: boolean;
authMethod?: string; // e.g. "file" or "keychain" — never the secret itself
ready: boolean; // installed && authed
}
function resolvePath(bin: string): string | undefined {
try {
return execFileSync('command', ['-v', bin], { shell: '/bin/bash', encoding: 'utf8' }).trim() || undefined;
} catch {
try { return execFileSync('which', [bin], { encoding: 'utf8' }).trim() || undefined; } catch { return undefined; }
}
}
function authState(spec: AgentSpec): { authed: boolean; method?: string } {
for (const a of spec.authArtifacts) {
if (existsSync(expand(a))) return { authed: true, method: 'file' };
}
if (spec.keychainService) {
try {
execFileSync('security', ['find-generic-password', '-s', spec.keychainService], { stdio: 'ignore' });
return { authed: true, method: 'keychain' };
} catch { /* not in keychain */ }
}
return { authed: false };
}
function detectOne(spec: AgentSpec): Promise<AgentDetection> {
const base = {
id: spec.id, label: spec.label, vendor: spec.vendor, bin: spec.bin,
blurb: spec.blurb, invokeHint: spec.invokeHint,
};
return new Promise((resolve) => {
execFile(spec.bin, spec.versionArgs, { timeout: 8000 }, (err, stdout) => {
if (err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
resolve({ ...base, installed: false, authed: false, ready: false });
return;
}
// even a non-zero exit but no ENOENT means the binary exists
const version = spec.parseVersion(String(stdout || ''));
const auth = authState(spec);
resolve({
...base,
installed: true,
path: resolvePath(spec.bin),
version,
authed: auth.authed,
authMethod: auth.method,
ready: auth.authed,
});
});
});
}
/** Detect every known local agent CLI (installed? authed? ready?). No tokens spent. */
export async function detectLocalAgents(): Promise<AgentDetection[]> {
// Test/CI hook: T3MP3ST_DISABLE_LOCAL_AGENTS=1 forces a backbone-less server (skip
// local-agent auto-detection) so key-required / fail-closed paths can be exercised
// deterministically — used by scripts/arsenal-smoke.mjs for a reproducible run.
if (/^(1|true|yes|on)$/i.test((process.env.T3MP3ST_DISABLE_LOCAL_AGENTS || '').trim())) return [];
return Promise.all(SPECS.map(detectOne));
}
export interface AgentRunResult {
ok: boolean;
latencyMs: number;
output: string;
error?: string;
}
/**
* Drive a connected agent with a one-shot headless prompt (real round-trip — SPENDS the agent's quota).
* Used by /ping (liveness proof) and /dispatch (actually using the operator).
*/
export function runLocalAgent(
id: string,
prompt: string,
opts: { model?: string; timeoutMs?: number; maxChars?: number } = {},
): Promise<AgentRunResult> {
const spec = getSpec(id);
const t0 = Date.now();
if (!spec) return Promise.resolve({ ok: false, latencyMs: 0, output: '', error: `unknown agent: ${id}` });
const args = spec.oneShot(prompt, opts.model);
const timeoutMs = opts.timeoutMs ?? 120000;
const maxChars = opts.maxChars ?? 4000;
// child env = ours minus the injected provider keys, so the CLI uses its OWN native login.
const env: NodeJS.ProcessEnv = { ...process.env };
for (const k of PROVIDER_ENV_TO_STRIP) delete env[k];
return new Promise((resolve) => {
// stdin:'ignore' so the agent doesn't stall waiting on piped input (e.g. `claude -p`'s 3s stdin wait).
const child = spawn(spec.bin, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
const finish = (r: AgentRunResult): void => { if (!done) { done = true; clearTimeout(timer); resolve(r); } };
const timer = setTimeout(() => {
try { child.kill('SIGKILL'); } catch { /* noop */ }
finish({ ok: false, latencyMs: Date.now() - t0, output: out.trim().slice(0, maxChars), error: `timed out after ${timeoutMs}ms` });
}, timeoutMs);
child.stdout?.on('data', (d) => { if (out.length < 1_000_000) out += String(d); });
child.stderr?.on('data', (d) => { if (errOut.length < 100_000) errOut += String(d); });
child.on('error', (e) => finish({ ok: false, latencyMs: Date.now() - t0, output: '', error: (e as Error).message }));
child.on('close', (code) => {
const latencyMs = Date.now() - t0;
const output = out.trim().slice(0, maxChars);
if (code === 0) finish({ ok: true, latencyMs, output });
else finish({ ok: false, latencyMs, output, error: (errOut.trim() || `exited with code ${code}`).slice(0, 300) });
});
});
}
/** A cheap liveness probe — asks the agent to echo a token. SPENDS a tiny bit of the agent's quota. */
export function pingLocalAgent(id: string, prompt?: string, timeoutMs?: number): Promise<AgentRunResult> {
return runLocalAgent(id, prompt || 'Reply with exactly the single word: PONG', {
timeoutMs: timeoutMs ?? 90000,
maxChars: 400,
});
}
/**
* Drive a connected agent as the LLM BACKEND for the mission/operator flow — a long-prompt one-shot
* that returns ONLY the model's reply text (no CLI banner). Per-agent invocation: claude/codex feed
* the prompt via STDIN (robust for long planning prompts), codex uses --output-last-message for a
* clean reply, hermes takes the prompt as an arg. Provider keys are stripped so each CLI uses its own
* login (no API key needed). Throws on non-zero exit / timeout so the LLMBackbone retry/fallback fires.
*/
export function localAgentChat(id: string, prompt: string, opts: { model?: string; timeoutMs?: number } = {}): Promise<string> {
const spec = getSpec(id);
if (!spec) return Promise.reject(new Error(`unknown local agent: ${id}`));
const env: NodeJS.ProcessEnv = { ...process.env };
for (const k of PROVIDER_ENV_TO_STRIP) delete env[k];
const model = opts.model && opts.model !== 'codex-default' && opts.model !== id ? opts.model : undefined;
const timeoutMs = opts.timeoutMs ?? 240000;
let args: string[];
let viaStdin = true;
let outFile: string | null = null;
let workDir: string | null = null;
if (id === 'claude') {
args = ['-p', '--output-format', 'text', ...(model ? ['--model', model] : [])];
} else if (id === 'codex') {
workDir = mkdtempSync(join(tmpdir(), 't3mp3st-codexllm-'));
outFile = join(workDir, 'reply.txt');
args = ['exec', '--skip-git-repo-check', '--color', 'never', '--sandbox', 'read-only', '--output-last-message', outFile, ...(model ? ['-m', model] : [])];
} else { // hermes — takes the prompt as an arg
args = ['-z', prompt, ...(hermesYoloEnabled() ? ['--yolo'] : []), ...(model ? ['-m', model] : [])];
viaStdin = false;
}
const cleanup = () => { if (workDir) { try { rmSync(workDir, { recursive: true, force: true }); } catch { /* noop */ } } };
return new Promise((resolve, reject) => {
const child = spawn(spec.bin, args, { env, stdio: [viaStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'] });
let out = '';
let errOut = '';
let done = false;
const finish = (fn: () => void) => { if (!done) { done = true; clearTimeout(timer); cleanup(); fn(); } };
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* noop */ } finish(() => reject(new Error(`${id} timed out after ${timeoutMs}ms`))); }, timeoutMs);
child.stdout?.on('data', (d) => { if (out.length < 8_000_000) out += String(d); });
child.stderr?.on('data', (d) => { if (errOut.length < 200_000) errOut += String(d); });
child.on('error', (e) => finish(() => reject(e)));
child.on('close', (code) => {
let content = out.trim();
if (outFile) { try { content = (readFileSync(outFile, 'utf8').trim() || content); } catch { /* fall back to stdout */ } }
finish(() => {
if (code === 0 && content) resolve(content);
else reject(new Error((errOut.trim() || content || `exited with code ${code}`).slice(0, 800)));
});
});
if (viaStdin && child.stdin) { child.stdin.write(prompt); child.stdin.end(); }
});
}