open-standards/eoc/eoc-rs/crates/eoc-agent/src/tot.rs
Transaction Science 94681289e6 ingest wave A2: 3 crates · op-dispute-evidence + smart-byte-edv + eoc-agent
OpenPay (1 crate, ~2287 LOC, 15 tests)
  op-dispute-evidence  Chargeback lifecycle + evidence packaging across Visa
                       VCR (10.x/11.x/12.x/13.x), Mastercard Mastercom
                       (4837/4853/1240/1442), Amex SafeKey + DRR, Discover DRR,
                       PayPal. CE3.0 compelling-evidence qualifier (120-365d
                       window, ≥2 qualifiers, ≥2 linking matches, 10.4-only).
                       Phase state machine + ReasonCodeCatalog + sealed
                       EvidencePackageBuilder + win-rate scoring.

Smart Byte (1 crate, ~2479 LOC, 44 tests)
  smart-byte-edv       Encrypted Data Vaults (DIF v0.10).
                       JWE ECDH-ES P-256 + HKDF-SHA256-Concat-KDF + AES-256-GCM,
                       multi-recipient; HMAC-SHA-256 encrypted index with
                       AND-equality queries; full ZCAP-LD chain validation;
                       chunked AES-256-GCM streaming with per-chunk AAD;
                       Vault trait + InMemoryVault + one-way pull replication.

EOC (1 crate, ~2065 LOC, 29 tests)
  eoc-agent            Agentic loops with per-step joule accounting.
                       ReAct, Reflexion (PASS/FAIL judge + episodic memory),
                       Tree of Thoughts (BFS/DFS + self-scoring), LATS
                       (MCTS-UCT select/expand/simulate/backprop),
                       Plan-and-Execute, CodeAct (fenced-block + pluggable
                       runtime). Budget enforcement (tokens/joules/tool-calls),
                       trace spans, JouleCounter integration.

Three other batch-5 deferrals (op-ewallets-asia, smart-byte-zk, eoc-rag)
will land in wave A3.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-24 11:42:26 -04:00

236 lines
7.2 KiB
Rust

//! Tree of Thoughts — BFS / DFS with a self-scoring expansion.
//!
//! At each node the agent asks the LLM for `k` candidate continuations,
//! then scores them in a second prompt. The frontier is expanded under
//! the search strategy until a `Finish` candidate appears or the
//! [`Budget`](crate::agent::Budget) is exhausted.
//!
//! The branching factor `k` and the depth cap are part of the search
//! state, not the budget — the budget caps *total* steps and energy.
use std::collections::VecDeque;
use std::sync::Arc;
use async_trait::async_trait;
use crate::agent::{Agent, AgentLoop, Budget, LlmProvider, StopReason};
use crate::error::AgentResult;
use crate::trace::SpanKind;
/// Search strategy.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Search {
/// Breadth-first frontier expansion.
Bfs,
/// Depth-first frontier expansion.
Dfs,
}
/// One node in the thought tree.
#[derive(Debug, Clone)]
pub struct Thought {
/// Index of this node within the tree.
pub id: usize,
/// Parent index (`None` for the root).
pub parent: Option<usize>,
/// Text the model emitted at this node.
pub text: String,
/// Self-assessed score (higher = more promising).
pub score: f32,
/// Depth (root = 0).
pub depth: usize,
/// Is this a `Finish` node?
pub finished: bool,
}
/// ToT driver.
pub struct ToTLoop {
provider: Arc<dyn LlmProvider>,
inner: AgentLoop,
/// Branching factor.
pub branch: usize,
/// Max depth.
pub max_depth: usize,
/// Search order.
pub search: Search,
nodes: Vec<Thought>,
}
impl ToTLoop {
/// Build.
pub fn new(
provider: Arc<dyn LlmProvider>,
budget: Budget,
branch: usize,
max_depth: usize,
search: Search,
) -> Self {
Self {
provider,
inner: AgentLoop::new(budget),
branch: branch.max(1),
max_depth: max_depth.max(1),
search,
nodes: Vec::new(),
}
}
/// Borrow the tree (read-only).
pub fn nodes(&self) -> &[Thought] {
&self.nodes
}
fn expand_prompt(&self, goal: &str, parent: Option<&Thought>) -> String {
match parent {
None => format!(
"Goal: {goal}\n\nPropose one next thought. End with `FINISH: answer` if you can solve it now."
),
Some(p) => format!(
"Goal: {goal}\nCurrent thought: {t}\n\nPropose one continuation. End with `FINISH: answer` if you can solve it now.",
t = p.text
),
}
}
fn score_prompt(text: &str) -> String {
format!(
"Rate the promise of this thought on a 0.0-1.0 scale. Reply with just the number.\n\nThought: {text}"
)
}
pub(crate) fn parse_score(text: &str) -> f32 {
for tok in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
if let Ok(v) = tok.parse::<f32>() {
return v.clamp(0.0, 1.0);
}
}
0.5
}
pub(crate) fn is_finish(text: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim();
if let Some(rest) = t.strip_prefix("FINISH:") {
return Some(rest.trim().to_string());
}
}
None
}
/// Run the search loop directly (instead of via the `Agent` trait)
/// when the caller wants to inspect the tree.
pub async fn search(&mut self, goal: &str) -> AgentResult<(String, StopReason)> {
// Frontier of node-ids waiting to be expanded.
let mut frontier: VecDeque<usize> = VecDeque::new();
let mut best_text = String::new();
// Root.
self.nodes.push(Thought {
id: 0,
parent: None,
text: String::from("(root)"),
score: 0.0,
depth: 0,
finished: false,
});
frontier.push_back(0);
let mut iter = 0usize;
while let Some(parent_id) = match self.search {
Search::Bfs => frontier.pop_front(),
Search::Dfs => frontier.pop_back(),
} {
if let Some(stop) = self.inner.iteration_check(iter) {
return Ok((best_text, stop));
}
iter = iter.saturating_add(1);
let parent_depth = self.nodes[parent_id].depth;
if parent_depth >= self.max_depth {
continue;
}
for _ in 0..self.branch {
let parent_snap = self.nodes[parent_id].clone();
let prompt = self.expand_prompt(goal, Some(&parent_snap));
let reply = self.provider.complete(&prompt).await?;
if let Some(stop) = self.inner.charge_llm_step(
SpanKind::Expand,
"tot.expand",
&prompt,
&reply,
) {
return Ok((best_text, stop));
}
let finished_answer = Self::is_finish(&reply.text);
let score_prompt = Self::score_prompt(&reply.text);
let score_reply = self.provider.complete(&score_prompt).await?;
if let Some(stop) = self.inner.charge_llm_step(
SpanKind::Think,
"tot.score",
&score_prompt,
&score_reply,
) {
return Ok((best_text, stop));
}
let score = Self::parse_score(&score_reply.text);
let new_id = self.nodes.len();
let depth = parent_depth + 1;
let finished = finished_answer.is_some();
let text = reply.text.clone();
self.nodes.push(Thought {
id: new_id,
parent: Some(parent_id),
text: text.clone(),
score,
depth,
finished,
});
if let Some(ans) = finished_answer {
return Ok((ans, StopReason::Done));
}
if score >= self.nodes.iter().map(|n| n.score).fold(0.0_f32, f32::max) {
best_text = text;
}
if depth < self.max_depth {
frontier.push_back(new_id);
}
}
}
Ok((best_text, StopReason::EarlyStop("frontier_empty".into())))
}
}
#[async_trait]
impl Agent for ToTLoop {
async fn run(&mut self, goal: &str) -> AgentResult<(String, StopReason)> {
self.search(goal).await
}
fn trace(&self) -> &crate::trace::Trace {
&self.inner.trace
}
fn budget(&self) -> &Budget {
&self.inner.budget
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_score_extracts_number() {
assert!((ToTLoop::parse_score("0.7") - 0.7).abs() < 1e-6);
assert!((ToTLoop::parse_score("Score: 0.42 thanks") - 0.42).abs() < 1e-6);
assert!((ToTLoop::parse_score("bogus") - 0.5).abs() < 1e-6);
}
#[test]
fn is_finish_matches() {
assert_eq!(ToTLoop::is_finish("FINISH: 42"), Some("42".into()));
assert!(ToTLoop::is_finish("not done yet").is_none());
}
}