@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.
- package/CHANGELOG.md +34 -0
- package/LICENSE +202 -0
- package/README.md +68 -0
- package/bin/ravel.mjs +6 -0
- package/examples/acme/agent.md +14 -0
- package/examples/acme/growth/agent.md +19 -0
- package/examples/acme/growth/copywriter/agent.md +13 -0
- package/examples/acme/growth/copywriter/tools.json +11 -0
- package/examples/acme/growth/researcher/agent.md +11 -0
- package/examples/acme/processes/prospect-outreach.process.md +24 -0
- package/examples/harbor/agent.md +31 -0
- package/examples/harbor/processes/new-client-quote.process.md +31 -0
- package/examples/harbor/processes/resolve-ticket-batch.process.md +33 -0
- package/examples/harbor/sales/agent.md +29 -0
- package/examples/harbor/sales/sdr/agent.md +24 -0
- package/examples/harbor/sales/solutions/agent.md +29 -0
- package/examples/harbor/sales/solutions/tools.json +16 -0
- package/examples/harbor/support/agent.md +31 -0
- package/examples/harbor/support/kb-writer/agent.md +25 -0
- package/examples/harbor/support/kb-writer/tools.json +10 -0
- package/examples/harbor/support/qa-reviewer/agent.md +23 -0
- package/examples/harbor/support/tools.json +16 -0
- package/examples/harbor/support/triage/agent.md +24 -0
- package/examples/harbor/support/triage/tools.json +10 -0
- package/examples/plugin-demo/agent.md +15 -0
- package/examples/plugin-demo/processes/jot.process.md +21 -0
- package/examples/plugin-demo/scribe/agent.md +15 -0
- package/examples/plugin-demo/scribe/plugin.ts +53 -0
- package/examples/plugin-demo/scribe/tools.json +9 -0
- package/package.json +65 -0
- package/src/cli/main.ts +428 -0
- package/src/control-plane/registry.ts +294 -0
- package/src/control-plane/watcher.ts +132 -0
- package/src/domain/ids.ts +17 -0
- package/src/domain/pricing.ts +51 -0
- package/src/domain/types.ts +168 -0
- package/src/index.ts +35 -0
- package/src/memory/genericTools.ts +276 -0
- package/src/memory/kv.ts +52 -0
- package/src/memory/store.ts +76 -0
- package/src/messaging/bus.ts +143 -0
- package/src/messaging/inbox.ts +119 -0
- package/src/orchestrator/orchestrator.ts +270 -0
- package/src/orchestrator/planner.ts +218 -0
- package/src/platform/app.ts +287 -0
- package/src/plugins/loader.ts +86 -0
- package/src/plugins/server.ts +41 -0
- package/src/plugins/types.ts +96 -0
- package/src/runtime/agent.ts +488 -0
- package/src/runtime/engine.ts +84 -0
- package/src/runtime/fakeEngine.ts +75 -0
- package/src/runtime/lifecycle.ts +85 -0
- package/src/runtime/officeActions.ts +58 -0
- package/src/runtime/officeTools.ts +42 -0
- package/src/runtime/sdkEngine.ts +213 -0
- package/src/schemas/agent.ts +47 -0
- package/src/schemas/common.ts +52 -0
- package/src/schemas/frontmatter.ts +36 -0
- package/src/schemas/process.ts +55 -0
- package/src/schemas/tools.ts +83 -0
- package/src/secrets/store.ts +131 -0
- package/src/service/scheduler.ts +377 -0
- package/src/service/server.ts +554 -0
- package/src/trust/approval.ts +180 -0
- package/src/trust/audit.ts +195 -0
- package/src/trust/budget.ts +64 -0
- package/src/trust/emittingAudit.ts +32 -0
- package/src/trust/executor.ts +73 -0
- package/src/trust/killswitch.ts +73 -0
- package/src/trust/observability.ts +97 -0
- package/src/trust/proposals.ts +114 -0
- package/ui/dist/assets/index-C6CxDaPS.js +44 -0
- package/ui/dist/assets/index-CD-lhs0Z.css +1 -0
- 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
|
+
}
|