diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0cc4058..beb62c5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -140,6 +140,19 @@ export interface Message { export type RunStatus = 'running' | 'completed' | 'failed' | 'cancelled'; +/** + * Why a run stopped. `completed` is the only success value: a run cut short by a + * budget or a runaway loop is not an ordinary answer, so telemetry, evaluation + * and the UI branch on this instead of treating every terminal run as success. + */ +export type StopReason = + | 'completed' + | 'budget_exhausted' + | 'loop_detected' + | 'retry_storm' + | 'cancelled' + | 'error'; + /** One agent execution inside a session. */ export interface Run { id: string; @@ -150,6 +163,10 @@ export interface Run { completedAt?: number; answer?: string; error?: ApiError; + /** Machine-readable reason the run stopped; absent on records written before #17. */ + stopReason?: StopReason; + /** The numbers behind a non-success stop (which budget ran out, which loop fired). */ + stopDetail?: Record; } /** Live tool call state, streamed through agent events. */ diff --git a/packages/shared/src/kernel/agent-kernel-budget.test.ts b/packages/shared/src/kernel/agent-kernel-budget.test.ts new file mode 100644 index 0000000..1f49974 --- /dev/null +++ b/packages/shared/src/kernel/agent-kernel-budget.test.ts @@ -0,0 +1,173 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import type { + AgentEvent, + AgentEventPayload, + AgentRunInput, + AgentRuntime, + ApiResult, + RuntimeSession, + ToolDefinition, +} from '@finagent/core'; +import { AgentKernel } from './agent-kernel.ts'; + +let dir = ''; +let clock = 1000; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'finagent-kernel-budget-')); + clock = 1000; +}); + +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +class ScriptedRuntime implements AgentRuntime { + cancelCalls: Array<{ sessionId: string; runId: string }> = []; + + constructor(private readonly script: (input: AgentRunInput) => AsyncIterable) {} + + async getTools(): Promise> { + return { ok: true, data: [] }; + } + + async ensureSession(session: { id: string }): Promise { + return { sessionId: session.id, status: 'active' }; + } + + async *run(input: AgentRunInput): AsyncIterable { + yield* this.script(input); + } + + async cancel(input: { sessionId: string; runId: string }): Promise { + this.cancelCalls.push(input); + } + + async dispose(): Promise {} +} + +function event( + sessionId: string, + runId: string, + type: AgentEvent['type'], + payload?: AgentEventPayload, + sequence = 1 +): AgentEvent { + return { + id: `evt-${type}-${sequence}`, + sessionId, + runId, + type, + timestamp: clock, + sequence, + ...(payload === undefined ? {} : { payload }), + } as unknown as AgentEvent; +} + +/** Two model calls, then a normal completion — over-budget only when a budget says so. */ +function twoStepScript(answer: string) { + return async function* (input: AgentRunInput) { + yield event(input.sessionId, input.runId, 'message_completed', { answer }, 1); + yield event(input.sessionId, input.runId, 'message_completed', { answer }, 2); + yield event(input.sessionId, input.runId, 'run_completed', { answer, toolCalls: [] }, 3); + }; +} + +describe('AgentKernel run budgets (#17)', () => { + it('forwards budget options to the run loop so a real run can be stopped', async () => { + const runtime = new ScriptedRuntime(twoStepScript('partial')); + const kernel = new AgentKernel({ + storageDir: dir, + piSessionDir: join(dir, 'pi-sessions'), + runtime, + now: () => clock, + budgets: { defaults: { modelCalls: 1 } }, + }); + const session = await kernel.sessions.createSession('Budget'); + + const run = await kernel.runs.startRun(session.id, 'q'); + await waitFor(async () => !kernel.runs.isRunning()); + + expect(runtime.cancelCalls).toEqual([{ sessionId: session.id, runId: run.id }]); + expect(await kernel.sessions.getRun(session.id, run.id)).toMatchObject({ + status: 'cancelled', + answer: 'partial', + stopReason: 'budget_exhausted', + stopDetail: { key: 'modelCalls', limit: 1, used: 1 }, + }); + }); + + it('runs the same script to completion when no budget is configured', async () => { + const runtime = new ScriptedRuntime(twoStepScript('done')); + const kernel = new AgentKernel({ + storageDir: dir, + piSessionDir: join(dir, 'pi-sessions'), + runtime, + now: () => clock, + }); + const session = await kernel.sessions.createSession('Plain'); + + const run = await kernel.runs.startRun(session.id, 'q'); + await waitFor(async () => !kernel.runs.isRunning()); + + expect(runtime.cancelCalls).toEqual([]); + expect(await kernel.sessions.getRun(session.id, run.id)).toMatchObject({ + status: 'completed', + answer: 'done', + }); + }); + + it('forwards runaway detector thresholds for repeated tool calls', async () => { + const runtime = new ScriptedRuntime(async function* (input) { + for (let i = 1; i <= 3; i += 1) { + yield event( + input.sessionId, + input.runId, + 'tool_completed', + { + toolCall: { + id: `t${i}`, + toolName: 'get_quote', + args: { symbol: 'AAPL.US' }, + startedAt: clock, + completedAt: clock, + status: 'success', + result: {}, + }, + }, + i + ); + } + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'done', toolCalls: [] }, 9); + }); + const kernel = new AgentKernel({ + storageDir: dir, + piSessionDir: join(dir, 'pi-sessions'), + runtime, + now: () => clock, + runaway: { repeatedToolCallThreshold: 2 }, + }); + const session = await kernel.sessions.createSession('Loop'); + + const run = await kernel.runs.startRun(session.id, 'q'); + await waitFor(async () => !kernel.runs.isRunning()); + + expect(await kernel.sessions.getRun(session.id, run.id)).toMatchObject({ + status: 'cancelled', + stopReason: 'loop_detected', + }); + }); +}); + +async function waitFor(predicate: () => Promise, timeoutMs = 2000) { + const started = Date.now(); + while (!(await predicate())) { + if (Date.now() - started > timeoutMs) { + throw new Error('waitFor timed out'); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } +} diff --git a/packages/shared/src/kernel/agent-kernel.ts b/packages/shared/src/kernel/agent-kernel.ts index 8e6d4c2..a6e7eb8 100644 --- a/packages/shared/src/kernel/agent-kernel.ts +++ b/packages/shared/src/kernel/agent-kernel.ts @@ -12,6 +12,8 @@ import { createCodeError } from '../agent/errors.ts'; import type { PiRpcClientOptions } from '../agent/pi-rpc-client.ts'; import { SessionManager } from './session-manager.ts'; import { RunManager } from './run-manager.ts'; +import type { ResolveBudgetInput } from './run-budget.ts'; +import type { RunawayPolicy } from './runaway-detector.ts'; export type AgentProvider = 'local' | 'pi-runtime'; @@ -33,6 +35,15 @@ export interface AgentKernelOptions { /** Skill hub used for progressive skill loading in the runtime prompt. */ skillHub?: SkillHub; now?: () => number; + /** + * Budget defaults and the system ceiling every run obeys (#17). Without it + * runs are unbudgeted; a run may still tighten its own limits at startRun. + */ + budgets?: ResolveBudgetInput; + /** Tool-name patterns (`*` wildcard) whose `query` argument feeds the search-loop detector. */ + searchTools?: string[]; + /** Runaway detector thresholds; unset fields fall back to `defaultRunawayPolicy()`. */ + runaway?: Partial; } /** @@ -68,6 +79,9 @@ export class AgentKernel { runs: new RunRepository(store), runtime: this.runtime, now, + budgets: options.budgets, + searchTools: options.searchTools, + runaway: options.runaway, }); } diff --git a/packages/shared/src/kernel/run-budget.ts b/packages/shared/src/kernel/run-budget.ts index 6e5faf3..5af2b32 100644 --- a/packages/shared/src/kernel/run-budget.ts +++ b/packages/shared/src/kernel/run-budget.ts @@ -8,6 +8,8 @@ * instead of reporting it as an ordinary success. */ +import type { StopReason } from '@finagent/core'; + /** Every dimension a run budget can constrain. */ export type BudgetKey = | 'wallClockMs' @@ -54,16 +56,11 @@ export interface BudgetExhaustion { } /** - * Why a run stopped. Machine-readable so traces, run summaries and evaluation - * can branch on it; `completed` is the only success value. + * Why a run stopped. Owned by the core protocol because the UI, telemetry and + * evaluation read it off the persisted run record; re-exported here so callers + * that only work with budgets keep importing it from one place. */ -export type StopReason = - | 'completed' - | 'budget_exhausted' - | 'loop_detected' - | 'retry_storm' - | 'cancelled' - | 'error'; +export type { StopReason }; /** A run outcome paired with the detail behind a non-success reason. */ export interface RunStop { diff --git a/packages/shared/src/kernel/run-manager.test.ts b/packages/shared/src/kernel/run-manager.test.ts index 6d13af6..e34d121 100644 --- a/packages/shared/src/kernel/run-manager.test.ts +++ b/packages/shared/src/kernel/run-manager.test.ts @@ -16,7 +16,7 @@ import { MessageRepository } from '../storage/message-repository.ts'; import { RunRepository } from '../storage/run-repository.ts'; import { SessionRepository } from '../storage/session-repository.ts'; import { SessionManager } from './session-manager.ts'; -import { RunManager } from './run-manager.ts'; +import { RunManager, type RunManagerOptions } from './run-manager.ts'; let dir = ''; let clock = 1000; @@ -30,7 +30,10 @@ afterEach(async () => { await rm(dir, { recursive: true, force: true }); }); -function makeKernel(script: (input: AgentRunInput) => AsyncIterable) { +function makeKernel( + script: (input: AgentRunInput) => AsyncIterable, + extra: Partial> = {} +) { const runtime = new ScriptedRuntime(script); const store = new JsonFileStore(dir); const sessions = new SessionManager({ @@ -45,6 +48,7 @@ function makeKernel(script: (input: AgentRunInput) => AsyncIterable) runs: new RunRepository(store), runtime, now: () => clock, + ...extra, }); return { runtime, sessions, runs, store }; } @@ -282,6 +286,159 @@ describe('RunManager', () => { }); }); +describe('RunManager budgets and runaway detection (#17)', () => { + const toolEvent = ( + input: AgentRunInput, + toolName: string, + args: Record, + sequence: number + ) => + event( + input.sessionId, + input.runId, + 'tool_completed', + { + toolCall: { + id: `t${sequence}`, + toolName, + args, + startedAt: clock, + completedAt: clock, + status: 'success', + result: {}, + }, + }, + sequence + ); + + it('stops a run that exceeds its model-call budget and keeps the partial answer', async () => { + const { sessions, runs, runtime } = makeKernel( + async function* (input) { + yield event(input.sessionId, input.runId, 'message_completed', { answer: 'partial one' }, 1); + yield event(input.sessionId, input.runId, 'message_completed', { answer: 'partial two' }, 2); + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'finished', toolCalls: [] }, 3); + }, + { budgets: { defaults: { modelCalls: 2 } } } + ); + const session = await sessions.createSession('Budget'); + + const run = await runs.startRun(session.id, 'long task'); + await waitFor(async () => !runs.isRunning()); + + expect(runtime.cancelCalls).toEqual([{ sessionId: session.id, runId: run.id }]); + const persisted = await sessions.getRun(session.id, run.id); + expect(persisted).toMatchObject({ + status: 'cancelled', + answer: 'partial two', + stopReason: 'budget_exhausted', + stopDetail: { key: 'modelCalls', limit: 2, used: 2 }, + }); + const messages = await sessions.listMessages(session.id); + expect(messages[1]).toMatchObject({ role: 'assistant', content: 'partial two' }); + }); + + it('lets a run tighten its own budget but clamps it to the system ceiling', async () => { + const script = async function* (input: AgentRunInput) { + yield event(input.sessionId, input.runId, 'message_completed', { answer: 'one' }, 1); + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'one', toolCalls: [] }, 2); + }; + const { sessions, runs, runtime } = makeKernel(script, { + budgets: { defaults: { modelCalls: 10 }, ceiling: { modelCalls: 5 } }, + }); + const session = await sessions.createSession('Override'); + + const tightened = await runs.startRun(session.id, 'q', undefined, undefined, { modelCalls: 1 }); + await waitFor(async () => !runs.isRunning()); + expect(runtime.cancelCalls).toEqual([{ sessionId: session.id, runId: tightened.id }]); + expect(await sessions.getRun(session.id, tightened.id)).toMatchObject({ + stopReason: 'budget_exhausted', + stopDetail: { key: 'modelCalls', limit: 1, used: 1 }, + }); + + const clamped = await runs.startRun(session.id, 'q', undefined, undefined, { modelCalls: 100 }); + await waitFor(async () => !runs.isRunning()); + expect(runtime.cancelCalls).toHaveLength(1); + expect(await sessions.getRun(session.id, clamped.id)).toMatchObject({ status: 'completed' }); + }); + + it('stops on a repeated identical tool call and reports loop_detected', async () => { + const { sessions, runs, runtime } = makeKernel( + async function* (input) { + for (let i = 1; i <= 3; i += 1) { + yield toolEvent(input, 'get_quote', { symbol: 'AAPL.US' }, i); + } + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'done', toolCalls: [] }, 9); + }, + { runaway: { repeatedToolCallThreshold: 2 } } + ); + const session = await sessions.createSession('Loop'); + + const run = await runs.startRun(session.id, 'q'); + await waitFor(async () => !runs.isRunning()); + + expect(runtime.cancelCalls).toHaveLength(1); + const persisted = await sessions.getRun(session.id, run.id); + expect(persisted).toMatchObject({ status: 'cancelled', stopReason: 'loop_detected' }); + expect(persisted?.stopDetail).toMatchObject({ + signal: 'repeated_tool_call', + tool: 'get_quote', + count: 2, + }); + }); + + it('stops on a repeated search query once search tools are configured', async () => { + const { sessions, runs } = makeKernel( + async function* (input) { + yield toolEvent(input, 'web_search', { query: 'nvidia earnings 2026' }, 1); + yield toolEvent(input, 'web_search', { query: 'nvidia earnings 2026 q2' }, 2); + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'done', toolCalls: [] }, 9); + }, + { searchTools: ['web_search*'], runaway: { repeatedSearchQueryThreshold: 2 } } + ); + const session = await sessions.createSession('Search'); + + const run = await runs.startRun(session.id, 'q'); + await waitFor(async () => !runs.isRunning()); + + const persisted = await sessions.getRun(session.id, run.id); + expect(persisted).toMatchObject({ stopReason: 'loop_detected' }); + expect(persisted?.stopDetail).toMatchObject({ signal: 'repeated_search_query', count: 2 }); + }); + + it('stops on the wall-clock budget measured with the injected clock', async () => { + const { sessions, runs } = makeKernel( + async function* (input) { + clock += 5_000; + yield event(input.sessionId, input.runId, 'message_completed', { answer: 'slow step' }, 1); + yield event(input.sessionId, input.runId, 'run_completed', { answer: 'done', toolCalls: [] }, 2); + }, + { budgets: { defaults: { wallClockMs: 1_000 } } } + ); + const session = await sessions.createSession('Clock'); + + const run = await runs.startRun(session.id, 'q'); + await waitFor(async () => !runs.isRunning()); + + expect(await sessions.getRun(session.id, run.id)).toMatchObject({ + stopReason: 'budget_exhausted', + stopDetail: { key: 'wallClockMs', limit: 1_000, used: 5_000 }, + }); + }); + + it('leaves a run untouched when neither budgets nor detectors are configured', async () => { + const { sessions, runs, runtime } = makeKernel(completedScript('Answer')); + const session = await sessions.createSession('Plain'); + + const run = await runs.startRun(session.id, 'q'); + await waitFor(async () => !runs.isRunning()); + + expect(runtime.cancelCalls).toEqual([]); + const persisted = await sessions.getRun(session.id, run.id); + expect(persisted).toMatchObject({ status: 'completed', answer: 'Answer' }); + expect(persisted?.stopReason).toBeUndefined(); + }); +}); + async function waitFor(predicate: () => Promise, timeoutMs = 2000) { const started = Date.now(); while (!(await predicate())) { diff --git a/packages/shared/src/kernel/run-manager.ts b/packages/shared/src/kernel/run-manager.ts index 91621eb..b552cde 100644 --- a/packages/shared/src/kernel/run-manager.ts +++ b/packages/shared/src/kernel/run-manager.ts @@ -15,18 +15,56 @@ import type { import type { RunRepository } from '../storage/index.ts'; import type { SessionManager } from './session-manager.ts'; import { createCodeError, isRuntimeInfraCode, toApiError } from '../agent/errors.ts'; +import { + addUsage, + budgetStop, + checkBudget, + createUsage, + resolveBudget, + type ResolveBudgetInput, + type RunBudgetLimits, + type RunBudgetUsage, + type RunStop, +} from './run-budget.ts'; +import { + createRunawayState, + observeSearchQuery, + observeToolCall, + runawayStop, + toolPatternMatches, + type RunawayPolicy, + type RunawayState, +} from './runaway-detector.ts'; export interface RunManagerOptions { sessions: SessionManager; runs: RunRepository; runtime: AgentRuntime; now?: () => number; + /** + * Budget defaults and the system ceiling applied to every run (#17). A run + * may override the defaults but never the ceiling; with no `budgets` option a + * run is unbudgeted and behaves exactly as before. + */ + budgets?: ResolveBudgetInput; + /** Tool-name patterns (`*` wildcard) whose `query` argument feeds the search-loop detector. */ + searchTools?: string[]; + /** Runaway detector thresholds; unset fields fall back to `defaultRunawayPolicy()`. */ + runaway?: Partial; } interface ActiveRun { sessionId: string; runId: string; cancelRequested: boolean; + /** When the run started, for the wall-clock budget. */ + startedAt: number; + /** Effective limits for this run, after defaults, overrides and the ceiling. */ + limits: RunBudgetLimits; + usage: RunBudgetUsage; + runaway: RunawayState; + /** Set when a budget or a runaway detector stopped the run. */ + stop?: RunStop; } /** @@ -43,6 +81,9 @@ export class RunManager { private readonly runs: RunRepository; private readonly runtime: AgentRuntime; private readonly now: () => number; + private readonly budgetInput: ResolveBudgetInput; + private readonly searchToolPatterns: readonly string[]; + private readonly runawayPolicy: Partial; private readonly listeners = new Set<(event: AgentEvent) => void>(); private activeRun: ActiveRun | null = null; @@ -51,6 +92,9 @@ export class RunManager { this.runs = options.runs; this.runtime = options.runtime; this.now = options.now ?? Date.now; + this.budgetInput = options.budgets ?? {}; + this.searchToolPatterns = options.searchTools ?? []; + this.runawayPolicy = options.runaway ?? {}; } subscribe(listener: (event: AgentEvent) => void): () => void { @@ -68,11 +112,21 @@ export class RunManager { return this.activeRun?.sessionId === sessionId; } + /** + * Start a run and drive it to a terminal state. + * @param sessionId - the session the run belongs to. + * @param content - the user message. + * @param workspaceContext - optional workspace context for the runtime. + * @param locale - optional UI locale. + * @param budgetOverrides - per-run budget overrides, clamped by the system ceiling. + * @returns the persisted running run. + */ async startRun( sessionId: string, content: string, workspaceContext?: WorkspaceContext, - locale?: SupportedLocale + locale?: SupportedLocale, + budgetOverrides?: RunBudgetLimits ): Promise { const text = content.trim(); if (!text) { @@ -109,7 +163,20 @@ export class RunManager { await this.sessions.appendMessage(sessionId, userMessage); await this.sessions.updateSession(sessionId, { status: 'running' }); - this.activeRun = { sessionId, runId: run.id, cancelRequested: false }; + const { limits } = resolveBudget({ + defaults: this.budgetInput.defaults, + ceiling: this.budgetInput.ceiling, + overrides: budgetOverrides, + }); + this.activeRun = { + sessionId, + runId: run.id, + cancelRequested: false, + startedAt: now, + limits, + usage: createUsage(), + runaway: createRunawayState(), + }; this.emit({ id: randomUUID(), sessionId, @@ -172,6 +239,10 @@ export class RunManager { answer = event.payload.answer; sawTerminal = true; } + + // Budgets and detectors are evaluated after the event is accounted for, + // so a run that stops keeps the evidence it had already produced. + if (await this.applyBudget(event)) break; } } catch (error) { failure = toApiError(error); @@ -179,6 +250,11 @@ export class RunManager { const active = this.activeRun; const cancelRequested = active?.cancelRequested ?? false; + const stop = active?.stop; + if (stop !== undefined) { + run.stopReason = stop.stopReason; + run.stopDetail = stop.detail; + } const now = this.now(); const cancelled = Boolean(cancelRequested || (failure && failure.code === 'RUN_CANCELLED')); @@ -225,7 +301,9 @@ export class RunManager { // stream failed before producing one (e.g. runtime spawn failure), so the // UI always observes a terminal event. if (!sawTerminal) { - if (cancelled) { + if (stop !== undefined) { + this.emitRunEvent(run, 'run_failed', { error: stopError(stop) }); + } else if (cancelled) { this.emitRunEvent(run, 'run_failed', { error: { code: 'RUN_CANCELLED', message: 'Run cancelled by user.' }, }); @@ -237,6 +315,71 @@ export class RunManager { } } + /** + * Account for one runtime event and decide whether the run must stop. A stop + * requests cancellation, so the caller stops consuming events and the run + * settles as `cancelled` — never as an ordinary success — carrying its partial + * answer, its tool calls and the machine-readable reason it stopped. + * @param event - the event that was just broadcast. + * @returns true when the run was stopped and cancellation was requested. + */ + private async applyBudget(event: AgentEvent): Promise { + const active = this.activeRun; + if (!active) return false; + + let usage = active.usage; + if (event.type === 'message_completed') usage = addUsage(usage, { modelCalls: 1 }); + if (event.type === 'tool_completed') usage = addUsage(usage, { toolCalls: 1 }); + + const searchQuery = + event.type === 'tool_completed' ? this.searchQueryOf(event.payload.toolCall) : undefined; + if (searchQuery !== undefined) usage = addUsage(usage, { searchIterations: 1 }); + + // Wall-clock is absolute: recomputed from the run's start on every event. + active.usage = { ...usage, wallClockMs: this.now() - active.startedAt }; + + let stop: RunStop | undefined; + const exhaustion = checkBudget(active.limits, active.usage); + if (exhaustion !== undefined) { + stop = budgetStop(exhaustion); + } else if (event.type === 'tool_completed') { + const call = observeToolCall( + active.runaway, + { tool: event.payload.toolCall.toolName, args: event.payload.toolCall.args }, + this.runawayPolicy + ); + active.runaway = call.state; + if (call.detection.detected) stop = runawayStop(call.detection); + } + + if (stop === undefined && searchQuery !== undefined) { + const search = observeSearchQuery(active.runaway, searchQuery, this.runawayPolicy); + active.runaway = search.state; + if (search.detection.detected) stop = runawayStop(search.detection); + } + + if (stop === undefined) return false; + + active.stop = stop; + active.cancelRequested = true; + await this.runtime.cancel({ sessionId: active.sessionId, runId: active.runId }); + return true; + } + + /** + * The query a search-tool call carries, when this run tracks search loops. + * @param toolCall - the completed tool call. + * @returns the query text, or undefined when the call is not a tracked search. + */ + private searchQueryOf(toolCall: ToolCall): string | undefined { + if (this.searchToolPatterns.length === 0) return undefined; + if (!this.searchToolPatterns.some((pattern) => toolPatternMatches(pattern, toolCall.toolName))) { + return undefined; + } + const query = toolCall.args.query ?? toolCall.args.q; + return typeof query === 'string' && query.trim() !== '' ? query : undefined; + } + private emitRunEvent(run: Run, type: AgentEvent['type'], payload?: AgentEventPayload): void { // Callers pair `type` with the matching payload shape. const event = { @@ -258,6 +401,24 @@ export class RunManager { } } +/** + * The error a budget or runaway stop reports to the UI. The code is stable so + * the renderer can tell a budget stop from a user cancel, and the detail keeps + * the numbers (which budget, which loop) attached to the message. + * @param stop - the stop recorded on the run. + * @returns an ApiError describing why the run stopped. + */ +function stopError(stop: RunStop): ApiError { + const code = + stop.stopReason === 'budget_exhausted' + ? 'BUDGET_EXHAUSTED' + : stop.stopReason === 'retry_storm' + ? 'RETRY_STORM' + : 'LOOP_DETECTED'; + const detail = stop.detail === undefined ? '' : ` ${JSON.stringify(stop.detail)}`; + return { code, message: `Run stopped: ${stop.stopReason}.${detail}` }; +} + function toRecord(toolCall: ToolCall): ToolCallRecord { return { id: toolCall.id, diff --git a/packages/shared/src/kernel/runaway-detector.ts b/packages/shared/src/kernel/runaway-detector.ts index 1e0e9a6..fd48584 100644 --- a/packages/shared/src/kernel/runaway-detector.ts +++ b/packages/shared/src/kernel/runaway-detector.ts @@ -171,6 +171,18 @@ function wildcardToRegExp(pattern: string): RegExp { return new RegExp(`^${escaped.replaceAll('*', '.*')}$`); } +/** + * Whether a tool name matches one `*`-wildcard pattern. Exported because the + * runtime uses the same matching to decide which tool calls carry a search + * query, so both sides agree on what "the same tool scope" means. + * @param pattern - a tool-name pattern. + * @param tool - the tool name to test. + * @returns true when the name matches. + */ +export function toolPatternMatches(pattern: string, tool: string): boolean { + return wildcardToRegExp(pattern).test(tool); +} + /** * Whether a tool participates in loop detection. Untracked tools are * transparent: they neither count nor reset the chain, so polling through an diff --git a/scripts/eval/budget-smoke.ts b/scripts/eval/budget-smoke.ts new file mode 100644 index 0000000..4123c82 --- /dev/null +++ b/scripts/eval/budget-smoke.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env bun +// Run-budget smoke test (#17): a REAL provider run through the production agent +// path (AgentKernel -> RunManager -> Pi runtime), with budgets small enough that +// the run must be stopped by the guard rather than by the model finishing. +// +// source ~/.folio-e2e.env +// bun scripts/eval/budget-smoke.ts --prompt "..." --max-model-calls 2 +// bun scripts/eval/budget-smoke.ts --prompt "..." --loop-threshold 2 --max-tool-calls 20 +// +// Exit codes: 0 = the run stopped with the expected stop reason and kept a +// partial result; 1 = it did not (or the runtime failed), so CI/agents can gate +// on real evidence instead of reading prose. +// +// Credentials: ANTHROPIC_API_KEY (or the keys Pi resolves itself). Nothing is +// printed but counts, names and the partial answer. +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentEvent } from '../../packages/core/src/index.ts'; +import { AgentKernel } from '../../packages/shared/src/kernel/agent-kernel.ts'; + +interface CliOptions { + prompt: string; + provider: string; + model?: string; + maxModelCalls?: number; + maxToolCalls?: number; + wallClockMs?: number; + loopThreshold?: number; + searchTools: string[]; + expect: string; + timeoutMs: number; + healthTimeoutMs: number; + waitMs: number; +} + +const USAGE = `Usage: bun scripts/eval/budget-smoke.ts [flags] + + --prompt prompt to run (required) + --provider provider for setModel (default: anthropic) + --model model id for setModel (default: runtime default) + --max-model-calls model-call budget for the run + --max-tool-calls tool-call budget for the run + --wall-clock-ms wall-clock budget for the run + --loop-threshold repeated identical tool calls that stop the run + --search-tools tool-name patterns whose query feeds the search detector + --expect expected stop reason (default: any non-completed stop) + --timeout-ms runtime request timeout (default: 120000) + --health-timeout-ms pi startup health check budget (default: 180000; the + first run may download the pi runtime) + --wait-ms how long to wait for the run to settle before + cancelling it (default: 600000)`; + +function parseFlags(argv: string[]): CliOptions { + const options: CliOptions = { + prompt: '', + provider: 'anthropic', + searchTools: [], + expect: '', + timeoutMs: 120_000, + healthTimeoutMs: 180_000, + waitMs: 600_000, + }; + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i]; + const value = argv[i + 1]; + const takeNumber = () => { + i += 1; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`${flag} needs a positive number`); + return parsed; + }; + switch (flag) { + case '--prompt': + i += 1; + options.prompt = value ?? ''; + break; + case '--provider': + i += 1; + options.provider = value ?? 'anthropic'; + break; + case '--model': + i += 1; + options.model = value; + break; + case '--max-model-calls': + options.maxModelCalls = takeNumber(); + break; + case '--max-tool-calls': + options.maxToolCalls = takeNumber(); + break; + case '--wall-clock-ms': + options.wallClockMs = takeNumber(); + break; + case '--loop-threshold': + options.loopThreshold = takeNumber(); + break; + case '--search-tools': + i += 1; + options.searchTools = (value ?? '').split(',').filter((entry) => entry !== ''); + break; + case '--expect': + i += 1; + options.expect = value ?? ''; + break; + case '--timeout-ms': + options.timeoutMs = takeNumber(); + break; + case '--health-timeout-ms': + options.healthTimeoutMs = takeNumber(); + break; + case '--wait-ms': + options.waitMs = takeNumber(); + break; + case '--help': + case '-h': + console.log(USAGE); + process.exit(0); + break; + default: + throw new Error(`unknown flag ${flag}\n\n${USAGE}`); + } + } + if (options.prompt.trim() === '') throw new Error(`--prompt is required\n\n${USAGE}`); + return options; +} + +async function main(): Promise { + const options = parseFlags(process.argv.slice(2)); + if (!process.env.ANTHROPIC_API_KEY && !process.env.FINAGENT_PROVIDER_OVERRIDES) { + console.error('no LLM credential: set ANTHROPIC_API_KEY or FINAGENT_PROVIDER_OVERRIDES'); + return 1; + } + + const runtimeDir = await mkdtemp(join(tmpdir(), 'folio-budget-smoke-')); + const kernel = new AgentKernel({ + provider: 'pi-runtime', + storageDir: join(runtimeDir, 'store'), + piSessionDir: join(runtimeDir, 'pi-sessions'), + // Budgets are what this smoke test exists to exercise; the ceiling keeps a + // mistyped flag from turning into a runaway real-provider bill. + budgets: { + defaults: { + modelCalls: options.maxModelCalls, + toolCalls: options.maxToolCalls, + wallClockMs: options.wallClockMs, + }, + ceiling: { modelCalls: 25, toolCalls: 25, wallClockMs: 15 * 60_000 }, + }, + runaway: options.loopThreshold === undefined ? {} : { repeatedToolCallThreshold: options.loopThreshold }, + searchTools: options.searchTools, + rpc: { + cwd: process.cwd(), + extensions: [], + env: () => process.env, + requestTimeoutMs: options.timeoutMs, + // The first run may have to fetch the pi runtime, which takes far longer + // than the 5s default health budget. + healthTimeoutMs: options.healthTimeoutMs, + }, + }); + + const llm = kernel.getLlmApi(); + if (llm && options.model !== undefined) { + const state = await llm.setModel(options.provider, options.model); + console.log(`model: ${state.model?.provider ?? '?'}/${state.model?.id ?? '?'}`); + } + + const toolNames: string[] = []; + let modelCalls = 0; + const unsubscribe = kernel.runs.subscribe((event: AgentEvent) => { + if (event.type === 'message_completed') modelCalls += 1; + if (event.type === 'tool_completed') { + toolNames.push(event.payload.toolCall.toolName); + console.log(` tool: ${event.payload.toolCall.toolName} ${JSON.stringify(event.payload.toolCall.args).slice(0, 120)}`); + } + }); + + const session = await kernel.sessions.createSession('budget-smoke'); + const startedAt = Date.now(); + const run = await kernel.runs.startRun(session.id, options.prompt); + console.log(`run ${run.id} started in ${runtimeDir}`); + + const deadline = Date.now() + options.waitMs; + while (kernel.runs.isRunning() && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + const waitedOut = kernel.runs.isRunning(); + if (waitedOut) { + // Never leave a real-provider run burning: cancel before reporting. + console.error(`wait timed out after ${options.waitMs}ms; cancelling the run`); + await kernel.runs.cancelRun(session.id, run.id); + const cancelDeadline = Date.now() + 30_000; + while (kernel.runs.isRunning() && Date.now() < cancelDeadline) { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + const elapsedMs = Date.now() - startedAt; + unsubscribe(); + + const persisted = await kernel.sessions.getRun(session.id, run.id); + const messages = await kernel.sessions.listMessages(session.id); + await kernel.dispose(); + + const summary = { + status: persisted?.status, + stopReason: persisted?.stopReason, + stopDetail: persisted?.stopDetail, + modelCalls, + toolCalls: toolNames, + elapsedMs, + partialAnswer: (persisted?.answer ?? '').slice(0, 400), + persistedMessages: messages.map((message) => ({ + role: message.role, + chars: message.content.length, + toolCalls: message.toolCalls?.length ?? 0, + })), + runtimeDir, + }; + console.log('\n=== summary ==='); + console.log(JSON.stringify(summary, null, 2)); + + const stopped = persisted?.stopReason !== undefined && persisted.stopReason !== 'completed'; + const expected = options.expect === '' ? stopped : persisted?.stopReason === options.expect; + if (persisted?.status === 'failed') { + console.error(`FAIL: run failed: ${persisted.error?.code} ${persisted.error?.message}`); + return 1; + } + if (!expected) { + console.error( + `FAIL: expected stop reason ${options.expect || '(any non-completed)'}, got ${String(persisted?.stopReason)}` + ); + return 1; + } + // The stop reason is the contract under test. Whether any partial evidence + // exists depends on how far the real model got before the guard fired, so it + // is reported rather than asserted; a run that produced nothing is possible. + const evidence = (persisted?.answer ?? '') !== '' || toolNames.length > 0; + console.log( + `PASS: stopped with ${String(persisted?.stopReason)} after ${modelCalls} model calls, ` + + `${toolNames.length} tool calls, partial evidence: ${evidence ? 'yes' : 'none produced before the stop'}` + ); + return 0; +} + +main() + .then((code) => process.exit(code)) + .catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + });