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
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]>
308 lines
12 KiB
Rust
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());
|
|
}
|
|
}
|