@inkeep/openbolts-engine-runtime
v0.0.5
Published
Declarative engine orchestration for OpenBolts. `runEngine({ tasks, model })` is the sole public entry point — it creates the database, task list, MCP server, and adapter internally. The bridge is an implementation detail; consumers declare what they want
Maintainers
Keywords
Readme
@inkeep/openbolts-engine-runtime
Declarative engine orchestration for OpenBolts. runEngine({ tasks, model }) is the sole public entry point — it creates the database, task list, MCP server, and adapter internally. The bridge is an implementation detail; consumers declare what they want and the system derives the rest.
Installation
bun add @inkeep/openbolts-engine-runtime @inkeep/openbolts-core @inkeep/openbolts-mcp @inkeep/openbolts-adapter-vercel-aiInstall the provider package for your model:
bun add @ai-sdk/anthropic # Anthropic (Claude)
bun add @ai-sdk/openai # OpenAI (GPT-4o)Quick Start
import { anthropic } from '@ai-sdk/anthropic';
import { runEngine } from '@inkeep/openbolts-engine-runtime';
// Minimal — 2 required fields
const handle = runEngine({
tasks: [{ title: 'Build auth module' }],
model: anthropic('claude-sonnet-4-20250514'),
});
const result = await handle.run();
console.log(result.status); // 'completed' | 'escalated' | 'paused' | 'failed'
console.log(result.telemetry.totalSteps); // total LLM steps across invocations
console.log(result.telemetry.invocationCount); // number of session invocations
await handle.cleanup();With hooks and limits
const handle = runEngine({
tasks: [
{ id: 'spec', title: 'Write spec' },
{ id: 'impl', title: 'Implement', dependsOn: ['spec'] },
{ id: 'test', title: 'Write tests', dependsOn: ['impl'] },
],
model: anthropic('claude-sonnet-4-20250514'),
hooks: {
onIterationEnd: async (outcome, ctx) => {
// Re-run tests when all tasks complete; reset on failure to iterate again.
if (outcome.reason === 'all_complete') {
const tests = await runTestSuite();
if (!tests.passed) {
return { action: 'retry', resetTasks: ['impl'], context: tests.error };
}
return 'pause';
}
if (outcome.reason === 'budget_exhausted') return 'pause';
// Non-transient crashes (auth errors, deterministic bugs) escalate —
// retrying them wastes budget with zero chance of success.
if (outcome.reason === 'crashed' && outcome.error?.isTransient === false) {
return { action: 'escalate', reason: `Non-transient: ${outcome.error.message}` };
}
return 'retry'; // backoff applied automatically on retry-worthy crashes
},
},
limits: { maxBudgetUsd: 5, maxIterations: 5 },
});
const result = await handle.run();Worker path (escape hatch — no LLM)
When you want full programmatic control without an LLM session loop, pass a worker function instead of model. runEngine discriminates the config type and routes to the same hooks and delegation infrastructure.
const handle = runEngine({
tasks: [{ id: 'build', title: 'Build feature' }],
worker: async (ctx) => {
const output = await runBuild();
await ctx.complete(ctx.tasks[0].id, output);
},
});ctx provides typed async methods mirroring the MCP tool surface: complete, block, delegate, checkDelegations, requestInput, spawn, note. See WorkerContext in the SDK docs.
Abort / timeout
const handle = runEngine({ tasks: [{ title: 'Long task' }], model: myModel });
// Cancel after 5 seconds
const runPromise = handle.run();
setTimeout(() => handle.abort(), 5000);
const result = await runPromise;
// result.status === 'paused', result.statusMessage === 'Aborted'
// Or pass an external AbortSignal
const result2 = await handle.run({ signal: AbortSignal.timeout(30_000) });API Reference
runEngine(config): EngineRuntimeHandle
Creates and returns a handle for executing the engine loop. The config is a discriminated union — provide model for the agent (LLM) path or worker for the programmatic path. Use isAgentConfig() / isWorkerConfig() to narrow.
AgentConfig (agent / LLM path)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| tasks | TaskDeclaration[] | Yes | Tasks with optional id, dependsOn, delegate, resultSchema |
| model | LanguageModel \| ModelFactory | Yes | AI SDK model or factory |
| instructions | string \| SystemModelMessage \| SystemModelMessage[] | No | System instructions (supports cache control) |
| tools | Record<string, Tool \| MCPClientConfig \| StdioMcpConfig> | No | Additional tools (inline or MCP) beyond built-in engine tools |
| skills | string[] | No | Skill names to discover and compose |
| skillDirectories | string[] | No | Directories to search for skills |
| stopWhen | StopCondition \| StopCondition[] | No | Inner tool-loop stop conditions |
| hooks | { onIterationEnd?, onEscalation?, onTaskCompleted?, onTaskBlocked?, onTaskYielded? } | No | Iteration control + per-event observation hooks |
| limits | { maxBudgetUsd?, maxBlockRetries?, maxIterations?, retryDelay? } | No | Engine limits + retry backoff config |
| providerOptions | OpenBoltsProviderOptions | No | Provider-specific options (e.g., Claude Code settingSources, thinking) |
| allowedTools, disallowedTools | string[] | No | Cross-provider tool scoping |
| output | Output.object({ schema }) | No | Structured output from the agent |
| elicitations | Record<string, { schema, description? }> | No | Pre-declared input request types |
| db | DatabaseClient | No | Override default in-memory PGlite |
WorkerConfig (programmatic path)
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| tasks | TaskDeclaration[] | Yes | Tasks with optional id, dependsOn, delegate |
| worker | (ctx: WorkerContext) => Promise<void> | Yes | Worker function — calls ctx.complete / ctx.block / ctx.delegate / etc. |
| hooks, limits, db | — | No | Same shared fields as AgentConfig |
EngineRuntimeHandle
interface EngineRuntimeHandle<TResult = unknown> {
readonly runId: RunId;
readonly journalId: JournalId;
readonly listId: ListId;
run(opts?: { signal?: AbortSignal }): Promise<RuntimeResult<TResult>>;
abort(): void;
/**
* Resolve one or more pending child-escalations programmatically.
* Returns `true` if at least one escalation was resolved.
*/
resolveEscalation(resolutions: EscalationResolution[]): boolean;
cleanup(): Promise<void>;
[Symbol.asyncDispose](): Promise<void>;
}await using handle = runEngine(config) is supported — cleanup fires automatically when the scope exits.
RuntimeResult
Discriminated union on status (MCP Tasks-aligned enum; 4 terminal states on the sync path):
| Variant | Fields | Description |
|---------|--------|-------------|
| completed | results: Map<string, TResult>, output?: TOutput, telemetry, children | All tasks completed; onIterationEnd returned pause on the all_complete outcome |
| escalated | statusMessage: string, telemetry, children | Engine escalated (e.g., onIterationEnd returned escalate, non-transient crash) |
| paused | statusMessage: string, telemetry, children | Engine paused (abort, budget exhausted, aborted during retry delay) |
| failed | statusMessage: string, error: Error, telemetry, children | Unrecoverable error |
'working', 'input_required', and 'cancelled' are additional statuses on the async EngineRunResult (MCP lifecycle tools) but are not reachable via await handle.run() — see packages/docs/content/engine.mdx for the full terminal-states discussion.
RuntimeTelemetry
interface RuntimeTelemetry {
totalSteps: number;
invocationCount: number;
totalInputTokens: number;
totalOutputTokens: number;
totalReasoningTokens: number;
/** Cumulative wait time in ms across all retry backoffs in this run. */
totalRetryDelayMs?: number;
/** Count of D32 silent session-resume fallbacks observed (Claude Code only). */
sessionResumeFallbacks?: number;
}Utility exports
| Export | Description |
|--------|-------------|
| buildPrompt(item, config) | Assembles task prompt with journal context and dependency status |
| buildSessionPrompt(config) | Session-scoped prompt with task overview + journal + delegation status |
| mapExitReasonToAdapterResult(result) | Maps adapter exit reasons to engine action types |
| discoverSkills(names, dirs?) | Loads skill metadata from directories |
| composeInstructions(skills) | Composes skill instructions into system messages |
| flattenInstructions(instructions) | Normalizes instruction variants to SystemModelMessage[] |
| resolveConversationState(...) | Resolves prior conversation state from journal |
Recent additions (unify-engine-resume)
- Envelope tools: parents update child-engine work definitions via
add-child-tasks/send-child-messageMCP tools (orapplyEnvelopeChangedirectly). Parent-envelope principle (D2): envelope operations never write execution state. Seepackages/docs/content/engine.mdxandpackages/docs/content/mcp.mdx. - Unified resume primitive:
runEngine(config, { resume: { runId, listId, journalId, strategy } })(internal; use viaretry-engine-runoradd-child-tasks) re-enters an existing run on durable state. - Durable journal entries in
DEFAULT_RENDER_INCLUDE_TYPES:system:parent_directive,system:run_failed,system:session_resume_failed. Surfaced to resumed agents viarenderEntries. handle.resolveEscalation(resolutions)— top-level API for programmatic escalation resolution (complement to theprovide-inputMCP tool).
Test-infrastructure dependencies (unify-engine-resume)
The unit-tier recipe tests use @faker-js/faker, randexp, and zod-schema-faker to synthesize deterministic tool-call payloads from Zod schemas without booting a real LLM. Rationale and usage conventions live in src/__tests__/helpers/TESTING_CONVENTIONS.md. These are devDependencies only — they do not ship in the published package.
Breaking changes (unify-engine-resume)
Seven breaking changes landed together; migrate each independently. Full details and migration snippets in packages/docs/content/engine.mdx#breaking-changes.
| # | Change | Migration hint |
|---|--------|----------------|
| 1 | ContinuationStrategy union: { type: 'continue' } removed, folded into { type: 'resume', fork? } | Replace the string literal 'continue' with 'resume'; pass fork: true to preserve the old 'fork' branch. |
| 2 | ConversationState.origin enum tightened to 'fresh' \| 'resume' \| 'fork' | Writes of legacy 'continue' throw; reads coerce to 'resume' with a warn. Drop the 'continue' branch in any switch(origin). |
| 3 | canComplete gate ctx rename — ctx.resumeChild → ctx.applyEnvelopeChange(runId, { message?, tasks? }, opts?) | TypeScript flags the rename at compile time. JS/widened-to-any consumers must audit. |
| 4 | MCP tool resume-delegation-child removed → add-child-tasks + send-child-message | Split the old combined call into two verb-specific calls; each writes its own system:parent_directive entry that composes chronologically. |
| 5 | VercelAiAdapter default maxRetries → 0 (was SDK default 2) via new vercelAiMaxRetries field | Restore legacy via new VercelAiAdapter({ model, vercelAiMaxRetries: 2 }). Rationale: session-manager (Layer 4) owns the retry policy. |
| 6 | isTransientError treats api_error (5xx / 529 / "server error") as transient (was non-retryable) | The engine now retries with backoff up to limits.maxIterations instead of escalating on iteration 1. Opt out via onIterationEnd → escalate for category === 'api_error'. |
| 7 | limits.retryDelay.jitter defaults to 'full' (was deterministic) | Tests asserting exact delay values must set jitter: 'none'. Multi-engine deployments benefit automatically from thundering-herd protection. |
Architecture
runEngine(config) → EngineRuntimeHandle
│
├─ Discriminate: model present → declarative. adapter present → escape hatch.
│
├─ Declarative path (session manager):
│ 1. Create db (or use provided)
│ 2. Create TaskList from tasks + dependsOn/sequential
│ 3. Auto-generate actor IDs, relations
│ 4. Discover skills (factory-time)
│ 5. Detect provider (Claude Code vs standard API)
│ 6. Create sessionScope MCP server
│ 7. Create internal bridge adapter
│ 8. Run session manager loop:
│ while(true) {
│ buildSessionPrompt → bridgeAdapter → analyzeOutcome
│ → onIterationEnd → action (retry/escalate/pause)
│ }
│
└─ handle.run({ signal? }) → Promise<RuntimeResult>
handle.abort()
handle.cleanup()