eoc-agent: a local model's measured step is a measured span #127
5 changed files with 244 additions and 20 deletions
2
eoc/eoc-rs/Cargo.lock
generated
2
eoc/eoc-rs/Cargo.lock
generated
|
|
@ -734,7 +734,9 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"async-trait",
|
||||
"eoc-core",
|
||||
"eoc-local",
|
||||
"eoc-meter",
|
||||
"eoc-neural",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.20",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ description = "EOC — agentic loops (ReAct, Reflexion, ToT, LATS, Plan-and-Exec
|
|||
[dependencies]
|
||||
eoc-core.workspace = true
|
||||
eoc-meter.workspace = true
|
||||
eoc-neural.workspace = true
|
||||
|
||||
serde = { workspace = true }
|
||||
serde_json = "1"
|
||||
|
|
@ -19,4 +20,5 @@ async-trait.workspace = true
|
|||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
|
||||
[dev-dependencies]
|
||||
eoc-local = { path = "../eoc-local", features = ["mlc"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
|
|
|
|||
|
|
@ -25,13 +25,19 @@ use crate::trace::{Span, SpanKind, Trace};
|
|||
|
||||
/// A bare-bones vendor-neutral LLM completion port.
|
||||
///
|
||||
/// Production deployments wire this to [`eoc_vendor_api`] backends; tests
|
||||
/// supply scripted implementations.
|
||||
/// A provider that calls a model over the network wires this to an
|
||||
/// `eoc-vendor-api` client. One that runs a model in this process wraps an
|
||||
/// [`eoc_neural::NeuralBackend`] in a
|
||||
/// [`NeuralProvider`](crate::provider::NeuralProvider). Tests supply
|
||||
/// scripted implementations.
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// Run a single completion. Implementations *should* return their
|
||||
/// best estimate of `prompt_tokens + completion_tokens` and the
|
||||
/// estimated micro-joules attributable to the call.
|
||||
/// Run a single completion. Implementations return
|
||||
/// `prompt_tokens + completion_tokens` and the energy of the call with
|
||||
/// its provenance: [`JouleCost::measured`] only when a live counter read
|
||||
/// the call and covers where the model ran, [`JouleCost::estimated`]
|
||||
/// otherwise. A model served over the network ran on hardware no counter
|
||||
/// here reads, so a remote call's figure is an estimate.
|
||||
async fn complete(&self, prompt: &str) -> AgentResult<LlmReply>;
|
||||
}
|
||||
|
||||
|
|
@ -42,18 +48,20 @@ pub struct LlmReply {
|
|||
pub text: String,
|
||||
/// Total tokens (prompt + completion).
|
||||
pub tokens: u64,
|
||||
/// Energy estimate (micro-joules).
|
||||
pub microjoules: u64,
|
||||
/// The energy of the call and how the provider came by it.
|
||||
pub cost: JouleCost,
|
||||
}
|
||||
|
||||
impl LlmReply {
|
||||
/// Convenience constructor.
|
||||
/// A reply whose energy is the provider's estimate, `microjoules`.
|
||||
pub fn new(text: impl Into<String>, tokens: u64, microjoules: u64) -> Self {
|
||||
Self {
|
||||
text: text.into(),
|
||||
tokens,
|
||||
microjoules,
|
||||
}
|
||||
Self::with_cost(text, tokens, JouleCost::estimated(microjoules))
|
||||
}
|
||||
|
||||
/// A reply whose energy is `cost`, with the provenance the provider
|
||||
/// gives it.
|
||||
pub fn with_cost(text: impl Into<String>, tokens: u64, cost: JouleCost) -> Self {
|
||||
Self { text: text.into(), tokens, cost }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -338,9 +346,10 @@ impl AgentLoop {
|
|||
/// Charge an LLM step against the budget *and* record the span.
|
||||
/// Returns the stop reason if the budget is exhausted.
|
||||
///
|
||||
/// The step's figure is the provider's own, [`LlmReply::microjoules`],
|
||||
/// an estimate by [`LlmProvider`]'s contract that no counter here read,
|
||||
/// so the span is [`JouleSource::Estimated`](eoc_core::JouleSource).
|
||||
/// The step's figure is the provider's own, [`LlmReply::cost`], with
|
||||
/// the provenance the provider gave it: a span is
|
||||
/// [`JouleSource::Measured`](eoc_core::JouleSource) only when the
|
||||
/// provider's counter read the call.
|
||||
pub fn charge_llm_step(
|
||||
&mut self,
|
||||
kind: SpanKind,
|
||||
|
|
@ -350,9 +359,9 @@ impl AgentLoop {
|
|||
) -> Option<StopReason> {
|
||||
let span = Span::new(self.trace.len(), kind, label, input, reply.text.clone())
|
||||
.with_tokens(reply.tokens)
|
||||
.with_joule_cost(JouleCost::estimated(reply.microjoules));
|
||||
.with_joule_cost(reply.cost);
|
||||
self.push_span(span);
|
||||
self.budget.charge_llm(reply.tokens, reply.microjoules)
|
||||
self.budget.charge_llm(reply.tokens, reply.cost.microjoules)
|
||||
}
|
||||
|
||||
/// Check the hard iteration cap.
|
||||
|
|
|
|||
|
|
@ -4,8 +4,11 @@
|
|||
//! through *think → act → observe* cycles, sometimes branching, sometimes
|
||||
//! reflecting, sometimes planning ahead. EOC takes that loop and threads
|
||||
//! a **joule meter** through every step, so an agent's total cost is the
|
||||
//! sum of (a) provider-reported token costs, (b) tool-execution wall
|
||||
//! energy, and (c) any local inference performed between steps.
|
||||
//! sum of (a) its model steps, each at the figure and grade its provider
|
||||
//! reports: measured when a model running in this process was read by a
|
||||
//! live counter ([`NeuralProvider`]), estimated for a model served over the
|
||||
//! network; (b) tool-execution energy; and (c) any local inference performed
|
||||
//! between steps.
|
||||
//!
|
||||
//! ## Loops shipped
|
||||
//!
|
||||
|
|
@ -37,6 +40,7 @@ pub mod codeact;
|
|||
pub mod error;
|
||||
pub mod lats;
|
||||
pub mod plan_execute;
|
||||
pub mod provider;
|
||||
pub mod react;
|
||||
pub mod reflexion;
|
||||
pub mod tot;
|
||||
|
|
@ -48,6 +52,7 @@ pub use codeact::CodeActLoop;
|
|||
pub use error::{AgentError, AgentResult};
|
||||
pub use lats::{LatsConfig, LatsLoop};
|
||||
pub use plan_execute::{PlanExecuteLoop, Planner, Worker};
|
||||
pub use provider::NeuralProvider;
|
||||
pub use react::{ReActLoop, ReActStep};
|
||||
pub use reflexion::{ReflexionLoop, ReflexionMemory};
|
||||
pub use tot::{Search, ToTLoop, Thought};
|
||||
|
|
|
|||
206
eoc/eoc-rs/crates/eoc-agent/src/provider.rs
Normal file
206
eoc/eoc-rs/crates/eoc-agent/src/provider.rs
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
//! [`NeuralProvider`]: a model that runs in this process, as an
|
||||
//! [`LlmProvider`].
|
||||
//!
|
||||
//! A backend that runs inference here (an `eoc-local` backend, or any other
|
||||
//! [`NeuralBackend`]) meters its own span and grades the figure: measured
|
||||
//! when its counter read the inference and advanced over it, estimated
|
||||
//! otherwise. A loop driven through this provider records that figure with
|
||||
//! that grade, so a model step a live counter measured is a measured span.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eoc_core::Query;
|
||||
use eoc_neural::NeuralBackend;
|
||||
|
||||
use crate::agent::{LlmProvider, LlmReply};
|
||||
use crate::error::AgentResult;
|
||||
|
||||
/// An in-process [`NeuralBackend`] as an [`LlmProvider`]. The reply's text is
|
||||
/// the backend's payload and its energy is the backend's own
|
||||
/// [`JouleCost`](eoc_core::JouleCost), grade and all.
|
||||
///
|
||||
/// A [`eoc_core::Response`] carries no token count, so the reply's tokens
|
||||
/// are counted from the text, prompt and completion, by `count_tokens`:
|
||||
/// [`approx_tokens`] unless [`Self::with_token_counter`] gives the backend's
|
||||
/// own tokenizer. Tokens feed the loop's token budget, never its energy
|
||||
/// figure.
|
||||
pub struct NeuralProvider<B> {
|
||||
backend: B,
|
||||
count_tokens: fn(&str) -> u64,
|
||||
}
|
||||
|
||||
impl<B: NeuralBackend> NeuralProvider<B> {
|
||||
/// Drive `backend`, counting tokens with [`approx_tokens`].
|
||||
pub fn new(backend: B) -> Self {
|
||||
Self {
|
||||
backend,
|
||||
count_tokens: approx_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
/// Count tokens with `count` instead, such as the model's tokenizer.
|
||||
pub fn with_token_counter(mut self, count: fn(&str) -> u64) -> Self {
|
||||
self.count_tokens = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// The backend.
|
||||
pub fn backend(&self) -> &B {
|
||||
&self.backend
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<B: NeuralBackend> LlmProvider for NeuralProvider<B> {
|
||||
async fn complete(&self, prompt: &str) -> AgentResult<LlmReply> {
|
||||
let response = self.backend.infer(&Query::new(prompt)).await;
|
||||
let tokens =
|
||||
(self.count_tokens)(prompt).saturating_add((self.count_tokens)(&response.payload));
|
||||
Ok(LlmReply::with_cost(
|
||||
response.payload,
|
||||
tokens,
|
||||
response.joule_cost,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// About one token per four characters, rounded up, and at least one for
|
||||
/// any text that is not empty: the usual rule of thumb for English with a
|
||||
/// BPE vocabulary.
|
||||
pub fn approx_tokens(text: &str) -> u64 {
|
||||
(text.chars().count() as u64).div_ceil(4)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use eoc_core::{JouleCost, JouleSource, Response, Stage};
|
||||
use eoc_local::mlc::MlcBackend;
|
||||
use eoc_meter::{JouleCounter, StubCounter};
|
||||
use eoc_neural::EchoBackend;
|
||||
|
||||
use super::*;
|
||||
use crate::agent::scripted::Scripted;
|
||||
use crate::agent::{Agent, AgentLoop, Budget, StopReason, ToolBox};
|
||||
use crate::react::ReActLoop;
|
||||
use crate::trace::SpanKind;
|
||||
|
||||
/// A backend that answers `text` at a fixed cost.
|
||||
struct Fixed {
|
||||
text: &'static str,
|
||||
cost: JouleCost,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl NeuralBackend for Fixed {
|
||||
async fn infer(&self, q: &Query) -> Response {
|
||||
Response::new(q.id, self.text.to_string(), Stage::Neural, self.cost)
|
||||
}
|
||||
}
|
||||
|
||||
fn mlc(meter: Arc<dyn JouleCounter>) -> NeuralProvider<MlcBackend> {
|
||||
let backend = MlcBackend::new(std::env::temp_dir(), meter)
|
||||
.expect("the temp dir exists")
|
||||
.with_max_tokens(16);
|
||||
NeuralProvider::new(backend)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_reply_carries_the_backends_figure_and_grade() {
|
||||
let measured = NeuralProvider::new(Fixed {
|
||||
text: "hi",
|
||||
cost: JouleCost::measured(450),
|
||||
});
|
||||
let reply = measured.complete("say hi").await.unwrap();
|
||||
assert_eq!(
|
||||
(reply.text.as_str(), reply.cost),
|
||||
("hi", JouleCost::measured(450))
|
||||
);
|
||||
assert_eq!(reply.tokens, approx_tokens("say hi") + approx_tokens("hi"));
|
||||
|
||||
let echo = NeuralProvider::new(EchoBackend {
|
||||
estimated_microjoules: 7_000,
|
||||
prefix: String::new(),
|
||||
});
|
||||
assert_eq!(
|
||||
echo.complete("x").await.unwrap().cost,
|
||||
JouleCost::estimated(7_000)
|
||||
);
|
||||
}
|
||||
|
||||
/// A local backend metered by a counter that advanced over the
|
||||
/// inference: the loop records a measured span of what the counter read,
|
||||
/// and charges the budget that. Where the counter held, failed or is the
|
||||
/// stub, the span is the backend's estimate, graded estimated.
|
||||
#[tokio::test]
|
||||
async fn a_local_backends_measured_inference_is_a_measured_span() {
|
||||
let cases: [(Arc<dyn JouleCounter>, Option<u64>); 4] = [
|
||||
(
|
||||
Arc::new(Scripted::new(&[Some(1_000), Some(1_450)])),
|
||||
Some(450),
|
||||
),
|
||||
(Arc::new(Scripted::new(&[Some(7_000)])), None),
|
||||
(Arc::new(Scripted::new(&[None, Some(9_000_000)])), None),
|
||||
(Arc::new(StubCounter), None),
|
||||
];
|
||||
for (meter, expected) in cases {
|
||||
let reply = mlc(meter).complete("prompt").await.unwrap();
|
||||
let mut lp = AgentLoop::new(Budget::unlimited(4));
|
||||
assert_eq!(
|
||||
lp.charge_llm_step(SpanKind::Think, "think", "prompt", &reply),
|
||||
None
|
||||
);
|
||||
let span = &lp.trace.spans[0];
|
||||
match expected {
|
||||
Some(uj) => {
|
||||
assert_eq!(
|
||||
(span.microjoules, span.joule_source),
|
||||
(uj, JouleSource::Measured),
|
||||
"{span:?}"
|
||||
);
|
||||
}
|
||||
None => {
|
||||
assert_eq!(span.joule_source, JouleSource::Estimated, "{span:?}");
|
||||
assert!(
|
||||
span.microjoules > 0,
|
||||
"an inference that ran estimated at 0: {span:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(lp.budget.joules.used_microjoules, span.microjoules);
|
||||
assert_eq!(lp.budget.tokens.used, reply.tokens);
|
||||
}
|
||||
}
|
||||
|
||||
/// Through a whole ReAct run: the model's step is measured when the
|
||||
/// backend's counter measured it, and the loop's total is that figure.
|
||||
#[tokio::test]
|
||||
async fn a_react_run_on_a_metered_local_model_records_the_model_step_measured() {
|
||||
let provider = NeuralProvider::new(Fixed {
|
||||
text: "Thought: done\nAction: Finish[42]",
|
||||
cost: JouleCost::measured(2_500),
|
||||
});
|
||||
let mut agent =
|
||||
ReActLoop::new(Arc::new(provider), ToolBox::new(), Budget::unlimited(4), "");
|
||||
let (answer, stop) = agent.run("the answer").await.unwrap();
|
||||
assert_eq!((answer.as_str(), stop), ("42", StopReason::Done));
|
||||
let model: Vec<_> = agent
|
||||
.trace()
|
||||
.spans
|
||||
.iter()
|
||||
.filter(|s| s.joule_source == JouleSource::Measured)
|
||||
.collect();
|
||||
assert_eq!(model.len(), 1, "{:?}", agent.trace().spans);
|
||||
assert_eq!(model[0].microjoules, 2_500);
|
||||
assert_eq!(agent.budget().joules.used_microjoules, 2_500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approx_tokens_rounds_up() {
|
||||
assert_eq!(approx_tokens(""), 0);
|
||||
assert_eq!(approx_tokens("a"), 1);
|
||||
assert_eq!(approx_tokens("abcd"), 1);
|
||||
assert_eq!(approx_tokens("abcde"), 2);
|
||||
assert_eq!(approx_tokens("ééééé"), 2, "characters, not bytes");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue