Skip to content

Commit f811124

Browse files
authored
feat(agents): CodeAct execution mode — code actions instead of JSON tool calls (#4763)
1 parent eea08bf commit f811124

25 files changed

Lines changed: 2610 additions & 20 deletions

CLAUDE.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,15 @@ npm run dev:nodetool -- eval task-planner -p anthropic -m claude-sonnet-5
867867
npm run dev:nodetool -- eval script-planner -p openai -m gpt-5.4-mini
868868
```
869869

870+
A **`codeact`** suite scores the CodeAct execution mode (steps act by writing
871+
sandboxed JavaScript over the toolbelt instead of JSON tool calls —
872+
[docs/codeact-design.md](docs/codeact-design.md)) on offline instrumented
873+
cases: required tools invoked, action rounds within bounds, result correct.
874+
875+
```bash
876+
npm run dev:nodetool -- eval codeact -p anthropic -m claude-sonnet-5
877+
```
878+
870879
Alongside `graph-planner` (one-shot DSL) there are ten **tool-loop** suites
871880
that drive a real provider through the frontend `ui_*` tool contract against a
872881
headless bridge — no browser — and score the multi-turn tool-calling flow

docs/codeact-design.md

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
# CodeAct Execution Mode — Design
2+
3+
Status: implemented behind `executionMode: "codeact"` (default stays `"tools"`).
4+
Code: `packages/agents/src/codeact/`.
5+
6+
## What this is
7+
8+
An alternative action space for the agent step loop. In the default mode the
9+
model acts by emitting one JSON tool call per action, the host executes it, and
10+
the result comes back as a tool message — one round trip per tool. In CodeAct
11+
mode the model acts by writing a JavaScript program; the program runs in the
12+
QuickJS sandbox where the same toolbelt is exposed as async functions
13+
(`tools.web_search(...)`), and one round trip can chain, loop over, branch on,
14+
and post-process any number of tool calls. The program's output — return value,
15+
console logs, thrown error — comes back as the observation for the next turn.
16+
17+
The research this follows:
18+
19+
- **CodeAct** (Wang et al., ICML 2024, arXiv:2402.01030) — executable code as
20+
the action space beats JSON/text tool calling: up to +20% success, ~30% fewer
21+
turns. The core loop here (code action → execution observation → repair) is
22+
that paper's.
23+
- **CaveAgent** (arXiv:2601.01569) — a *persistent* runtime across turns
24+
(stateful objects survive between actions) adds +5–13.5% success and ~28%
25+
fewer tokens on data-heavy tasks. Our `state` object is this: it lives on the
26+
host and syncs back after every action, so turn N+1 can read what turn N
27+
computed without re-serializing it through the transcript.
28+
- **MCP design-choice study** (arXiv:2602.15945) and Anthropic's
29+
*Code execution with MCP* (Nov 2025) — decoupling tool *results* from model
30+
*context* is where the token savings come from (their headline example:
31+
98.7% reduction). A CodeAct program can fetch a large payload, reduce it in
32+
the sandbox, and surface only the reduction; in tool mode the whole payload
33+
transits the transcript.
34+
- **To Run or Not to Run** (arXiv:2606.26978) — execution isn't free; there are
35+
regimes where restricting it saves cost with little accuracy loss. That is
36+
why this is a *mode*, not a replacement: the default stays `"tools"`, and the
37+
eval suite exists to measure where codeact actually wins before any default
38+
flips.
39+
40+
## What already exists (and is reused unchanged)
41+
42+
| Piece | Where | Role here |
43+
|---|---|---|
44+
| QuickJS WASM sandbox | `packages/agents/src/js-sandbox.ts` (`runInSandbox`) | Executes every action. All its limits (30 s timeout, 64 MB heap, fetch caps, output truncation, SSRF guard, workspace containment) apply per action. |
45+
| Tool base class + registry | `src/tools/base-tool.ts`, `tool-registry.ts` | The toolbelt is the same `Tool[]` the tool-mode step gets — codeact adds no capability that tool mode doesn't have. |
46+
| Provider loop | `BaseProvider.generateLoop` | Drives the turn loop; codeact presents exactly one provider tool. |
47+
| Result-schema validation | `src/utils/json-schema-validate.ts` | `finish(result)` validates host-side with the same checker `finish_step` uses. |
48+
| Never-reject bridge convention | `js-sandbox.ts` / `script-runner.ts` | Host bridges resolve `{ok, ...}` envelopes; a guest prelude re-throws. Required by the QuickJS handle-leak workaround. |
49+
| Agent memory | `context.memory` | Step/task results land under the same keys; memory tools are in the toolbelt as functions like everything else. |
50+
51+
CodeAct is *not* script mode. `ScriptRunner` orchestrates **sub-agents**
52+
(`agent()` spawns a `StepExecutor`); codeact is what a single step *does
53+
instead of* JSON tool calls. The two compose: a script-mode run whose
54+
sub-steps execute in codeact mode is just both flags set.
55+
56+
## The action protocol
57+
58+
The model sees **one** provider tool:
59+
60+
```
61+
execute_code({ code: string })
62+
```
63+
64+
Code actions arrive through a tool call rather than fenced text because every
65+
provider adapter already delivers tool calls reliably; scraping code blocks
66+
out of prose is exactly the fragility tool calling was invented to avoid. The
67+
CodeAct paper's gains come from the *action space* being code, not from the
68+
transport being free text.
69+
70+
Inside the sandbox, on top of the standard surface (`console`, `fetch`,
71+
`workspace`, `crypto`, `data`, `format`, …), the action gets:
72+
73+
- **`tools.<name>(args)`** — one async function per tool in the step's
74+
toolbelt. Calls bridge to `Tool.executeTool` on the host. A tool that
75+
returns an `{error}` payload throws in the guest, so `try/catch` is the
76+
error-handling idiom. Per-action tool-call cap (`maxToolCallsPerAction`,
77+
default 50) so a runaway loop can't drain budgets silently.
78+
- **`state`** — a plain object that persists across actions within the step
79+
(host-side, synced back after every run via the sandbox's global sync-back).
80+
Fetch once, reuse every turn; never re-fetch to re-look at something.
81+
- **`finish(result)`** — completes the step. For schema'd steps the host
82+
validates against the declared schema; an invalid result throws in the guest
83+
with the violation list, so the same action can repair and retry, or the
84+
failure becomes the observation for the next action. Valid `finish` ends the
85+
provider loop (AbortController, same mechanism as `finish_step`).
86+
- **The action's return value** — becomes part of the observation. Returning a
87+
small summary of big intermediate data is the context-decoupling move; the
88+
prompt says so explicitly.
89+
90+
The observation sent back as the tool result is a JSON envelope:
91+
92+
```
93+
{ ok, result?, error?, stack?, logs?, finished?, toolCalls }
94+
```
95+
96+
truncated by the same `truncateToolResult` cap as any tool result (20 000
97+
chars). `toolCalls` is the count consumed, so the model can see budget burn.
98+
99+
### Completion semantics (identical contract to StepExecutor)
100+
101+
- Schema'd step: only a schema-valid `finish(result)` completes. Iterations
102+
exhausted → explicit failed step, never a silent guess.
103+
- Unschema'd step: `finish(...)` works, and a plain assistant message with no
104+
tool call also finalizes (its text is the result) — the same prose-mode rule
105+
the tool-mode executor has.
106+
- Failure reporting, memory writes (`step:<id>`, `task:<id>` with
107+
`useFinishTask`), and the `ProcessingMessage` stream (`task_update`,
108+
`step_result`, `tool_call_update`, `chunk`) are byte-compatible with
109+
`StepExecutor`, so every consumer — CLI tree, web ExecutionTree, script
110+
runner, supervisor — works unchanged.
111+
112+
Each host-bridged tool invocation is surfaced as a `tool_call_update` (id
113+
`codeact_<n>`), so observability keeps per-tool granularity even though the
114+
provider transcript only carries `execute_code`.
115+
116+
## Prompting
117+
118+
`buildCodeActSystemPrompt` renders:
119+
120+
1. The action contract (write code, observe, repair; `state` discipline; keep
121+
observations small — return summaries, stash payloads in `state` or
122+
memory).
123+
2. The tool catalog as **typed signatures**, generated from each tool's JSON
124+
schema (`await tools.browse({url: string, timeout?: number})` + first
125+
sentence of the description) — and only for the **resident** set. The
126+
high-traffic tools nearly every step reaches for (the whole search family
127+
`web_search`, `search_nodes`, `run_search`, `google_news`,
128+
`google_images`, `asset_search`, `grep`, `glob` — plus the Claude-agent
129+
file set (`read_file`, `write_file`, `edit_file`, `list_directory`),
130+
browser, HTTP, memory, `run_subtask``CODEACT_RESIDENT_TOOL_NAMES`,
131+
overridable per executor) stay fully documented; once the belt exceeds `CODEACT_DEFER_THRESHOLD` (16),
132+
everything else is listed by name only and discovered in-sandbox via
133+
`await searchTools("query")`, which reuses the ToolSearch query grammar
134+
(`select:`, keywords, `+substr`) and returns each match's signature and
135+
description. Deferred tools remain callable — the split spends prompt
136+
tokens, not capability. This is the progressive-disclosure half of the
137+
Anthropic MCP result.
138+
3. A condensed sandbox API reference (what exists beyond `tools.*`, what is
139+
blocked, the key limits) derived from the same manifest the Code-node
140+
prompt uses, so it cannot advertise an API the sandbox doesn't marshal.
141+
4. The output-schema section for schema'd steps.
142+
143+
Caller-supplied system prompts remain preambles, exactly as in
144+
`StepExecutor.buildSystemPrompt` — they cannot override the execution
145+
contract.
146+
147+
## Security posture
148+
149+
The action executes with the same privileges tool mode already grants:
150+
151+
- Every `tools.*` function is a tool the model could have called directly; the
152+
bridge adds **no** capability. Per-step `tools` allow-lists stay a privilege
153+
boundary — a codeact step only sees its allowed subset.
154+
- The sandbox's own limits bound the new part (arbitrary computation): CPU via
155+
interrupt handler, heap, fetch count/size/SSRF guard, workspace containment,
156+
no `eval`/`Function`, no module loader.
157+
- The genuinely new risk (per the MCP design-choice study) is *composition*:
158+
one action can chain tool calls without per-call visibility in the provider
159+
transcript. Mitigations: per-action tool-call cap, per-invocation
160+
`tool_call_update` events (nothing becomes invisible to the host), and the
161+
30 s default action timeout.
162+
- `finish` validation is host-side; the guest cannot forge a completed step.
163+
164+
## Integration surface
165+
166+
- `AgentOptions.executionMode?: "tools" | "codeact"` — threaded through
167+
`Agent``ParallelTaskExecutor``TaskExecutor`, which picks the executor
168+
class per step (`createStepExecutor`). Script mode forwards it to its
169+
sub-agents; process-mode fan-out steps use it too.
170+
- **The setting**: `NODETOOL_AGENT_EXECUTION_MODE` (`tools` | `codeact`),
171+
registered in the settings registry so it appears in the Settings UI and
172+
`nodetool settings`. Resolution precedence, everywhere a mode is resolved
173+
(`resolveExecutionMode`): explicit option > the setting > `"tools"`. The
174+
server mirrors the stored value into the environment at startup
175+
(`applyAgentExecutionModeSetting`); a real environment variable wins over
176+
the stored value, and a Settings change takes effect on the next server
177+
start.
178+
- CLI: `nodetool agent run <yaml> --codeact`; the agent YAML also takes
179+
`execution_mode: codeact`. Flag > YAML > setting.
180+
181+
## Evaluation
182+
183+
`eval codeact` (registered next to `subtask`): objectives with instrumented
184+
tools where the interesting metric is *rounds* and *tool routing*, scored
185+
structurally (required tools invoked, forbidden ones not, action count within
186+
bounds, final result correct). Run the same cases through both modes to get
187+
the paper's comparison on our own toolbelt:
188+
189+
```bash
190+
npm run dev:nodetool -- eval codeact -p anthropic -m claude-sonnet-5
191+
```
192+
193+
Harness tests (`tests/codeact-executor.test.ts`) drive the executor with a
194+
`ScriptedProvider` — tool chaining in one action, `state` persistence across
195+
actions, schema repair after an invalid `finish`, error observations, prose
196+
finalization — no network, no model.
197+
198+
## Non-goals (now)
199+
200+
- Flipping the default. `"tools"` remains until the eval says otherwise per
201+
the cost-effectiveness caveat above.
202+
- Python actions. The sandbox is JS; the CodeAct result is about code as the
203+
action space, not about Python specifically.
204+
- Replacing planners. GraphPlanner/ScriptPlanner/CodePlanner already use
205+
code-shaped *artifacts*; this changes the step execution loop only.
206+
- The chat/websocket toolbelt. Chat turns keep tool mode; wiring codeact into
207+
the websocket runner is a follow-up once step-level evals justify it.

packages/agents/CLAUDE.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,37 @@ downstream may treat a failure as a satisfied dependency: `TaskExecutor` blocks
459459
dependents and marks them failed with the blocking step named, and a plan whose
460460
every task failed throws instead of compiling a deliverable out of nothing.
461461

462+
## CodeAct Execution Mode (`src/codeact/`)
463+
464+
An alternative action space for the step loop (`executionMode: "codeact"` on
465+
`AgentOptions`, or the `NODETOOL_AGENT_EXECUTION_MODE` setting; default stays
466+
`"tools"`). Instead of one JSON tool call per
467+
action, each step acts by writing JavaScript that runs in the QuickJS sandbox
468+
with the toolbelt exposed as `tools.<name>()` functions, a `state` object that
469+
persists across actions, and `finish(result)` for host-validated completion.
470+
Design and the research it follows (CodeAct, ICML 2024): docs/codeact-design.md.
471+
472+
- `CodeActExecutor` mirrors `StepExecutor`'s message contract, memory writes,
473+
and failure semantics — consumers work unchanged. Bridged tool calls surface
474+
as `tool_call_update` events (ids `codeact_<n>`).
475+
- Progressive disclosure: resident tools (`CODEACT_RESIDENT_TOOL_NAMES`
476+
the search family incl. `web_search`/`search_nodes`/`run_search`/
477+
`asset_search`/`grep`/`glob`, the Claude-agent file set
478+
(`read_file`/`write_file`/`edit_file`/`list_directory`), browser, HTTP,
479+
memory, `run_subtask`) are documented in full; past `CODEACT_DEFER_THRESHOLD` tools, the rest is name-only in the
480+
prompt and discovered in-sandbox with `await searchTools("query")`
481+
(ToolSearch grammar). All tools stay callable either way.
482+
- The mode threads through `TaskExecutor`, `ParallelTaskExecutor`, and
483+
`ScriptRunner` sub-agents; each resolves `resolveExecutionMode(explicit)`
484+
explicit option > `NODETOOL_AGENT_EXECUTION_MODE` > `"tools"`. The setting
485+
is registered in the websocket settings registry (Settings UI, group
486+
Execution) and mirrored into the environment at server startup. CLI:
487+
`nodetool agent run <yaml> --codeact` (YAML: `execution_mode: codeact`).
488+
- Eval suite `codeact` runs the same offline instrumented cases through either
489+
executor for a mode comparison: `nodetool eval codeact -p <p> -m <m>`.
490+
- Tests: `tests/codeact-executor.test.ts`, `tests/codeact-eval.test.ts`
491+
(scripted provider, real sandbox, no network).
492+
462493
## Script Mode (code-shaped orchestration)
463494

464495
The third planning mode next to `TaskPlan` and the graph planner: the LLM

packages/agents/src/agent.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ import {
4848
createSecurityMonitorConsult
4949
} from "./security-monitor.js";
5050
import type {
51+
AgentExecutionMode,
5152
PlanApprovalDecision,
5253
RequestPlanApproval,
5354
Task,
5455
TaskPlan
5556
} from "./types.js";
5657
import { PLAN_APPROVAL_CONTEXT_KEY } from "./types.js";
58+
import { resolveExecutionMode } from "./codeact/execution-mode.js";
5759
import type { PlanCache, CheckpointStore } from "./checkpoint-store.js";
5860
import { resolveAgentPolicy, type AgentPolicy } from "./agent-policy.js";
5961
import type { NodeRegistry } from "@nodetool-ai/node-sdk";
@@ -278,6 +280,14 @@ export interface AgentOptions {
278280
* graph executed by {@link AgentWorkflowRunner}.
279281
*/
280282
useGraphPlanner?: boolean;
283+
/**
284+
* Step action space: `"tools"` (default) is the classic one-JSON-tool-call-
285+
* per-action loop; `"codeact"` has each step act by writing JavaScript that
286+
* runs in the QuickJS sandbox with the toolbelt exposed as `tools.<name>()`
287+
* functions (docs/codeact-design.md). Orthogonal to the planning mode —
288+
* script-mode sub-agents and process-mode fan-outs honor it too.
289+
*/
290+
executionMode?: AgentExecutionMode;
281291
/**
282292
* Use the script planner: the LLM authors a JavaScript orchestration
283293
* script (loops, conditionals, budget-scaled fan-out) instead of a
@@ -401,6 +411,7 @@ export class Agent {
401411
private readonly autoPersistMemory: boolean;
402412
private readonly synthesizeRecall: boolean;
403413
private readonly useGraphPlanner: boolean;
414+
private readonly executionMode: AgentExecutionMode;
404415
private readonly useScriptPlanner: boolean;
405416
private readonly script?: string;
406417
private readonly graphSource?: AgentGraphSource;
@@ -447,6 +458,7 @@ export class Agent {
447458
this.autoPersistMemory = opts.autoPersistMemory === true;
448459
this.synthesizeRecall = opts.synthesizeRecall ?? true;
449460
this.useGraphPlanner = opts.useGraphPlanner === true;
461+
this.executionMode = resolveExecutionMode(opts.executionMode);
450462
this.useScriptPlanner = opts.useScriptPlanner === true;
451463
this.script = opts.script;
452464
this.graphSource = opts.graph;
@@ -862,7 +874,8 @@ export class Agent {
862874
checkpointStore: this.checkpointStore,
863875
runId: this.runId,
864876
planTools: this.tools.map((t) => t.name),
865-
signal: this.signal
877+
signal: this.signal,
878+
executionMode: this.executionMode
866879
});
867880

868881
for await (const item of executor.execute()) {
@@ -1186,7 +1199,8 @@ export class Agent {
11861199
maxTokens: this.policy.maxTokens,
11871200
maxConcurrentAgents: this.policy.maxConcurrentAgents,
11881201
maxAgentCalls: this.policy.maxAgentCalls,
1189-
signal: this.signal
1202+
signal: this.signal,
1203+
executionMode: this.executionMode
11901204
});
11911205

11921206
const runGen = runner.execute(script);
@@ -1441,7 +1455,8 @@ export class Agent {
14411455
maxTokens: this.policy.maxTokens,
14421456
maxConcurrentAgents: this.policy.maxConcurrentAgents,
14431457
parallelExecution: true,
1444-
signal: this.signal
1458+
signal: this.signal,
1459+
executionMode: this.executionMode
14451460
});
14461461

14471462
for await (const item of executor.executeTasks()) {

0 commit comments

Comments
 (0)