CodeActAgent (CodeAct)
CodeActAgent is a peer to LlmAgent that acts by writing and
running code instead of emitting one tool call at a time. Each turn the model
produces a single script; tools are exposed as callable functions the script can
compose; and the script communicates its result by returning a tagged value.
This is the CodeAct pattern: rather than call tool A → observe → call tool B,
the model writes b(a(x)) in one script, so multi-step work happens in a single
turn. It is enabled by the codeact feature on adk-agent.
When to use it
- Tasks that chain or combine several tools per turn (data wrangling, batch operations, glue logic).
- Models post-trained for code generation.
- Workflows where a real interpreter (e.g. Python) is available as the action substrate.
For native tool-calling, prefer LlmAgent. For a sandboxed
file/shell coding harness, see the Coding Agent.
How the loop works
Each turn:
- The model emits one fenced code block (a script).
- The script runs on a [
CodeRuntime]; tool calls surface to the host, which executes the tool and resumes the script with the result. - The script returns a tagged
ScriptOutput:observation— fed back to the model; the loop continues.error— fed back as a message; the loop continues.final_result— returned to the caller; the loop ends.transfer_to_agent— hands control to another agent; the loop ends.
The framework is language-agnostic: the CodeRuntime trait is the step-wise
interpreter seam, and it reports its own language/environment to the model via a
freeform prompt. The intended production adapter wraps
Monty, a Rust-native Python interpreter.
Durability: suspend and resume
CodeActAgent is stateless across invocations — durable state lives in the
session, exactly like LlmAgent. Two situations suspend the run:
- a confirmation-gated tool with no decision yet (HITL), and
- a long-running tool whose result arrives out-of-band.
On suspension, the live interpreter continuation is serialized into a
CodeActCheckpoint and written to session state; the next run() reads it back
and resumes — the confirmation decision arrives via
RunConfig::tool_confirmation_decisions, and a long-running result arrives as a
FunctionResponse in the next message. Inline tool calls are bracketed with
write-ahead (SAVE-BEFORE) and SAVE-AFTER checkpoints: once the SAVE-AFTER
checkpoint is persisted, recovery resumes with the stored result and never
re-runs the tool. A crash in the narrow window after a tool's side effect but
before its SAVE-AFTER checkpoint lands will re-run the tool on recovery, so
tools that are not idempotent should guard against that (the same at-least-once
boundary as LlmAgent).
This requires a runtime that can snapshot a paused call. A runtime that cannot runs long-running tools inline and rejects confirmation pauses.
Building a CodeActAgent
use adk_agent::codeact::CodeActAgent;
use std::sync::Arc;
// `model` implements `adk_core::Llm`; `runtime` implements `CodeRuntime`.
let agent = CodeActAgent::builder()
.name("analyst")
.model(model)
.runtime(runtime)
.instruction("Prefer concise, composable steps.")
.tool(Arc::new(load_csv_tool))
.output_key("report")
.build()?;
model and runtime are required; everything else has a default.
Parity with LlmAgent
The builder mirrors LlmAgentBuilder:
- Model:
generate_content_configplustemperature/top_p/top_k/max_output_tokensshorthands. - Instructions:
instruction/instruction_provider,global_instruction/global_instruction_provider, with{state.key}template injection; plus skills (skillsfeature). - History:
include_contents. - Tools: static
tools and per-invocationtoolsets;tool_timeout,default_retry_budget/tool_retry_budget,circuit_breaker_threshold, andon_tool_errorfallbacks. - Authorization:
ToolConfirmationPolicy(require_tool_confirmation/require_tool_confirmation_for_all). - Transfer:
sub_agents anddisallow_transfer_to_parent/disallow_transfer_to_peers. - Output:
output_key,output_schema/output_typewith a correction-retry loop (output_max_retries). - Callbacks:
before_callback/after_callback,before_model_callback/after_model_callback, andbefore_tool_callback/after_tool_callback/after_tool_callback_full. After-tool callbacks can inspect structured execution metadata viaCallbackContext::tool_outcome(). - Feature-gated: input/output guardrails (
guardrails) and theEnhancedPluginpipeline (enhanced-plugins).
Each tool call gets a fresh ToolContext that carries the interpreter call id
and delegates artifacts, memory, shared state, user scopes, and secrets to the
live invocation — so a tool behaves identically under CodeActAgent or LlmAgent.
Deliberate differences
- Code-execution sandboxing is the
CodeRuntime's responsibility, not a bolt-on. - Tool dispatch is sequential by design (a single continuation is
snapshotted at one call boundary), so there is no parallel
tool_execution_strategy. - There is no
skip_summarizationbuilder option — the model ends the loop itself viafinal_result— though a tool that setsskip_summarizationon its actions still ends the run.
Example
A runnable, dependency-free end-to-end demo — a self-contained CodeRuntime plus
a deterministic model — lives in
examples/codeact_agent:
cargo run --manifest-path examples/codeact_agent/Cargo.toml
Implementing a CodeRuntime
A CodeRuntime parses and steps a script, surfacing one external call at a time:
pub trait CodeRuntime: Send + Sync {
fn start(&self, script: &str, script_name: &str) -> Result<RunStep, RuntimeError>;
fn resume(&self, snapshot: &[u8], with: ResumeWith) -> Result<RunStep, RuntimeError>;
fn capabilities(&self) -> RuntimeCapabilities { /* default */ }
fn render_tools(&self, tools: &[Arc<dyn Tool>]) -> String { /* default */ }
}
RunStepis a set of struct variants —Call { call, stdout },Complete { value, stdout }, andRaised { message, stdout }. Construct them with theRunStep::call/RunStep::complete/RunStep::raisedhelpers and attach captured output with.with_stdout(..).RunStep::Callsurfaces exactly one pending call; resume it with a value or an error, ordump()its continuation to suspend. Thestdouta runtime attaches is surfaced back to the model and persisted into checkpoints, so it survives suspend/resume.- A
PendingCallreports its arguments the way the interpreter produced them —positional_args()andkeyword_args()separately. Do not map positional arguments onto names yourself: the driver binds them onto the tool's parameters centrally viaadk_agent::codeact::bind_call_args, so a runtime needs no tool schema at the call boundary andrender_toolscan be a pure function of the tool slice. - Script vs. host errors. Anything the model could fix by writing different
code — a syntax/parse error, an uncaught exception, a resource-limit
cancellation — is a
RunStep::Raised(an opaque string fed back to the model verbatim).RuntimeErroris reserved for genuine host failures (snapshot (de)serialization, internal interpreter errors) and aborts the run. RuntimeCapabilities::supports_suspensionmust betrueto enable HITL and long-running deferral;promptdescribes the language/environment to the model.
See examples/codeact_agent/src/runtime.rs for a complete, minimal
implementation that supports suspend/resume.
Python via Monty
The intended production adapter is
adk-codeact-monty,
a CodeRuntime backed by Pydantic Monty. It
lets the model act by writing Python, runs in-process with no container or
subprocess, and snapshots a paused run to bytes — exactly what suspend/resume
needs. It is a regular workspace member — Monty is on crates.io since 0.0.19
(crates monty, monty-types, and monty-fs) and requires rustc 1.95+.
use adk_codeact_monty::MontyRuntime;
// Conservative default resource limits (per-advance time + memory caps) make
// `new()` safe for untrusted, LLM-generated code.
let runtime = Arc::new(MontyRuntime::new());
// Tighten or relax with the builder; `unlimited()` removes the caps for
// trusted scripts only.
let runtime = Arc::new(
MontyRuntime::builder()
.max_duration(std::time::Duration::from_secs(2))
.max_memory(64 * 1024 * 1024)
.build(),
);
OS access
Operating-system effects a script attempts — filesystem reads/writes,
os.getenv/os.environ, and date.today()/datetime.now() — are serviced
in place against a host-controlled policy. They are not tools and never
pause the agent loop. By default a runtime is fully sandboxed (no filesystem
access, an empty environment, host clock enabled). Grant specific access with
the builder:
use adk_codeact_monty::{MontyRuntime, PathAccess};
let runtime = Arc::new(
MontyRuntime::builder()
// Mount host directories at virtual paths; Monty enforces the boundary
// (canonicalization + symlink-escape detection) so a script can never
// escape a mount. Reads/writes outside every mount raise PermissionError.
.allow_path("/data", "/srv/agent/data", PathAccess::ReadOnly)
.allow_path("/out", "/srv/agent/out", PathAccess::ReadWrite)
// Expose an explicit environment map to os.getenv / os.environ. Empty by
// default — the host process environment is never exposed implicitly.
.environ_var("PROJECT", "acme")
// date.today() / datetime.now() read the host clock (enabled by default).
.system_clock(true)
.build(),
);
Network and subprocess access have no Monty OS-call surface and remain unavailable regardless of policy. The granted access is described to the model in the system prompt, so it knows which paths it may read or write and which environment variables exist.
Monty implements only a subset of pathlib.Path, so when paths are mounted the
prompt lists the exact supported methods (any other raises AttributeError):
- Read/query (any mount):
exists(),is_file(),is_dir(),is_symlink(),read_text(),read_bytes(),stat(),iterdir(),resolve(),absolute(),open("r"). - Write (read-write mounts only):
write_text(),write_bytes(),append_text(),append_bytes(),mkdir(),unlink(),rmdir(),rename(),open("w")/open("a"). - Pure path ops (no I/O): the
/operator andjoinpath(),is_absolute(),with_name(),with_stem(),with_suffix(),as_posix(), and the.name,.parent,.stem,.suffix,.suffixes,.partsproperties.
Tools are invoked through a single built-in function,
call_tool("name", {"arg": value, ...}) — the only way to call a tool; they are
never in scope as bare callables. The tool name is a string literal and every
argument is a string-keyed entry in one dict, so the real name travels inside the
serialized continuation (surviving suspend/resume with no host-side name table),
a tool and each argument may carry any name (not a valid Python identifier like
"fetch-cart", a Python keyword, or even "call_tool"), and the driver binds the
dict's entries by name exactly — no positional inference. Each tool appears in the
prompt as a call_tool("name", {...}) usage line with its parameters and
description. Anything but this one form — a bare fetch_cart(...), keyword
arguments, a non-dict argument, or a non-string key — is refused with a corrective
error rather than silently dispatched, so the model has exactly one calling form
to learn.
The runnable
examples/codeact_monty_agent
drives a CodeActAgent against real Python entirely offline.