@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,270 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path, { basename } from "node:path";
|
|
3
|
+
import type { RegistryProcess, RegistrySnapshot } from "../control-plane/registry.js";
|
|
4
|
+
import type { Budget } from "../schemas/common.js";
|
|
5
|
+
import { BudgetMeter } from "../trust/budget.js";
|
|
6
|
+
import type { AuditSink } from "../trust/audit.js";
|
|
7
|
+
import { newId, systemClock, type Clock } from "../domain/ids.js";
|
|
8
|
+
import {
|
|
9
|
+
addUsage,
|
|
10
|
+
emptyUsage,
|
|
11
|
+
type TaskContract,
|
|
12
|
+
type TaskResult,
|
|
13
|
+
type Usage,
|
|
14
|
+
} from "../domain/types.js";
|
|
15
|
+
import type { Lifecycle } from "../runtime/lifecycle.js";
|
|
16
|
+
import type { Planner, PlanContext, RosterEntry } from "./planner.js";
|
|
17
|
+
|
|
18
|
+
/** Hard ceiling on orchestration turns, independent of budget, to bound any loop. */
|
|
19
|
+
const ABSOLUTE_MAX_TURNS = 12;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Field-wise minimum of a requested per-task budget and what's left of the
|
|
23
|
+
* process budget — a task can ask for less but never more than remains.
|
|
24
|
+
*/
|
|
25
|
+
export function clampBudget(requested: Budget | undefined, remaining: Budget): Budget {
|
|
26
|
+
if (!requested) return remaining;
|
|
27
|
+
const out: Budget = { ...remaining };
|
|
28
|
+
for (const k of ["tokens", "usd", "seconds", "turns"] as const) {
|
|
29
|
+
const req = requested[k];
|
|
30
|
+
const rem = remaining[k];
|
|
31
|
+
if (req !== undefined) out[k] = rem !== undefined ? Math.min(req, rem) : req;
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type ProcessRunStatus = "completed" | "budget_exhausted" | "failed";
|
|
37
|
+
|
|
38
|
+
export interface ProcessRunResult {
|
|
39
|
+
runId: string;
|
|
40
|
+
processName: string;
|
|
41
|
+
status: ProcessRunStatus;
|
|
42
|
+
summary: string;
|
|
43
|
+
/** Every task result produced, in dispatch order. */
|
|
44
|
+
results: TaskResult[];
|
|
45
|
+
usage: Usage;
|
|
46
|
+
turns: number;
|
|
47
|
+
/** The run's shared workspace dir, where deliverables/handoffs live (if any). */
|
|
48
|
+
workspaceDir?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OrchestratorDeps {
|
|
52
|
+
lifecycle: Lifecycle;
|
|
53
|
+
planner: Planner;
|
|
54
|
+
audit: AuditSink;
|
|
55
|
+
/** Parent dir for per-run workspaces. When set, runs get a shared workspace. */
|
|
56
|
+
workspaceRoot?: string;
|
|
57
|
+
clock?: Clock;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Runs a process to completion via the orchestrated task-contract model:
|
|
62
|
+
*
|
|
63
|
+
* plan (owner decomposes) → dispatch contracts to workers → collect results
|
|
64
|
+
* → re-plan with results → … until done / budget / turn cap.
|
|
65
|
+
*
|
|
66
|
+
* This is the deterministic control loop. There is no free-form agent chatter:
|
|
67
|
+
* every handoff is a structured TaskContract and every return is a TaskResult.
|
|
68
|
+
* The process budget bounds total spend and turns so a run always terminates.
|
|
69
|
+
*/
|
|
70
|
+
export class Orchestrator {
|
|
71
|
+
private readonly clock: Clock;
|
|
72
|
+
constructor(private readonly deps: OrchestratorDeps) {
|
|
73
|
+
this.clock = deps.clock ?? systemClock;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Resolve the workers an owner may dispatch to: its descendants in the tree. */
|
|
77
|
+
private rosterFor(ownerId: string, snapshot: RegistrySnapshot): RosterEntry[] {
|
|
78
|
+
const prefix = ownerId === "" ? "" : `${ownerId}/`;
|
|
79
|
+
const roster: RosterEntry[] = [];
|
|
80
|
+
for (const node of snapshot.nodes.values()) {
|
|
81
|
+
if (node.id === ownerId) continue;
|
|
82
|
+
const inSubtree = ownerId === "" ? node.id !== "" : node.id.startsWith(prefix);
|
|
83
|
+
if (inSubtree && this.deps.lifecycle.get(node.id)) {
|
|
84
|
+
roster.push({ nodeId: node.id, role: node.spec.role ?? node.spec.name, name: node.spec.name });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return roster;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private resolveAssignee(role: string, roster: RosterEntry[]): RosterEntry | undefined {
|
|
91
|
+
const needle = role.trim().toLowerCase();
|
|
92
|
+
return roster.find(
|
|
93
|
+
(r) => r.role.toLowerCase() === needle || r.name.toLowerCase() === needle || r.nodeId.toLowerCase() === needle,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async runProcess(
|
|
98
|
+
proc: RegistryProcess,
|
|
99
|
+
snapshot: RegistrySnapshot,
|
|
100
|
+
run: { inputs?: Record<string, unknown>; files?: string[]; runId?: string } = {},
|
|
101
|
+
): Promise<ProcessRunResult> {
|
|
102
|
+
const runId = run.runId ?? newId("run");
|
|
103
|
+
const processName = proc.spec.name;
|
|
104
|
+
const budget: Budget = proc.spec.budget ?? { turns: ABSOLUTE_MAX_TURNS };
|
|
105
|
+
const meter = new BudgetMeter(budget, this.clock);
|
|
106
|
+
const results: TaskResult[] = [];
|
|
107
|
+
let usage: Usage = emptyUsage();
|
|
108
|
+
|
|
109
|
+
await this.deps.audit.append("process.started", {
|
|
110
|
+
nodeId: proc.ownerNodeId,
|
|
111
|
+
runId,
|
|
112
|
+
data: { process: processName, ...(run.inputs ? { inputs: run.inputs } : {}) },
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const owner = this.deps.lifecycle.get(proc.ownerNodeId);
|
|
116
|
+
if (!owner) {
|
|
117
|
+
return this.finalize(runId, processName, "failed", `owner ${proc.ownerNodeId} has no runtime`, results, usage, 0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Provision the per-run shared workspace and stage input files into shared/
|
|
121
|
+
// once, so every dispatched agent reads inputs and hands off artifacts there.
|
|
122
|
+
let workspaceRoot: string | undefined;
|
|
123
|
+
let sharedDir: string | undefined;
|
|
124
|
+
if (this.deps.workspaceRoot) {
|
|
125
|
+
workspaceRoot = path.join(this.deps.workspaceRoot, runId);
|
|
126
|
+
sharedDir = path.join(workspaceRoot, "shared");
|
|
127
|
+
await fs.mkdir(sharedDir, { recursive: true });
|
|
128
|
+
for (const src of run.files ?? []) {
|
|
129
|
+
try {
|
|
130
|
+
await fs.copyFile(src, path.join(sharedDir, basename(src)));
|
|
131
|
+
await this.deps.audit.append("run.file_staged", { runId, data: { file: basename(src) } });
|
|
132
|
+
} catch (err) {
|
|
133
|
+
await this.deps.audit.append("run.file_stage_failed", {
|
|
134
|
+
runId,
|
|
135
|
+
data: { file: src, error: err instanceof Error ? err.message : String(err) },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const roster = this.rosterFor(proc.ownerNodeId, snapshot);
|
|
142
|
+
let status: ProcessRunStatus = "completed";
|
|
143
|
+
let summary = "";
|
|
144
|
+
let turns = 0;
|
|
145
|
+
|
|
146
|
+
while (turns < ABSOLUTE_MAX_TURNS) {
|
|
147
|
+
if (meter.exceeded()) {
|
|
148
|
+
status = "budget_exhausted";
|
|
149
|
+
summary = `Process budget exhausted (${meter.exceeded()}).`;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
meter.recordTurn();
|
|
153
|
+
turns += 1;
|
|
154
|
+
|
|
155
|
+
const ctx: PlanContext = {
|
|
156
|
+
process: proc.spec,
|
|
157
|
+
ownerNodeId: proc.ownerNodeId,
|
|
158
|
+
roster,
|
|
159
|
+
priorResults: results,
|
|
160
|
+
budgetRemaining: meter.remaining(),
|
|
161
|
+
runId,
|
|
162
|
+
...(run.inputs ? { runInputs: run.inputs } : {}),
|
|
163
|
+
...(run.files?.length ? { runFileNames: run.files.map((f) => basename(f)) } : {}),
|
|
164
|
+
};
|
|
165
|
+
const plan = await this.deps.planner.plan(ctx);
|
|
166
|
+
meter.recordUsage(plan.usage);
|
|
167
|
+
usage = addUsage(usage, plan.usage);
|
|
168
|
+
|
|
169
|
+
await this.deps.audit.append("process.turn", {
|
|
170
|
+
nodeId: proc.ownerNodeId,
|
|
171
|
+
runId,
|
|
172
|
+
data: {
|
|
173
|
+
turn: turns,
|
|
174
|
+
done: plan.done,
|
|
175
|
+
taskCount: plan.tasks.length,
|
|
176
|
+
assignees: plan.tasks.map((t) => t.assigneeRole),
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (plan.done) {
|
|
181
|
+
summary = plan.summary ?? "Process completed.";
|
|
182
|
+
status = "completed";
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (plan.tasks.length === 0) {
|
|
187
|
+
// No tasks and not done — nothing to advance; stop rather than spin.
|
|
188
|
+
summary = plan.summary ?? "Owner produced no tasks and did not mark the process done.";
|
|
189
|
+
status = "failed";
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const task of plan.tasks) {
|
|
194
|
+
const assignee = this.resolveAssignee(task.assigneeRole, roster);
|
|
195
|
+
if (!assignee) {
|
|
196
|
+
const failed: TaskResult = {
|
|
197
|
+
contractId: newId("task"),
|
|
198
|
+
status: "failed",
|
|
199
|
+
summary: `No agent for role "${task.assigneeRole}"`,
|
|
200
|
+
artifacts: {},
|
|
201
|
+
followUps: [],
|
|
202
|
+
usage: emptyUsage(),
|
|
203
|
+
};
|
|
204
|
+
results.push(failed);
|
|
205
|
+
await this.deps.audit.append("task.unrouted", {
|
|
206
|
+
nodeId: proc.ownerNodeId,
|
|
207
|
+
runId,
|
|
208
|
+
data: { role: task.assigneeRole },
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const worker = this.deps.lifecycle.get(assignee.nodeId)!;
|
|
214
|
+
const contract: TaskContract = {
|
|
215
|
+
id: newId("task"),
|
|
216
|
+
assigneeNodeId: assignee.nodeId,
|
|
217
|
+
issuerNodeId: proc.ownerNodeId,
|
|
218
|
+
goal: task.goal,
|
|
219
|
+
// Run-level inputs are available to every task; the planned task's own
|
|
220
|
+
// inputs take precedence on key collisions.
|
|
221
|
+
inputs: { ...(run.inputs ?? {}), ...(task.inputs ?? {}) },
|
|
222
|
+
definitionOfDone: task.definitionOfDone,
|
|
223
|
+
// A task's own budget (used for fan-out) is clamped to what's left; with
|
|
224
|
+
// no per-task budget it gets the whole remaining process budget.
|
|
225
|
+
budget: clampBudget(task.budget, meter.remaining()),
|
|
226
|
+
runId,
|
|
227
|
+
// All run agents share one workspace; inputs are already staged in shared/.
|
|
228
|
+
...(workspaceRoot ? { workspaceRoot } : {}),
|
|
229
|
+
};
|
|
230
|
+
const result = await worker.runTask(contract);
|
|
231
|
+
results.push(result);
|
|
232
|
+
meter.recordUsage(result.usage);
|
|
233
|
+
usage = addUsage(usage, result.usage);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (turns >= ABSOLUTE_MAX_TURNS && status === "completed" && summary === "") {
|
|
238
|
+
status = "budget_exhausted";
|
|
239
|
+
summary = `Reached the orchestration turn cap (${ABSOLUTE_MAX_TURNS}).`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return this.finalize(runId, processName, status, summary, results, usage, turns, sharedDir);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private async finalize(
|
|
246
|
+
runId: string,
|
|
247
|
+
processName: string,
|
|
248
|
+
status: ProcessRunStatus,
|
|
249
|
+
summary: string,
|
|
250
|
+
results: TaskResult[],
|
|
251
|
+
usage: Usage,
|
|
252
|
+
turns: number,
|
|
253
|
+
workspaceDir?: string,
|
|
254
|
+
): Promise<ProcessRunResult> {
|
|
255
|
+
await this.deps.audit.append("process.finished", {
|
|
256
|
+
runId,
|
|
257
|
+
data: { process: processName, status, turns, usage },
|
|
258
|
+
});
|
|
259
|
+
return {
|
|
260
|
+
runId,
|
|
261
|
+
processName,
|
|
262
|
+
status,
|
|
263
|
+
summary,
|
|
264
|
+
results,
|
|
265
|
+
usage,
|
|
266
|
+
turns,
|
|
267
|
+
...(workspaceDir ? { workspaceDir } : {}),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Budget } from "../schemas/common.js";
|
|
3
|
+
import type { ProcessSpec } from "../schemas/process.js";
|
|
4
|
+
import { emptyUsage, type TaskResult, type Usage } from "../domain/types.js";
|
|
5
|
+
import type { AgentRuntime } from "../runtime/agent.js";
|
|
6
|
+
|
|
7
|
+
/** A worker the owner may dispatch to (a descendant in the org tree). */
|
|
8
|
+
export interface RosterEntry {
|
|
9
|
+
nodeId: string;
|
|
10
|
+
role: string;
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PlanContext {
|
|
15
|
+
process: ProcessSpec;
|
|
16
|
+
ownerNodeId: string;
|
|
17
|
+
roster: RosterEntry[];
|
|
18
|
+
/** Results of tasks dispatched so far in this run (fed back for re-planning). */
|
|
19
|
+
priorResults: TaskResult[];
|
|
20
|
+
budgetRemaining: Budget;
|
|
21
|
+
runId: string;
|
|
22
|
+
/** Owner-supplied inputs for this run (e.g. prospect name, target languages). */
|
|
23
|
+
runInputs?: Record<string, unknown>;
|
|
24
|
+
/** Basenames of source files staged for this run, available to workers. */
|
|
25
|
+
runFileNames?: string[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One task the owner wants to dispatch. */
|
|
29
|
+
export interface PlannedTask {
|
|
30
|
+
assigneeRole: string;
|
|
31
|
+
goal: string;
|
|
32
|
+
definitionOfDone: string;
|
|
33
|
+
inputs?: Record<string, unknown>;
|
|
34
|
+
/**
|
|
35
|
+
* Optional per-task budget ceiling. Use it for fan-out (one task per item) so
|
|
36
|
+
* each task gets its own bounded slice rather than the whole remaining budget.
|
|
37
|
+
* Clamped to what's left of the process budget at dispatch.
|
|
38
|
+
*/
|
|
39
|
+
budget?: Budget;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The owner's decision for this orchestration turn: either dispatch more tasks,
|
|
44
|
+
* or declare the process done with a final summary.
|
|
45
|
+
*/
|
|
46
|
+
export interface Plan {
|
|
47
|
+
done: boolean;
|
|
48
|
+
summary?: string;
|
|
49
|
+
tasks: PlannedTask[];
|
|
50
|
+
usage: Usage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Decomposition is delegated to the owning manager agent — the orchestrator
|
|
55
|
+
* only runs the deterministic loop. Production uses `EnginePlanner` (the
|
|
56
|
+
* manager LLM emits a structured plan); tests inject a deterministic fake.
|
|
57
|
+
*/
|
|
58
|
+
export interface Planner {
|
|
59
|
+
plan(ctx: PlanContext): Promise<Plan>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const PlanSchema = z.object({
|
|
63
|
+
done: z.boolean(),
|
|
64
|
+
summary: z.string().optional(),
|
|
65
|
+
tasks: z
|
|
66
|
+
.array(
|
|
67
|
+
z.object({
|
|
68
|
+
assigneeRole: z.string().min(1),
|
|
69
|
+
goal: z.string().min(1),
|
|
70
|
+
definitionOfDone: z.string().min(1),
|
|
71
|
+
inputs: z.record(z.unknown()).optional(),
|
|
72
|
+
budget: z
|
|
73
|
+
.object({
|
|
74
|
+
tokens: z.number().optional(),
|
|
75
|
+
usd: z.number().optional(),
|
|
76
|
+
seconds: z.number().optional(),
|
|
77
|
+
turns: z.number().optional(),
|
|
78
|
+
})
|
|
79
|
+
.optional(),
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
82
|
+
.default([]),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/** Pull the first balanced top-level JSON object out of a model's text reply. */
|
|
86
|
+
export function extractJsonObject(text: string): unknown {
|
|
87
|
+
const start = text.indexOf("{");
|
|
88
|
+
if (start === -1) return null;
|
|
89
|
+
let depth = 0;
|
|
90
|
+
let inString = false;
|
|
91
|
+
let escaped = false;
|
|
92
|
+
for (let i = start; i < text.length; i++) {
|
|
93
|
+
const ch = text[i]!;
|
|
94
|
+
if (inString) {
|
|
95
|
+
if (escaped) escaped = false;
|
|
96
|
+
else if (ch === "\\") escaped = true;
|
|
97
|
+
else if (ch === '"') inString = false;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '"') inString = true;
|
|
101
|
+
else if (ch === "{") depth++;
|
|
102
|
+
else if (ch === "}") {
|
|
103
|
+
depth--;
|
|
104
|
+
if (depth === 0) {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text.slice(start, i + 1));
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderPlanPrompt(ctx: PlanContext): string {
|
|
117
|
+
const roster = ctx.roster.map((r) => `- role "${r.role}" (${r.name})`).join("\n") || "(no direct reports)";
|
|
118
|
+
const prior =
|
|
119
|
+
ctx.priorResults.length === 0
|
|
120
|
+
? "(none yet — this is the first turn)"
|
|
121
|
+
: ctx.priorResults
|
|
122
|
+
.map((r, i) => `${i + 1}. [${r.status}] ${r.summary}`)
|
|
123
|
+
.join("\n");
|
|
124
|
+
const inputs =
|
|
125
|
+
ctx.runInputs && Object.keys(ctx.runInputs).length
|
|
126
|
+
? Object.entries(ctx.runInputs)
|
|
127
|
+
.map(([k, v]) => `- ${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`)
|
|
128
|
+
.join("\n")
|
|
129
|
+
: "(none)";
|
|
130
|
+
const files = ctx.runFileNames?.length
|
|
131
|
+
? ctx.runFileNames.map((n) => `- ${n}`).join("\n")
|
|
132
|
+
: "(none)";
|
|
133
|
+
return [
|
|
134
|
+
`You are orchestrating the process "${ctx.process.name}".`,
|
|
135
|
+
``,
|
|
136
|
+
`## Playbook`,
|
|
137
|
+
ctx.process.playbook,
|
|
138
|
+
``,
|
|
139
|
+
`## Run inputs`,
|
|
140
|
+
inputs,
|
|
141
|
+
``,
|
|
142
|
+
`## Source files (staged into each worker's working directory when dispatched)`,
|
|
143
|
+
files,
|
|
144
|
+
``,
|
|
145
|
+
`## Definition of done`,
|
|
146
|
+
ctx.process.definitionOfDone,
|
|
147
|
+
``,
|
|
148
|
+
`## Your team (you may dispatch tasks to these roles only)`,
|
|
149
|
+
roster,
|
|
150
|
+
``,
|
|
151
|
+
`## Results so far`,
|
|
152
|
+
prior,
|
|
153
|
+
``,
|
|
154
|
+
`Decide the next step. Respond with ONLY a JSON object of the form:`,
|
|
155
|
+
`{"done": boolean, "summary": string, "tasks": [{"assigneeRole": string, "goal": string, "definitionOfDone": string, "inputs": {}, "budget": {"usd": number, "turns": number}}]}`,
|
|
156
|
+
`Set "done": true with a "summary" when the definition of done is met or cannot be advanced.`,
|
|
157
|
+
`Otherwise set "done": false and list the task(s) to dispatch this turn.`,
|
|
158
|
+
`When you fan out over many items (e.g. one investigation per candidate),`,
|
|
159
|
+
`dispatch ONE task per item and give each a small per-task "budget" (e.g.`,
|
|
160
|
+
`{"usd": 0.5, "turns": 6}) so a long item can't drain the whole process budget.`,
|
|
161
|
+
`Omit "budget" for a single task that may use the remaining budget.`,
|
|
162
|
+
`A result marked [deferred] means the worker finished but a consequential action`,
|
|
163
|
+
`(e.g. sending or delivering) is queued for human approval — that work is DONE`,
|
|
164
|
+
`from your side. Do NOT re-dispatch a deferred task; treat its step as complete`,
|
|
165
|
+
`and mark the process "done" once the remaining steps are covered.`,
|
|
166
|
+
].join("\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Planner backed by the owning agent's model. Asks the manager to emit a
|
|
171
|
+
* structured plan; if the reply can't be parsed as a plan, treats the reply as
|
|
172
|
+
* a final summary (fail safe — never loops on malformed output).
|
|
173
|
+
*/
|
|
174
|
+
export class EnginePlanner implements Planner {
|
|
175
|
+
constructor(private readonly resolveRuntime: (nodeId: string) => AgentRuntime | undefined) {}
|
|
176
|
+
|
|
177
|
+
async plan(ctx: PlanContext): Promise<Plan> {
|
|
178
|
+
const owner = this.resolveRuntime(ctx.ownerNodeId);
|
|
179
|
+
if (!owner) {
|
|
180
|
+
return { done: true, summary: `owner ${ctx.ownerNodeId} has no runtime`, tasks: [], usage: emptyUsage() };
|
|
181
|
+
}
|
|
182
|
+
let outcome = await owner.ask(renderPlanPrompt(ctx), {
|
|
183
|
+
budget: ctx.budgetRemaining,
|
|
184
|
+
runId: ctx.runId,
|
|
185
|
+
});
|
|
186
|
+
let parsed = PlanSchema.safeParse(extractJsonObject(outcome.text));
|
|
187
|
+
if (!parsed.success) {
|
|
188
|
+
// No parseable plan. The planner may have spent its turn budget on tool
|
|
189
|
+
// calls and never emitted the JSON. Retry once, demanding JSON and no tools
|
|
190
|
+
// — distinguishes "ran out of turns inspecting" from "genuinely done".
|
|
191
|
+
outcome = await owner.ask(
|
|
192
|
+
renderPlanPrompt(ctx) +
|
|
193
|
+
`\n\nIMPORTANT: Respond with ONLY the JSON plan object — no prose, and do NOT call any tools. ` +
|
|
194
|
+
`If there is nothing left to dispatch, return {"done": true, "summary": "..."}.`,
|
|
195
|
+
{ budget: ctx.budgetRemaining, runId: ctx.runId },
|
|
196
|
+
);
|
|
197
|
+
parsed = PlanSchema.safeParse(extractJsonObject(outcome.text));
|
|
198
|
+
}
|
|
199
|
+
if (!parsed.success) {
|
|
200
|
+
// Still no plan — terminate, but make the reason VISIBLE rather than
|
|
201
|
+
// reporting a clean "completed" that silently did nothing.
|
|
202
|
+
return {
|
|
203
|
+
done: true,
|
|
204
|
+
summary: `Planner produced no structured plan (stopReason=${outcome.stopReason}${
|
|
205
|
+
outcome.budgetExceeded ? ", budget exhausted" : ""
|
|
206
|
+
}). No tasks dispatched. Raw reply: ${(outcome.text || "(empty)").slice(0, 200)}`,
|
|
207
|
+
tasks: [],
|
|
208
|
+
usage: outcome.usage,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
done: parsed.data.done,
|
|
213
|
+
...(parsed.data.summary !== undefined ? { summary: parsed.data.summary } : {}),
|
|
214
|
+
tasks: parsed.data.tasks,
|
|
215
|
+
usage: outcome.usage,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|