@runravel/ravel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +202 -0
  3. package/README.md +68 -0
  4. package/bin/ravel.mjs +6 -0
  5. package/examples/acme/agent.md +14 -0
  6. package/examples/acme/growth/agent.md +19 -0
  7. package/examples/acme/growth/copywriter/agent.md +13 -0
  8. package/examples/acme/growth/copywriter/tools.json +11 -0
  9. package/examples/acme/growth/researcher/agent.md +11 -0
  10. package/examples/acme/processes/prospect-outreach.process.md +24 -0
  11. package/examples/harbor/agent.md +31 -0
  12. package/examples/harbor/processes/new-client-quote.process.md +31 -0
  13. package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
  14. package/examples/harbor/sales/agent.md +29 -0
  15. package/examples/harbor/sales/sdr/agent.md +24 -0
  16. package/examples/harbor/sales/solutions/agent.md +29 -0
  17. package/examples/harbor/sales/solutions/tools.json +16 -0
  18. package/examples/harbor/support/agent.md +31 -0
  19. package/examples/harbor/support/kb-writer/agent.md +25 -0
  20. package/examples/harbor/support/kb-writer/tools.json +10 -0
  21. package/examples/harbor/support/qa-reviewer/agent.md +23 -0
  22. package/examples/harbor/support/tools.json +16 -0
  23. package/examples/harbor/support/triage/agent.md +24 -0
  24. package/examples/harbor/support/triage/tools.json +10 -0
  25. package/examples/plugin-demo/agent.md +15 -0
  26. package/examples/plugin-demo/processes/jot.process.md +21 -0
  27. package/examples/plugin-demo/scribe/agent.md +15 -0
  28. package/examples/plugin-demo/scribe/plugin.ts +53 -0
  29. package/examples/plugin-demo/scribe/tools.json +9 -0
  30. package/package.json +65 -0
  31. package/src/cli/main.ts +428 -0
  32. package/src/control-plane/registry.ts +294 -0
  33. package/src/control-plane/watcher.ts +132 -0
  34. package/src/domain/ids.ts +17 -0
  35. package/src/domain/pricing.ts +51 -0
  36. package/src/domain/types.ts +168 -0
  37. package/src/index.ts +35 -0
  38. package/src/memory/genericTools.ts +276 -0
  39. package/src/memory/kv.ts +52 -0
  40. package/src/memory/store.ts +76 -0
  41. package/src/messaging/bus.ts +143 -0
  42. package/src/messaging/inbox.ts +119 -0
  43. package/src/orchestrator/orchestrator.ts +270 -0
  44. package/src/orchestrator/planner.ts +218 -0
  45. package/src/platform/app.ts +287 -0
  46. package/src/plugins/loader.ts +86 -0
  47. package/src/plugins/server.ts +41 -0
  48. package/src/plugins/types.ts +96 -0
  49. package/src/runtime/agent.ts +488 -0
  50. package/src/runtime/engine.ts +84 -0
  51. package/src/runtime/fakeEngine.ts +75 -0
  52. package/src/runtime/lifecycle.ts +85 -0
  53. package/src/runtime/officeActions.ts +58 -0
  54. package/src/runtime/officeTools.ts +42 -0
  55. package/src/runtime/sdkEngine.ts +213 -0
  56. package/src/schemas/agent.ts +47 -0
  57. package/src/schemas/common.ts +52 -0
  58. package/src/schemas/frontmatter.ts +36 -0
  59. package/src/schemas/process.ts +55 -0
  60. package/src/schemas/tools.ts +83 -0
  61. package/src/secrets/store.ts +131 -0
  62. package/src/service/scheduler.ts +377 -0
  63. package/src/service/server.ts +554 -0
  64. package/src/trust/approval.ts +180 -0
  65. package/src/trust/audit.ts +195 -0
  66. package/src/trust/budget.ts +64 -0
  67. package/src/trust/emittingAudit.ts +32 -0
  68. package/src/trust/executor.ts +73 -0
  69. package/src/trust/killswitch.ts +73 -0
  70. package/src/trust/observability.ts +97 -0
  71. package/src/trust/proposals.ts +114 -0
  72. package/ui/dist/assets/index-C6CxDaPS.js +44 -0
  73. package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
  74. package/ui/dist/index.html +13 -0
@@ -0,0 +1,488 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import type { RegistryNode } from "../control-plane/registry.js";
4
+ import { resolveModel, type ModelTier, type Budget } from "../schemas/common.js";
5
+ import { policyForTool, SAFE_AUTO_TOOLS, type ApprovalBroker } from "../trust/approval.js";
6
+ import type { ToolsConfig } from "../schemas/tools.js";
7
+ import type { MemoryStore } from "../memory/store.js";
8
+ import type { PluginRegistry } from "../plugins/loader.js";
9
+ import type { SecretStore } from "../secrets/store.js";
10
+ import { BudgetMeter } from "../trust/budget.js";
11
+ import type { KillSwitch } from "../trust/killswitch.js";
12
+ import type { AuditSink } from "../trust/audit.js";
13
+ import { systemClock, type Clock } from "../domain/ids.js";
14
+ import { emptyUsage, type TaskContract, type TaskResult, type TaskStatus, type Usage } from "../domain/types.js";
15
+ import type { AgentEngine, EngineRequest } from "./engine.js";
16
+
17
+ export type AgentState = "idle" | "running" | "draining" | "killed";
18
+
19
+ /**
20
+ * Live, in-memory snapshot of what an agent is doing right now — surfaced to the
21
+ * operator console so a run isn't an opaque "running". Reset to {} when idle.
22
+ */
23
+ export interface AgentActivity {
24
+ /** Goal of the task currently in flight (or chat/planning). */
25
+ taskGoal?: string;
26
+ /** The tool the agent most recently asked to use. */
27
+ currentTool?: string;
28
+ /** True while a tool call sits in the human-approval queue. */
29
+ waitingOnApproval?: boolean;
30
+ /** ISO time the current activity began. */
31
+ since?: string;
32
+ }
33
+
34
+ /** Built-in SDK tools we recognize, so authors can opt into them by name in tools.json. */
35
+ const BUILTIN_TOOLS = [
36
+ "Bash",
37
+ "Read",
38
+ "Write",
39
+ "Edit",
40
+ "Glob",
41
+ "Grep",
42
+ "WebSearch",
43
+ "WebFetch",
44
+ "TodoWrite",
45
+ "NotebookEdit",
46
+ "Task",
47
+ ] as const;
48
+ /** Cheap read-only set so an agent can always read its staged files / working dir. */
49
+ const READONLY_TOOLS = SAFE_AUTO_TOOLS;
50
+ /**
51
+ * Internal SDK turn caps — a *backstop* against a runaway agent loop, not a cost
52
+ * leash. Real cost is bounded by the token/$ budget (which aborts via onUsage)
53
+ * and prompt caching, so these are set generously: a worker that reads inputs,
54
+ * writes a couple of artifacts, and verifies needs well more than a handful of
55
+ * turns, and cutting it off mid-work surfaces as a spurious `error_max_turns`.
56
+ */
57
+ const WORKER_MAX_TURNS = 45;
58
+ // Planning is mostly pure reasoning, but a manager may make a few read calls
59
+ // before emitting its plan. Keep enough headroom that it never runs out mid-plan
60
+ // (the old cap of 6 let a manager exhaust turns on inspection and emit nothing).
61
+ const PLANNING_MAX_TURNS = 12;
62
+ // Direct chat with a worker can ask it to actually do its job (search, dedup,
63
+ // record), not just answer — so it needs a worker-class budget, not the planner's.
64
+ // At the planner cap (12) a tool-heavy agent exhausts turns and returns
65
+ // error_max_turns mid-workflow.
66
+ const CHAT_MAX_TURNS = WORKER_MAX_TURNS;
67
+
68
+ /**
69
+ * Decide which built-in SDK tools to expose for a call. Heavy tools (Bash,
70
+ * Edit, WebSearch, …) are off unless the agent explicitly grants them by name
71
+ * in tools.json — this is the primary lever on per-call token cost.
72
+ */
73
+ function builtinToolsFor(tools: ToolsConfig, allowRead: boolean): string[] {
74
+ // `builtins: "none"` withholds the read-only file tools entirely — for
75
+ // memory-only agents that have no files and would otherwise loop on Grep/Read.
76
+ const seedRead = allowRead && tools.builtins !== "none";
77
+ const set = new Set<string>(seedRead ? READONLY_TOOLS : []);
78
+ for (const grant of tools.tools) {
79
+ if (grant.policy === "deny") continue;
80
+ const match = BUILTIN_TOOLS.find((b) => b.toLowerCase() === grant.name.toLowerCase());
81
+ if (match) set.add(match);
82
+ }
83
+ return [...set];
84
+ }
85
+
86
+ /** Raw outcome of one engine execution, before task/chat-specific framing. */
87
+ export interface EngineOutcome {
88
+ text: string;
89
+ usage: Usage;
90
+ stopReason: "done" | "aborted" | "error";
91
+ budgetExceeded: boolean;
92
+ /** Proposals queued (deferred approvals) during this execution. */
93
+ pendingProposalIds: string[];
94
+ error?: string;
95
+ }
96
+
97
+ export interface AgentRuntimeDeps {
98
+ engine: AgentEngine;
99
+ audit: AuditSink;
100
+ approvals: ApprovalBroker;
101
+ killSwitch: KillSwitch;
102
+ /** Root under which each agent gets a persistent working directory. */
103
+ workdirRoot: string;
104
+ /** Shared memory store, exposed to agents as memory-backed MCP tools (optional in tests). */
105
+ memory?: MemoryStore;
106
+ /** Loaded team plugins; an agent gets its own team's plugin tools (optional in tests). */
107
+ plugins?: PluginRegistry;
108
+ /** Per-node credential resolver; an agent gets only its own `.env` chain (optional in tests). */
109
+ secrets?: SecretStore;
110
+ clock?: Clock;
111
+ }
112
+
113
+ /**
114
+ * The team-memory scope a node shares: a manager and its DIRECT reports resolve
115
+ * to the same key (the manager's id). Note: only two levels share — a nested
116
+ * sub-manager forms its own scope. Keep teams flat if they must share memory.
117
+ */
118
+ export function managerScopeOf(node: RegistryNode): string {
119
+ return node.childIds.length > 0 ? node.id : (node.parentId ?? node.id);
120
+ }
121
+
122
+ function sanitize(nodeId: string): string {
123
+ return nodeId === "" ? "_root" : nodeId.replace(/\//g, "__");
124
+ }
125
+
126
+ /** Render a task contract into a prompt for the worker. */
127
+ function renderTaskPrompt(contract: TaskContract, privateDirName: string | null): string {
128
+ const inputs = Object.entries(contract.inputs)
129
+ .map(([k, v]) => `- ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
130
+ .join("\n");
131
+ const fileNames = (contract.files ?? []).map((f) => path.basename(f));
132
+ const workspaceSection = privateDirName
133
+ ? [
134
+ ``,
135
+ `## Workspace (your working directory)`,
136
+ `- \`shared/\` — shared with the whole team for THIS run. Read inputs here and`,
137
+ ` write your outputs and any handoffs for the next agent here, with clear`,
138
+ ` filenames. Earlier steps' files are already in \`shared/\`.`,
139
+ `- \`${privateDirName}/\` — your private scratch for this run.`,
140
+ ]
141
+ : fileNames.length
142
+ ? ["", `## Source files (in your working directory)`, ...fileNames.map((n) => `- ${n}`)]
143
+ : [];
144
+ return [
145
+ `You have been assigned a task.`,
146
+ ``,
147
+ `## Goal`,
148
+ contract.goal,
149
+ ``,
150
+ `## Inputs`,
151
+ inputs || "(none)",
152
+ ...workspaceSection,
153
+ ``,
154
+ `## Definition of done`,
155
+ contract.definitionOfDone,
156
+ ``,
157
+ `Work toward the definition of done within your budget. When finished,`,
158
+ `respond with a concise summary of what you did and any follow-ups.`,
159
+ `If you cannot complete it, explain what is blocking you so it can be escalated.`,
160
+ ].join("\n");
161
+ }
162
+
163
+ /**
164
+ * Runs one agent (one registry node). Agents are event-driven: a runtime sits
165
+ * idle (and spends nothing) until `runTask` / `chat` is called. Each run is
166
+ * bounded by a budget and a kill-switch-backed abort signal, and every tool
167
+ * call passes through the approval gate.
168
+ */
169
+ export class AgentRuntime {
170
+ state: AgentState = "idle";
171
+ /** What this agent is doing right now (for the live console). */
172
+ activity: AgentActivity = {};
173
+ private readonly clock: Clock;
174
+ private _node: RegistryNode;
175
+ private pendingNode: RegistryNode | null = null;
176
+
177
+ constructor(
178
+ node: RegistryNode,
179
+ private readonly deps: AgentRuntimeDeps,
180
+ /** Default tier when the agent doesn't pin a model (manager=opus, worker=sonnet). */
181
+ private readonly defaultTier: ModelTier = "sonnet",
182
+ ) {
183
+ this._node = node;
184
+ this.clock = deps.clock ?? systemClock;
185
+ }
186
+
187
+ get node(): RegistryNode {
188
+ return this._node;
189
+ }
190
+
191
+ /**
192
+ * Swap in a new compiled config for this agent. If the agent is mid-run, the
193
+ * current run finishes on its original config and the new one applies once it
194
+ * goes idle — in-flight work is never disrupted by an edit.
195
+ */
196
+ updateNode(node: RegistryNode): void {
197
+ if (this.state === "running" || this.state === "draining") {
198
+ this.pendingNode = node;
199
+ } else {
200
+ this._node = node;
201
+ }
202
+ }
203
+
204
+ get workdir(): string {
205
+ return path.join(this.deps.workdirRoot, sanitize(this.node.id));
206
+ }
207
+
208
+ /**
209
+ * Resolve the working directory for a task. With a per-run workspace, cwd is
210
+ * the run root and the agent gets a shared `shared/` folder plus a private
211
+ * `<nodeId>/` subfolder; otherwise it falls back to its persistent dir.
212
+ */
213
+ private async resolveWorkspace(
214
+ contract: TaskContract,
215
+ ): Promise<{ cwd: string; privateDirName: string | null }> {
216
+ if (contract.workspaceRoot) {
217
+ const privateDirName = sanitize(this.node.id);
218
+ await fs.mkdir(path.join(contract.workspaceRoot, "shared"), { recursive: true });
219
+ await fs.mkdir(path.join(contract.workspaceRoot, privateDirName), { recursive: true });
220
+ return { cwd: contract.workspaceRoot, privateDirName };
221
+ }
222
+ await fs.mkdir(this.workdir, { recursive: true });
223
+ return { cwd: this.workdir, privateDirName: null };
224
+ }
225
+
226
+ /**
227
+ * Copy any source files attached to the contract into this agent's working
228
+ * directory (under their basename), so the agent's file tools can read them.
229
+ * Files are the host's; the agent only ever sees the staged copy.
230
+ */
231
+ private async stageFiles(contract: TaskContract, cwd: string): Promise<void> {
232
+ if (!contract.files?.length) return;
233
+ // In a workspace run, staged files belong in shared/ so every agent sees them.
234
+ const destDir = contract.workspaceRoot ? path.join(cwd, "shared") : cwd;
235
+ await fs.mkdir(destDir, { recursive: true });
236
+ for (const src of contract.files) {
237
+ const dest = path.join(destDir, path.basename(src));
238
+ try {
239
+ await fs.copyFile(src, dest);
240
+ await this.deps.audit.append("task.file_staged", {
241
+ nodeId: this.node.id,
242
+ runId: contract.runId,
243
+ data: { file: path.basename(src) },
244
+ });
245
+ } catch (err) {
246
+ await this.deps.audit.append("task.file_stage_failed", {
247
+ nodeId: this.node.id,
248
+ runId: contract.runId,
249
+ data: { file: src, error: err instanceof Error ? err.message : String(err) },
250
+ });
251
+ }
252
+ }
253
+ }
254
+
255
+ /** Execute a single task contract to completion / budget / escalation. */
256
+ async runTask(contract: TaskContract): Promise<TaskResult> {
257
+ if (this.deps.killSwitch.isKilled(this.node.id)) {
258
+ return this.abortedResult(contract, "agent is under an active kill");
259
+ }
260
+ if (this.state === "draining" || this.state === "killed") {
261
+ return this.abortedResult(contract, `agent is ${this.state}`);
262
+ }
263
+
264
+ await this.deps.audit.append("task.started", {
265
+ nodeId: this.node.id,
266
+ runId: contract.runId,
267
+ data: { contractId: contract.id, goal: contract.goal },
268
+ });
269
+ this.activity = { taskGoal: contract.goal, since: this.clock.iso() };
270
+
271
+ const { cwd, privateDirName } = await this.resolveWorkspace(contract);
272
+ await this.stageFiles(contract, cwd);
273
+ const engine = await this.runEngine(renderTaskPrompt(contract, privateDirName), contract.budget, contract.runId, {
274
+ // Workers may read their staged files; heavy tools only if explicitly granted.
275
+ builtinTools: builtinToolsFor(this.node.tools, true),
276
+ maxTurns: WORKER_MAX_TURNS,
277
+ cwd,
278
+ });
279
+ const result: TaskResult = {
280
+ contractId: contract.id,
281
+ status: this.classify(engine.stopReason, engine.budgetExceeded, engine.pendingProposalIds.length),
282
+ summary: engine.text || engine.error || "",
283
+ artifacts: {},
284
+ followUps: [],
285
+ usage: engine.usage,
286
+ ...(engine.pendingProposalIds.length ? { pendingProposalIds: engine.pendingProposalIds } : {}),
287
+ };
288
+
289
+ await this.deps.audit.append("task.finished", {
290
+ nodeId: this.node.id,
291
+ runId: contract.runId,
292
+ // Carry the agent's own summary (its reasoning/decision) so the console shows
293
+ // WHAT it concluded, not just which tools it called. Capped to keep the log lean.
294
+ data: {
295
+ contractId: contract.id,
296
+ status: result.status,
297
+ usage: result.usage,
298
+ summary: result.summary.slice(0, 2000),
299
+ },
300
+ });
301
+ return result;
302
+ }
303
+
304
+ /**
305
+ * Run a raw prompt against this agent and return the text. Used by the
306
+ * orchestrator's planner (the manager decomposing a playbook) and by owner
307
+ * chat. Same budget, approval, kill-switch, and working-dir guarantees as a
308
+ * task — only the framing differs.
309
+ */
310
+ async ask(
311
+ prompt: string,
312
+ opts: { budget?: Budget; runId?: string; allowRead?: boolean; maxTurns?: number } = {},
313
+ ): Promise<EngineOutcome> {
314
+ if (this.deps.killSwitch.isKilled(this.node.id) || this.state === "killed" || this.state === "draining") {
315
+ return { text: "", usage: emptyUsage(), stopReason: "aborted", budgetExceeded: false, pendingProposalIds: [] };
316
+ }
317
+ // Planning is pure reasoning → no tools by default (allowRead=false). Chat
318
+ // opts in to read-only so it can inspect files. Heavy tools only if granted.
319
+ await fs.mkdir(this.workdir, { recursive: true });
320
+ return this.runEngine(prompt, opts.budget ?? {}, opts.runId, {
321
+ builtinTools: builtinToolsFor(this.node.tools, opts.allowRead ?? false),
322
+ maxTurns: opts.maxTurns ?? PLANNING_MAX_TURNS,
323
+ cwd: this.workdir,
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Owner-facing chat with this specific agent. Same guarantees as a task; the
329
+ * exchange is recorded to the audit trail so direct human↔agent conversations
330
+ * are as auditable as orchestrated work.
331
+ */
332
+ async chat(message: string, opts: { budget?: Budget } = {}): Promise<string> {
333
+ await this.deps.audit.append("chat.message", { nodeId: this.node.id, data: { message } });
334
+ const outcome = await this.ask(message, { allowRead: true, maxTurns: CHAT_MAX_TURNS, ...(opts.budget ? { budget: opts.budget } : {}) });
335
+ const reply = outcome.text || outcome.error || "(no reply)";
336
+ await this.deps.audit.append("chat.reply", { nodeId: this.node.id, data: { reply } });
337
+ return reply;
338
+ }
339
+
340
+ /** Shared engine mechanics: state, budget, kill switch, approval gate, timer. */
341
+ private async runEngine(
342
+ prompt: string,
343
+ budget: Budget,
344
+ runId: string | undefined,
345
+ call: { builtinTools: string[]; maxTurns: number; cwd: string },
346
+ ): Promise<EngineOutcome> {
347
+ this.state = "running";
348
+ const meter = new BudgetMeter(budget, this.clock);
349
+ const controller = this.deps.killSwitch.register(this.node.id);
350
+ const pendingProposalIds: string[] = [];
351
+
352
+ let timer: NodeJS.Timeout | null = null;
353
+ if (budget.seconds !== undefined) {
354
+ timer = setTimeout(() => controller.abort(new Error("budget: seconds")), budget.seconds * 1000);
355
+ }
356
+
357
+ const model = resolveModel(this.node.spec.model, this.defaultTier);
358
+ const managerNodeId = managerScopeOf(this.node);
359
+ // Resolve this node's own credential scope (its `.env` chain). Tools fall back
360
+ // to process.env for anything not set per-node.
361
+ const nodeEnv = (await this.deps.secrets?.resolve(this.node.dir)) ?? {};
362
+ const plugin = this.deps.plugins?.forNode(this.node.id);
363
+ const pluginEnv: Record<string, string> = {};
364
+ for (const k of plugin?.env ?? []) {
365
+ const v = nodeEnv[k] ?? process.env[k];
366
+ if (v !== undefined) pluginEnv[k] = v;
367
+ }
368
+ const request: EngineRequest = {
369
+ systemPrompt: this.node.spec.systemPrompt,
370
+ model,
371
+ prompt,
372
+ tools: this.node.tools,
373
+ builtinTools: call.builtinTools,
374
+ maxTurns: call.maxTurns,
375
+ nodeEnv,
376
+ cwd: call.cwd,
377
+ ...(this.deps.memory
378
+ ? {
379
+ toolContext: {
380
+ nodeId: this.node.id,
381
+ managerNodeId,
382
+ memory: this.deps.memory,
383
+ ...(plugin?.tools ? { pluginTools: plugin.tools, pluginEnv } : {}),
384
+ },
385
+ }
386
+ : {}),
387
+ signal: controller.signal,
388
+ decide: async (use) => {
389
+ if (meter.exceeded()) return "deny";
390
+ // Surface the in-flight tool to the live console before we gate it.
391
+ this.activity = { ...this.activity, currentTool: use.name, waitingOnApproval: false };
392
+ await this.deps.audit.append("tool.started", {
393
+ nodeId: this.node.id,
394
+ ...(runId !== undefined ? { runId } : {}),
395
+ data: { tool: use.name },
396
+ });
397
+ const policy = policyForTool(this.node.tools, use.name);
398
+ const result = await this.deps.approvals.decide({
399
+ nodeId: this.node.id,
400
+ toolName: use.name,
401
+ input: use.input,
402
+ policy,
403
+ cwd: call.cwd,
404
+ managerNodeId,
405
+ ...(use.rationale !== undefined ? { rationale: use.rationale } : {}),
406
+ ...(runId !== undefined ? { runId } : {}),
407
+ });
408
+ if (result.proposalId) {
409
+ pendingProposalIds.push(result.proposalId);
410
+ this.activity = { ...this.activity, waitingOnApproval: true };
411
+ }
412
+ return result.decision;
413
+ },
414
+ onUsage: (u) => {
415
+ meter.recordUsage(u);
416
+ if (meter.exceeded()) controller.abort(new Error("budget exhausted"));
417
+ },
418
+ };
419
+
420
+ try {
421
+ const engineResult = await this.deps.engine.run(request);
422
+ meter.recordTurn();
423
+ return {
424
+ text: engineResult.text,
425
+ // The engine's own total is authoritative (SDK total_cost_usd + cache
426
+ // breakdown); the meter drove mid-run budget enforcement via onUsage.
427
+ usage: engineResult.usage,
428
+ stopReason: engineResult.stopReason,
429
+ budgetExceeded: meter.exceeded() !== null,
430
+ pendingProposalIds,
431
+ ...(engineResult.error ? { error: engineResult.error } : {}),
432
+ };
433
+ } catch (err) {
434
+ return {
435
+ text: "",
436
+ usage: meter.spent().usage,
437
+ stopReason: "error",
438
+ budgetExceeded: meter.exceeded() !== null,
439
+ pendingProposalIds,
440
+ error: err instanceof Error ? err.message : String(err),
441
+ };
442
+ } finally {
443
+ if (timer) clearTimeout(timer);
444
+ this.deps.killSwitch.release(this.node.id, controller);
445
+ this.activity = {};
446
+ const current = this.state as AgentState;
447
+ this.state = current === "draining" || current === "killed" ? current : "idle";
448
+ if (this.state === "idle" && this.pendingNode) {
449
+ this._node = this.pendingNode;
450
+ this.pendingNode = null;
451
+ }
452
+ }
453
+ }
454
+
455
+ private classify(
456
+ stopReason: "done" | "aborted" | "error",
457
+ budgetExceeded: boolean,
458
+ pendingProposals: number,
459
+ ): TaskStatus {
460
+ if (budgetExceeded) return "budget_exhausted";
461
+ if (stopReason === "aborted") return "aborted";
462
+ if (stopReason === "error") return "failed";
463
+ // Finished cleanly, but left consequential actions awaiting human approval.
464
+ if (pendingProposals > 0) return "deferred";
465
+ return "completed";
466
+ }
467
+
468
+ private abortedResult(contract: TaskContract, reason: string): TaskResult {
469
+ return {
470
+ contractId: contract.id,
471
+ status: "aborted",
472
+ summary: reason,
473
+ artifacts: {},
474
+ followUps: [],
475
+ usage: emptyUsage(),
476
+ };
477
+ }
478
+
479
+ /** Begin graceful drain — finish current run, accept no new work. */
480
+ drain(): void {
481
+ if (this.state === "killed") return;
482
+ this.state = this.state === "running" ? "draining" : "idle";
483
+ }
484
+
485
+ markKilled(): void {
486
+ this.state = "killed";
487
+ }
488
+ }
@@ -0,0 +1,84 @@
1
+ import type { Usage, PermissionDecision } from "../domain/types.js";
2
+ import type { ToolsConfig } from "../schemas/tools.js";
3
+ import type { MemoryStore } from "../memory/store.js";
4
+ import type { PluginTool } from "../plugins/types.js";
5
+
6
+ /**
7
+ * Platform services an engine may use to build the agent's tool environment
8
+ * (e.g. memory-backed MCP tools). Kept provider-agnostic — `FakeEngine` ignores
9
+ * it; `SdkEngine` uses it to assemble in-process MCP servers. Optional so the
10
+ * port stays clean and older callers keep working.
11
+ */
12
+ export interface ToolContext {
13
+ nodeId: string;
14
+ /** Team-memory scope (manager + direct reports share one key). */
15
+ managerNodeId: string;
16
+ memory: MemoryStore;
17
+ /** This node's team plugin tools, if it ships a plugin (assembled as the `plugin` server). */
18
+ pluginTools?: PluginTool[];
19
+ /** The plugin's declared env vars, resolved from the host. */
20
+ pluginEnv?: Record<string, string>;
21
+ }
22
+
23
+ export interface EngineToolUse {
24
+ name: string;
25
+ input: unknown;
26
+ }
27
+
28
+ /**
29
+ * A single bounded model interaction. The runtime hands the engine a fully
30
+ * resolved request; the engine runs the agent loop (tool calls included),
31
+ * calling `decide` before any tool executes and reporting usage as it accrues.
32
+ * The engine must stop promptly when `signal` aborts (kill switch / budget /
33
+ * timeout).
34
+ */
35
+ export interface EngineRequest {
36
+ systemPrompt: string;
37
+ model: string;
38
+ /** The task or message to act on. */
39
+ prompt: string;
40
+ tools: ToolsConfig;
41
+ /**
42
+ * The built-in SDK tools to expose to the model (e.g. `["Read","Glob","Grep"]`).
43
+ * `[]` disables all built-in tools. Scoping this is the main control on
44
+ * per-call token cost — tool schemas are re-sent on every internal turn.
45
+ */
46
+ builtinTools: string[];
47
+ /** Cap on the SDK's internal agentic turns for this single call. */
48
+ maxTurns?: number;
49
+ /** Platform services for building memory/connector tools (SdkEngine uses it). */
50
+ toolContext?: ToolContext;
51
+ /**
52
+ * This node's resolved credentials (its `.env` chain). Used to scope per-agent
53
+ * secrets into connector env, external-MCP stdio env, and `${VAR}` http headers —
54
+ * callers fall back to `process.env`. Top-level (not in `toolContext`) so an
55
+ * agent with external MCP servers but no memory still gets its keys.
56
+ */
57
+ nodeEnv?: Record<string, string>;
58
+ /** Persistent per-agent working directory. */
59
+ cwd: string;
60
+ signal: AbortSignal;
61
+ /** HITL gate: resolves allow/deny before a tool runs. */
62
+ decide: (use: EngineToolUse & { rationale?: string }) => Promise<PermissionDecision>;
63
+ /** Reports incremental usage so the runtime can enforce budgets live. */
64
+ onUsage?: (u: Usage) => void;
65
+ }
66
+
67
+ export interface EngineResult {
68
+ text: string;
69
+ usage: Usage;
70
+ stopReason: "done" | "aborted" | "error";
71
+ /** Tool calls the model attempted (whether allowed or denied). */
72
+ toolUses: EngineToolUse[];
73
+ error?: string;
74
+ }
75
+
76
+ /**
77
+ * The port the runtime depends on instead of a concrete LLM. The Claude Agent
78
+ * SDK is one implementation (see `sdkEngine.ts`); tests use a scripted fake.
79
+ * This keeps budget, approval, audit, and orchestration logic provider-agnostic
80
+ * and runnable without an API key.
81
+ */
82
+ export interface AgentEngine {
83
+ run(req: EngineRequest): Promise<EngineResult>;
84
+ }
@@ -0,0 +1,75 @@
1
+ import type { AgentEngine, EngineRequest, EngineResult, EngineToolUse } from "./engine.js";
2
+ import { emptyUsage, addUsage, type Usage, type PermissionDecision } from "../domain/types.js";
3
+
4
+ /** Context a scripted program uses to simulate an agent's behavior. */
5
+ export interface FakeContext {
6
+ req: EngineRequest;
7
+ /** Attempt a tool use; routes through the runtime's decide() gate. */
8
+ useTool: (name: string, input: unknown, rationale?: string) => Promise<PermissionDecision>;
9
+ /** Report simulated token usage (also forwarded to the runtime's budget meter). */
10
+ emitUsage: (u: Usage) => void;
11
+ /** Resolves true once the run has been aborted (kill / budget / timeout). */
12
+ aborted: () => boolean;
13
+ }
14
+
15
+ export type FakeProgram = (ctx: FakeContext) => Promise<string> | string;
16
+
17
+ /**
18
+ * A deterministic, offline AgentEngine for tests. Each `run` consumes the next
19
+ * queued program (or the default). Programs may attempt tool uses (exercising
20
+ * the approval gate) and emit usage (exercising budget enforcement).
21
+ */
22
+ export class FakeEngine implements AgentEngine {
23
+ private queue: FakeProgram[];
24
+
25
+ constructor(
26
+ private readonly defaultProgram: FakeProgram = () => "ok",
27
+ programs: FakeProgram[] = [],
28
+ ) {
29
+ this.queue = [...programs];
30
+ }
31
+
32
+ /** Queue a program for the next run. Returns the engine for chaining. */
33
+ enqueue(program: FakeProgram): this {
34
+ this.queue.push(program);
35
+ return this;
36
+ }
37
+
38
+ async run(req: EngineRequest): Promise<EngineResult> {
39
+ const program = this.queue.shift() ?? this.defaultProgram;
40
+ let usage = emptyUsage();
41
+ const toolUses: EngineToolUse[] = [];
42
+
43
+ const ctx: FakeContext = {
44
+ req,
45
+ useTool: async (name, input, rationale) => {
46
+ toolUses.push({ name, input });
47
+ return req.decide({ name, input, ...(rationale !== undefined ? { rationale } : {}) });
48
+ },
49
+ emitUsage: (u) => {
50
+ usage = addUsage(usage, u);
51
+ req.onUsage?.(u);
52
+ },
53
+ aborted: () => req.signal.aborted,
54
+ };
55
+
56
+ try {
57
+ const text = await program(ctx);
58
+ if (req.signal.aborted) {
59
+ return { text, usage, stopReason: "aborted", toolUses };
60
+ }
61
+ return { text, usage, stopReason: "done", toolUses };
62
+ } catch (err) {
63
+ if (req.signal.aborted) {
64
+ return { text: "", usage, stopReason: "aborted", toolUses };
65
+ }
66
+ return {
67
+ text: "",
68
+ usage,
69
+ stopReason: "error",
70
+ toolUses,
71
+ error: err instanceof Error ? err.message : String(err),
72
+ };
73
+ }
74
+ }
75
+ }