Skip to content

Commit 946ed33

Browse files
authored
feat(agents): script mode — code-shaped orchestration for agents (#4239)
Add ScriptPlanner and ScriptRunner for agent orchestration via JavaScript scripts in QuickJS sandbox. Scripts can express loops, budget-scaled fan-out, dedup, and early exit — patterns a static DAG cannot.
1 parent af81268 commit 946ed33

8 files changed

Lines changed: 1491 additions & 0 deletions

File tree

packages/agents/CLAUDE.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,53 @@ const agent = new Agent({
260260
| `DEFAULT_MAX_STEPS` | 50 | `task-executor.ts` |
261261
| `MAX_RETRIES` (planning) | 3 | `task-planner.ts` |
262262

263+
## Script Mode (code-shaped orchestration)
264+
265+
The third planning mode next to `TaskPlan` and the graph planner: the LLM
266+
authors a JavaScript *orchestration script* (`ScriptPlanner`), and
267+
`ScriptRunner` executes it in the QuickJS sandbox. Every `agent()` call in the
268+
script runs a real `StepExecutor` sub-agent on the host. A script expresses
269+
what a static DAG cannot — loops until a condition holds, budget-scaled
270+
fan-out, dedup between rounds, early exit.
271+
272+
```typescript
273+
const agent = new Agent({
274+
name: "researcher",
275+
objective: "Find and verify 5 claims about X",
276+
provider, model,
277+
useScriptPlanner: true, // LLM writes the script
278+
// script: "...", // or supply one directly (skips planning)
279+
maxConcurrentAgents: 8, // semaphore over concurrent agent() calls
280+
maxAgentCalls: 100 // lifetime cap per run
281+
});
282+
```
283+
284+
Guest API (see `SCRIPT_PRELUDE` in `script-runner.ts`):
285+
286+
| Primitive | Behavior |
287+
|---|---|
288+
| `await agent(prompt, opts?)` | Run a sub-agent. `opts.schema` → structured result via `finish_step`; `opts.tools` restricts the toolset; `opts.label` names progress events. Throws on failure. |
289+
| `await parallel(thunks)` | Concurrent thunks; a failure resolves to `null` instead of rejecting the batch. |
290+
| `await pipeline(items, ...stages)` | Each item flows through all stages independently (no barrier). Stages receive `(prev, originalItem, index)`. |
291+
| `log(message)` | Emits a `log_update` to the host event stream. |
292+
| `budget` | `maxAgentCalls`, `agentCalls()`, `remainingCalls()`, `await spentUsd()`. |
293+
| `inputs` | Caller-supplied inputs object. |
294+
295+
The script's `return` value becomes `agent.getResults()`. Sub-agents share
296+
`context.memory` as usual, and concurrency is bounded host-side by a semaphore
297+
(`maxConcurrentAgents`, default 8) plus a lifetime call cap (`maxAgentCalls`,
298+
default 100) — calls past the cap fail with a budget error the script can
299+
handle (`budget.remainingCalls()` guards loops). Script failures (syntax
300+
error, uncaught exception, wall-clock timeout — default 60 min including
301+
sub-agent time) throw from `Agent.execute`.
302+
303+
Host bridges never reject (the QuickJS handle-leak rule from
304+
`js-sandbox.ts` applies): `__runAgent` resolves `{ok, result|error}` envelopes
305+
and the guest `agent()` re-throws.
306+
307+
Tests: `tests/script-runner.test.ts`, `tests/script-planner.test.ts`,
308+
`tests/agent-script-mode.test.ts`.
309+
263310
## Observing LLM Steps and Planning
264311

265312
### Execution Tree (CLI)

packages/agents/src/agent.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { ParallelTaskExecutor } from "./parallel-task-executor.js";
3232
import { CompilerAgent } from "./compiler-agent.js";
3333
import { GraphPlanner } from "./graph-planner.js";
3434
import { AgentWorkflowRunner } from "./agent-workflow-runner.js";
35+
import { ScriptPlanner } from "./script-planner.js";
36+
import { ScriptRunner } from "./script-runner.js";
3537
import type { Tool } from "./tools/base-tool.js";
3638
import { gateTools } from "./tools/tool-permissions.js";
3739
import {
@@ -262,6 +264,24 @@ export interface AgentOptions {
262264
* graph executed by {@link AgentWorkflowRunner}.
263265
*/
264266
useGraphPlanner?: boolean;
267+
/**
268+
* Use the script planner: the LLM authors a JavaScript orchestration
269+
* script (loops, conditionals, budget-scaled fan-out) instead of a
270+
* TaskPlan, and {@link ScriptRunner} executes it deterministically in the
271+
* QuickJS sandbox — every `agent()` call in the script runs a real
272+
* sub-agent. Takes precedence over {@link useGraphPlanner}.
273+
*/
274+
useScriptPlanner?: boolean;
275+
/**
276+
* Pre-authored orchestration script. Skips planning entirely and runs the
277+
* script directly (implies script mode). See {@link ScriptRunner} for the
278+
* script API.
279+
*/
280+
script?: string;
281+
/** Script mode: concurrent `agent()` calls beyond this queue. Default 8. */
282+
maxConcurrentAgents?: number;
283+
/** Script mode: lifetime `agent()` call cap per run. Default 100. */
284+
maxAgentCalls?: number;
265285
/** Node registry required when {@link useGraphPlanner} is true. */
266286
registry?: NodeRegistry;
267287
/**
@@ -339,6 +359,10 @@ export class Agent {
339359
private readonly autoPersistMemory: boolean;
340360
private readonly synthesizeRecall: boolean;
341361
private readonly useGraphPlanner: boolean;
362+
private readonly useScriptPlanner: boolean;
363+
private readonly script?: string;
364+
private readonly maxConcurrentAgents?: number;
365+
private readonly maxAgentCalls?: number;
342366
private readonly registry?: NodeRegistry;
343367
private readonly providers?: Record<string, BaseProvider>;
344368
private readonly securityMonitorEnabled: boolean;
@@ -374,6 +398,10 @@ export class Agent {
374398
this.autoPersistMemory = opts.autoPersistMemory === true;
375399
this.synthesizeRecall = opts.synthesizeRecall ?? true;
376400
this.useGraphPlanner = opts.useGraphPlanner === true;
401+
this.useScriptPlanner = opts.useScriptPlanner === true;
402+
this.script = opts.script;
403+
this.maxConcurrentAgents = opts.maxConcurrentAgents;
404+
this.maxAgentCalls = opts.maxAgentCalls;
377405
this.registry = opts.registry;
378406
this.providers = opts.providers;
379407
this.securityMonitorEnabled = opts.securityMonitor?.enabled === true;
@@ -654,6 +682,17 @@ export class Agent {
654682
return;
655683
}
656684

685+
// Script mode: LLM-authored (or pre-authored) orchestration script,
686+
// executed deterministically by ScriptRunner.
687+
if (this.script || this.useScriptPlanner) {
688+
yield* this.executeScriptPlan(
689+
context,
690+
mergedSystemPrompt,
691+
effectiveObjective
692+
);
693+
return;
694+
}
695+
657696
// Graph-native planner: build DAG of nodes directly.
658697
if (this.useGraphPlanner && this.registry) {
659698
yield* this.executeGraphPlan(context, mergedSystemPrompt);
@@ -910,6 +949,66 @@ export class Agent {
910949
}
911950
}
912951

952+
/**
953+
* Script mode: obtain an orchestration script (pre-authored via the
954+
* `script` option, otherwise written by ScriptPlanner) and execute it with
955+
* ScriptRunner. The script's return value becomes the agent result.
956+
*/
957+
private async *executeScriptPlan(
958+
context: ProcessingContext,
959+
systemPrompt: string | undefined,
960+
objective: string
961+
): AsyncGenerator<ProcessingMessage> {
962+
let script = this.script ?? null;
963+
964+
if (!script) {
965+
log.info("Script planning phase started", { name: this.name });
966+
const planner = new ScriptPlanner({
967+
provider: this.provider,
968+
model: this.planningModel,
969+
tools: this.tools,
970+
systemPrompt,
971+
outputSchema: this.outputSchema,
972+
inputs: this.inputs
973+
});
974+
const planGen = planner.plan(objective, context);
975+
let planResult = await planGen.next();
976+
while (!planResult.done) {
977+
yield planResult.value;
978+
planResult = await planGen.next();
979+
}
980+
script = planResult.value;
981+
if (!script) {
982+
throw new Error(
983+
"ScriptPlanner failed to produce an orchestration script."
984+
);
985+
}
986+
}
987+
988+
const runner = new ScriptRunner({
989+
provider: this.provider,
990+
model: this.model,
991+
context,
992+
tools: this.buildExecutorTools(),
993+
systemPrompt,
994+
inputs: this.inputs,
995+
maxStepIterations: this.maxStepIterations,
996+
maxConcurrentAgents: this.maxConcurrentAgents,
997+
maxAgentCalls: this.maxAgentCalls
998+
});
999+
1000+
const runGen = runner.execute(script);
1001+
let next = await runGen.next();
1002+
while (!next.done) {
1003+
yield next.value;
1004+
next = await runGen.next();
1005+
}
1006+
this.results = next.value ?? null;
1007+
1008+
log.info("Agent completed", { name: this.name });
1009+
this.persistAgentRunMemory();
1010+
}
1011+
9131012
/**
9141013
* Graph-native plan: build a DAG of nodes via GraphPlanner, then execute it
9151014
* with AgentWorkflowRunner.

packages/agents/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,17 @@ export type { ParallelTaskExecutorOptions } from "./parallel-task-executor.js";
344344
export { CompilerAgent } from "./compiler-agent.js";
345345
export type { CompilerAgentOptions } from "./compiler-agent.js";
346346

347+
// Script-mode planning & execution (code-shaped orchestration)
348+
export { ScriptPlanner, validateScript } from "./script-planner.js";
349+
export type { ScriptPlannerOptions } from "./script-planner.js";
350+
export {
351+
ScriptRunner,
352+
SCRIPT_RESERVED_NAMES,
353+
DEFAULT_MAX_CONCURRENT_AGENTS,
354+
DEFAULT_MAX_AGENT_CALLS
355+
} from "./script-runner.js";
356+
export type { ScriptRunnerOptions } from "./script-runner.js";
357+
347358
// Graph-native planning & execution
348359
export { GraphBuilder, AGENT_STEP_NODE_TYPE } from "./graph-builder.js";
349360
export { GraphPlanner } from "./graph-planner.js";

0 commit comments

Comments
 (0)