open-standards/eoc/eoc-rs/crates/eoc-agent/src/codeact.rs
Transaction Science 1352aeea07
All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m39s
standards workspaces build + test / aac-rs (pull_request) Successful in 35s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 35s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 32s
standards workspaces build + test / map-rs (pull_request) Successful in 29s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m50s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m18s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m15s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m8s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m33s
standards workspaces build + test / arl-rs (pull_request) Successful in 2m19s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 2m49s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 2m51s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 2m48s
standards workspaces build + test / sense-rs (pull_request) Successful in 2m50s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m49s
eoc-agent: every span says whether its joule figure was measured or estimated
A trace `Span` carried a micro-joule figure and nothing about where it came
from. A provider's own figure, a tool step's duration estimate, a zero for an
observation, and a delta a live counter read all looked the same, so nothing
reading a trace could tell a measurement from an estimate. Every other eoc
surface that reports energy says which it is (`JouleCost`, eoc-eval's
`joule_source`); an agent trace did not.

`Span` gains `joule_source: JouleSource`, eoc-core's tag, under eoc-core's
rule: `Measured` only for what a live counter read over the step — both reads
succeeded and the counter advanced — and `Estimated` for anything else.

- The field is additive on the wire. It is `#[serde(default)]`, as eoc-eval's
  report field is: a trace written before it reads back with every span
  `Estimated`, since nothing in it says it was measured. Nothing in the
  monorepo outside eoc-agent uses eoc-agent, so no struct literal of `Span`
  breaks.
- `Span::new` is `Estimated` (0 µJ that no counter read). `with_joule_cost`
  attaches a figure and its tag together, and `joule_cost()` reads them back.
  `with_microjoules` attaches a bare figure, which is an estimate: it no
  longer leaves an earlier `Measured` tag on a figure the counter did not read.
- `AgentLoop::measured_with` and `measured_tool` return a `JouleCost` instead
  of a bare `u64`. The figure is unchanged — the reading, or the estimate when
  larger, or the estimate alone — and it is `Measured` exactly when it is the
  reading: a reading smaller than the estimate is not what is charged, so the
  estimate charged in its place is `Estimated`. `measured` still returns the
  bare figure, as on main. Both changed functions are new on this branch.
- Model steps: `charge_llm_step` tags the provider's own figure `Estimated`;
  `LlmProvider`'s contract calls it an estimate and no counter here read it.
  This covers every loop's model steps (ReAct, CodeAct, Reflexion, ToT, LATS).
- Tool steps: ReAct and CodeAct put `measured_tool`'s tag on the act span, and
  Plan-and-Execute on its plan and execute spans.
- Other steps: ReAct's observation of an unknown tool is `Span::new`'s
  estimated 0, as nothing ran.

Tests:
- a new span and a bare figure are estimates; a measured cost survives
  `with_joule_cost`, and a bare figure attached after it is an estimate;
- a trace round-trips through JSON with a `Measured` and an `Estimated` span,
  and a trace in the old format, without the field, reads back all
  `Estimated`;
- `measured_tool`: the stub, a failed first or last read, a counter that held
  or went down, and one that advanced 10 µJ over a 20 ms step are all
  `Estimated` and charged at least the 300,000 µJ estimate; a 50 J reading is
  `Measured`. `measured_with` at a fixed estimate: a 450 µJ reading is
  `Measured` at estimates of 100 and 450, and an estimate of 1,000 is charged
  as `Estimated`;
- `charge_llm_step` records the reply's figure as `Estimated`;
- ReAct, with a counter that advances 50 J over the tool step: think spans
  `Estimated` at 1,000 µJ, the unknown tool's observation `Estimated` at 0,
  the act span `Measured` at 50 J, and the budget 3,000 µJ plus 50 J; with
  the stub, every span `Estimated`;
- CodeAct, the same over code that succeeds and code that fails;
- Plan-and-Execute: with the stub every span `Estimated`; with the scripted
  meter the plan step (40 J) and worker `b` (50 J) `Measured`, and worker `a`,
  over which the counter held, `Estimated`.

Falsified, each mutant applied to this commit, run, and restored with
`git checkout`:
- every span pushed to a trace tagged `Measured`: 8 tests fail;
- `Span::new` and `with_joule_cost` always `Measured`: 9 fail;
- every span pushed to a trace tagged `Estimated`: 4 fail;
- a span without the field reading back `Measured`: 1 fails;
- `measured_with` tagging any reading `Measured`, including when the
  estimate is what is charged: 1 fails;
- `charge_llm_step` tagging the provider's figure `Measured`: 5 fail;
- `with_microjoules` keeping an earlier tag: 1 fails;
- ReAct, CodeAct, and Plan-and-Execute each dropping the tag
  (`with_microjoules(cost.microjoules)`): 1 fails in each.

`cargo test -p eoc-agent`: 38 unit and 9 integration tests pass.
The eoc workspace (`cargo test --workspace`): 695 passed, 0 failed,
3 ignored, on an M5 Max (macOS 27.0.1). clippy for x86_64-unknown-linux-gnu
on eoc-agent: the same two pre-existing warnings and no others.
On macOS too clippy adds no warning (the two collapsible-if warnings predate
this), and rustdoc reports nothing in the lines changed here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 22:38:48 -04:00

308 lines
12 KiB
Rust

//! CodeAct — tool calls expressed as code.
//!
//! Instead of structured `tool_call` JSON, the model emits a small code
//! block that the executor parses. The reference implementation here
//! supports a minimal "linelang" of `name(arg)` invocations, one per
//! line, executed sequentially. Production deployments swap in a real
//! sandboxed interpreter (Python, JS, KCL …) by replacing the
//! [`CodeRuntime`] trait.
use std::sync::Arc;
use async_trait::async_trait;
use crate::agent::{Agent, AgentLoop, Budget, LlmProvider, StopReason, Tool, ToolBox};
use crate::error::{AgentError, AgentResult};
use crate::trace::SpanKind;
/// Executor for the code blocks the model emits.
#[async_trait]
pub trait CodeRuntime: Send + Sync {
/// Run `code` and return its rendered output (everything the model
/// will see as the observation).
async fn run(&self, code: &str) -> AgentResult<String>;
}
/// The reference "linelang" runtime: each non-blank line is `name(arg)`
/// and is dispatched against a [`ToolBox`]. Arguments are passed as
/// `{"input": arg}`.
pub struct LineLangRuntime {
tools: Arc<ToolBox>,
}
impl LineLangRuntime {
/// Build.
pub fn new(tools: Arc<ToolBox>) -> Self {
Self { tools }
}
/// Parse a single line into `(name, arg)`. Lenient — a bare `name`
/// is treated as `name()`.
pub fn parse_line(line: &str) -> AgentResult<(String, String)> {
let line = line.trim();
if line.is_empty() {
return Err(AgentError::Parse("empty line".into()));
}
if let (Some(lp), Some(rp)) = (line.find('('), line.rfind(')')) {
if lp < rp {
let name = line[..lp].trim().to_string();
let arg = line[lp + 1..rp].trim().to_string();
return Ok((name, arg));
}
}
Ok((line.to_string(), String::new()))
}
}
#[async_trait]
impl CodeRuntime for LineLangRuntime {
async fn run(&self, code: &str) -> AgentResult<String> {
let mut out = String::new();
for line in code.lines() {
if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let (name, arg) = match LineLangRuntime::parse_line(line) {
Ok(p) => p,
Err(e) => {
out.push_str(&format!("parse_error: {e}\n"));
continue;
}
};
let tool = match self.tools.get(&name) {
Some(t) => t,
None => {
out.push_str(&format!("unknown_tool: {name}\n"));
continue;
}
};
let args = serde_json::json!({ "input": arg });
match Tool::run(tool.as_ref(), args).await {
Ok(v) => out.push_str(&format!("{name} => {v}\n")),
Err(e) => out.push_str(&format!("{name} error: {e}\n")),
}
}
Ok(out)
}
}
/// CodeAct driver.
pub struct CodeActLoop {
provider: Arc<dyn LlmProvider>,
runtime: Arc<dyn CodeRuntime>,
inner: AgentLoop,
}
impl CodeActLoop {
/// Build with a vendor-neutral code runtime.
pub fn new(
provider: Arc<dyn LlmProvider>,
runtime: Arc<dyn CodeRuntime>,
budget: Budget,
) -> Self {
Self {
provider,
runtime,
inner: AgentLoop::new(budget),
}
}
/// Extract the first ```code … ``` block. Returns `None` if there
/// is no fenced block — callers may then treat the whole reply as
/// the final answer.
pub fn extract_code(text: &str) -> Option<String> {
let start = text.find("```")?;
let after = &text[start + 3..];
// Skip an optional language tag on the same line.
let body_start = after.find('\n').map(|n| n + 1).unwrap_or(0);
let body = &after[body_start..];
let end = body.find("```")?;
Some(body[..end].to_string())
}
/// Detect a `FINAL: ...` line and return its body if present.
pub fn extract_final(text: &str) -> Option<String> {
for line in text.lines() {
let l = line.trim();
if let Some(rest) = l.strip_prefix("FINAL:") {
return Some(rest.trim().to_string());
}
}
None
}
}
#[async_trait]
impl Agent for CodeActLoop {
async fn run(&mut self, goal: &str) -> AgentResult<(String, StopReason)> {
let mut transcript = String::new();
let mut last_output = String::new();
for iter in 0..self.inner.budget.max_iterations {
if let Some(stop) = self.inner.iteration_check(iter) {
return Ok((last_output, stop));
}
let prompt = if transcript.is_empty() {
format!(
"Goal: {goal}\n\nReply with a ``` fenced code block of tool calls (one `name(arg)` per line), or `FINAL: answer` when done."
)
} else {
format!(
"Goal: {goal}\n\nTranscript:\n{transcript}\n\nReply with a ``` fenced code block of tool calls (one `name(arg)` per line), or `FINAL: answer` when done."
)
};
let reply = self.provider.complete(&prompt).await?;
last_output = reply.text.clone();
if let Some(stop) = self.inner.charge_llm_step(
SpanKind::Think,
"codeact.think",
&prompt,
&reply,
) {
return Ok((last_output, stop));
}
if let Some(ans) = Self::extract_final(&reply.text) {
return Ok((ans, StopReason::Done));
}
let code = match Self::extract_code(&reply.text) {
Some(c) => c,
None => {
return Ok((last_output, StopReason::Error("no code block in reply".into())));
}
};
// Charged what the meter measured, or at least the step's
// duration estimate: code that ran is never 0 µJ. The span says
// which.
let (out_res, cost) = self
.inner
.measured_tool(|| async { self.runtime.run(&code).await })
.await;
let out = match out_res {
Ok(o) => o,
Err(e) => format!("runtime_error: {e}"),
};
if let Some(stop) = self.inner.budget.charge_tool(cost.microjoules) {
return Ok((last_output, stop));
}
self.inner.push_span(
crate::trace::Span::new(0, SpanKind::Act, "codeact.exec", code.clone(), out.clone())
.with_joule_cost(cost),
);
transcript.push_str(&format!("CODE:\n{code}\nOBSERVATION:\n{out}\n"));
}
Ok((last_output, StopReason::MaxIterations))
}
fn trace(&self) -> &crate::trace::Trace {
&self.inner.trace
}
fn budget(&self) -> &Budget {
&self.inner.budget
}
}
#[cfg(test)]
mod tests {
use super::*;
use eoc_core::{JouleCost, JouleSource};
#[test]
fn extract_code_finds_block() {
let s = "blah\n```python\nfoo(1)\nbar(2)\n```\ntail";
let code = CodeActLoop::extract_code(s).unwrap();
assert!(code.contains("foo(1)"));
assert!(code.contains("bar(2)"));
}
#[test]
fn extract_final_finds_answer() {
assert_eq!(
CodeActLoop::extract_final("FINAL: 42"),
Some("42".into())
);
assert!(CodeActLoop::extract_final("not done").is_none());
}
/// A provider that replies with a script, 1,000 µJ a reply.
struct Replies(std::sync::Mutex<Vec<&'static str>>);
#[async_trait]
impl LlmProvider for Replies {
async fn complete(&self, _prompt: &str) -> AgentResult<crate::agent::LlmReply> {
let text = self.0.lock().unwrap().remove(0);
Ok(crate::agent::LlmReply::new(text, 5, 1_000))
}
}
/// A runtime that takes at least 20 ms, and fails or not as told.
struct SlowRuntime(bool);
#[async_trait]
impl CodeRuntime for SlowRuntime {
async fn run(&self, code: &str) -> AgentResult<String> {
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
if self.0 {
Err(AgentError::Tool { tool: "runtime".into(), reason: format!("failed: {code}") })
} else {
Ok(format!("ran {code}"))
}
}
}
/// Code the meter does not measure (the host default, the stub) is
/// charged its duration estimate — at least 20 ms at the default power for
/// a runtime that runs at least 20 ms — on the span and against the
/// budget, whether it succeeded or failed: it ran either way.
#[tokio::test]
async fn unmetered_code_is_charged_its_duration_estimate() {
let floor = crate::agent::duration_estimate(std::time::Duration::from_millis(20), crate::agent::DEFAULT_TOOL_WATTS);
for fails in [false, true] {
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"])));
let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), Budget::unlimited(10));
let (out, stop) = agent.run("go").await.unwrap();
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span");
assert!(act.microjoules >= floor, "fails={fails}: 20 ms of code charged {} µJ", act.microjoules);
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules);
for span in &agent.trace().spans {
assert_eq!(span.joule_source, JouleSource::Estimated, "fails={fails}: {span:?}");
}
}
}
/// Code over which the counter advanced 50 J is measured, and charged
/// that, whether it succeeded or failed; the model steps around it (the
/// provider's own figures) are estimates.
#[tokio::test]
async fn metered_code_is_measured_and_model_steps_are_estimated() {
for fails in [false, true] {
let meter = Arc::new(crate::agent::scripted::Scripted::new(&[Some(0), Some(50_000_000)]));
let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"])));
let budget = Budget::unlimited(10);
let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), budget);
agent.inner = AgentLoop::with_meter(budget, meter);
let (out, stop) = agent.run("go").await.unwrap();
assert_eq!((out.as_str(), stop), ("done", StopReason::Done));
let costs: Vec<_> = agent.trace().spans.iter().map(|s| (s.kind, s.joule_cost())).collect();
assert_eq!(
costs,
[
(SpanKind::Think, JouleCost::estimated(1_000)),
(SpanKind::Act, JouleCost::measured(50_000_000)),
(SpanKind::Think, JouleCost::estimated(1_000)),
],
"fails={fails}"
);
assert_eq!(agent.budget().joules.used_microjoules, 2_000 + 50_000_000);
}
}
#[test]
fn parse_line_lenient() {
let (n, a) = LineLangRuntime::parse_line("calc(1+1)").unwrap();
assert_eq!(n, "calc");
assert_eq!(a, "1+1");
let (n, a) = LineLangRuntime::parse_line("noop").unwrap();
assert_eq!(n, "noop");
assert!(a.is_empty());
}
}