use crate::err_run;
use areev_core::error::{Hash, Result};
use areev_core::types::Grain;
use areev_run_core::{Budgets, NodeExecutor, PlanGraph, RunError};
use areev_store::Areev;
use serde_json::json;
use std::collections::BTreeMap;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ForkBase {
pub base_run: String,
pub base_checkpoint: String,
pub base_superstep: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct PinnedTool {
pub node: String,
pub tool_hash: String,
pub tool_name: String,
pub executor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub executor_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub runtime_limits: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub capabilities: Option<serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct RunManifest {
pub run_id: String,
pub plan_hash: String,
pub principal: String,
pub pinned: Vec<PinnedTool>,
pub budgets: BudgetsSpec,
pub ask_ttl_sec: Option<i64>,
pub redaction: String,
pub input: serde_json::Value,
#[serde(default)]
pub llm_max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_effects_per_attempt: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_tool_result_chars: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_context_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub reducers: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_of: Option<ForkBase>,
}
#[derive(Debug, Clone, Copy, Default, serde::Serialize, serde::Deserialize)]
pub struct BudgetsSpec {
pub max_supersteps: Option<u64>,
pub max_tokens: Option<u64>,
pub max_usd_micros: Option<u64>,
pub max_wall_ms: Option<u64>,
pub max_storage_bytes: Option<u64>,
}
impl BudgetsSpec {
pub fn to_budgets(self) -> Budgets {
Budgets {
max_supersteps: self.max_supersteps.unwrap_or(Budgets::default().max_supersteps),
max_tokens: self.max_tokens,
max_usd_micros: self.max_usd_micros,
max_wall_ms: self.max_wall_ms,
max_storage_bytes: self.max_storage_bytes,
}
}
}
const RESULT_SHARE_OF_CEILING: u64 = 4;
const CHARS_PER_TOKEN: u64 = 4;
impl RunManifest {
#[allow(clippy::too_many_arguments)]
pub fn resolve(
m: &mut Areev,
ns: &str,
run_id: &str,
plan_hash: &Hash,
plan: &PlanGraph,
principal: &str,
budgets: BudgetsSpec,
ask_ttl_sec: Option<i64>,
input: serde_json::Value,
llm_available: bool,
) -> std::result::Result<RunManifest, RunError> {
let plan_ns =
m.get(plan_hash).ok().and_then(|g| g.get_str("namespace").map(str::to_string));
let mut pinned = Vec::with_capacity(plan.nodes.len());
for (i, node) in plan.nodes.iter().enumerate() {
let resolved = match &plan.bindings[i] {
Some(hash_str) => {
let h = Hash::from_hex(hash_str).map_err(|_| RunError::UnresolvedRef {
what: format!("binding for node '{node}' is not a content address"),
})?;
let g = m.get(&h).map_err(|e| RunError::UnresolvedRef {
what: format!("binding for node '{node}': {e}"),
})?;
if g.grain_type == areev_core::types::GrainType::Workflow {
PinnedTool {
node: node.clone(),
tool_hash: h.to_hex(),
tool_name: node.clone(),
executor: "subgraph".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
capabilities: None,
}
} else {
pin_from_definition(node, &h, &g)?
}
}
None => {
match find_definition_by_name(m, ns, node) {
Some((h, g)) => pin_from_definition(node, &h, &g)?,
None if misrouted(m, ns, plan_ns.as_deref(), node) => {
let pns = plan_ns.as_deref().unwrap_or_default();
return Err(RunError::UnresolvedRef {
what: format!(
"node '{node}' has no binding, and namespace '{ns}' — where \
this run reads and journals — holds no Tool Definition \
named '{node}'. The plan grain itself lives in namespace \
'{pns}', which does. Start the run there (`--ns {pns}`), \
or bind the node to a Definition by hash so it resolves \
from any namespace"
),
});
}
None if llm_available => PinnedTool {
node: node.clone(),
tool_hash: String::new(),
tool_name: node.clone(),
executor: "abstract".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
capabilities: None,
},
None => return Err(RunError::NoToolLlm { node: node.clone() }),
}
}
};
pinned.push(resolved);
}
let mut reducers: BTreeMap<String, String> = BTreeMap::new();
if let Ok(wf_grain) = m.get(plan_hash) {
if let Some(table) = wf_grain.fields.get("reducers").and_then(|v| v.as_object()) {
for (key, name) in table {
let Some(name) = name.as_str() else {
return Err(RunError::InvalidPlan {
why: format!("reducer for state key '{key}' is not a string"),
});
};
if !crate::reducers::is_builtin(name) {
return Err(RunError::InvalidPlan {
why: format!(
"unknown reducer '{name}' for state key '{key}' \
(builtins: {:?})",
crate::reducers::BUILTIN_REDUCERS
),
});
}
reducers.insert(key.clone(), name.to_string());
}
}
}
Ok(RunManifest {
run_id: run_id.to_string(),
plan_hash: plan_hash.to_hex(),
principal: principal.to_string(),
pinned,
budgets,
ask_ttl_sec,
redaction: "none".into(),
input,
llm_max_tokens: None,
max_effects_per_attempt: None,
llm_tool_result_chars: None,
llm_context_tokens: None,
reducers,
fork_of: None,
})
}
pub fn with_limits(mut self, opts: &crate::RunOptions, model_window: Option<u64>) -> Self {
self.llm_max_tokens = opts.llm_max_tokens;
self.max_effects_per_attempt = opts.max_effects_per_attempt;
self.llm_context_tokens = opts.llm_context_tokens.or_else(|| {
model_window.map(|w| w.saturating_sub(u64::from(self.llm_max_tokens.unwrap_or(
crate::runner::DEFAULT_LLM_MAX_TOKENS,
))))
});
self.llm_tool_result_chars = opts.llm_tool_result_chars.or_else(|| {
self.llm_context_tokens
.map(|c| ((c / RESULT_SHARE_OF_CEILING) * CHARS_PER_TOKEN) as usize)
});
self
}
pub fn llm_reserve_tokens(&self) -> u64 {
u64::from(self.llm_max_tokens.unwrap_or(crate::runner::DEFAULT_LLM_MAX_TOKENS))
}
pub fn max_effects_per_attempt(&self) -> u32 {
self.max_effects_per_attempt
.unwrap_or(areev_run_core::DEFAULT_MAX_EFFECTS_PER_ATTEMPT)
}
pub fn executors(&self) -> Vec<NodeExecutor> {
let mut seen = std::collections::HashSet::new();
let offered: Vec<areev_run_core::OfferedTool> = self
.pinned
.iter()
.filter(|p| p.executor == "host")
.filter(|p| seen.insert(p.tool_name.clone()))
.map(|p| areev_run_core::OfferedTool {
tool_name: p.tool_name.clone(),
tool_hash: p.tool_hash.clone(),
})
.collect();
self.pinned
.iter()
.map(|p| match p.executor.as_str() {
"client" => NodeExecutor::Client {
tool_hash: p.tool_hash.clone(),
tool_name: p.tool_name.clone(),
},
"subgraph" => NodeExecutor::Subgraph { workflow_hash: p.tool_hash.clone() },
"abstract" => NodeExecutor::Abstract { tools: offered.clone() },
_ => NodeExecutor::Host {
tool_hash: p.tool_hash.clone(),
tool_name: p.tool_name.clone(),
},
})
.collect()
}
pub fn persist_in_namespace(&self, m: &mut Areev, ns: &str) -> Result<(Hash, Hash)> {
let config = serde_json::to_value(json!({ "areev_run": self }))
.expect("manifest serializes");
let mut state = areev_core::types::State::new(config)
.namespace(areev_core::authz::HARNESS_NS)
.created_at(0);
state.common.author_did = Some(self.principal.clone());
let config_hash = m.add(&state)?;
let mut link = areev_core::types::Fact::new(
&format!("run:{}", self.run_id),
"mg:harness",
&config_hash.to_hex(),
)
.namespace(areev_core::authz::HARNESS_NS);
link.common.author_did = Some(self.principal.clone());
link.common
.extra_fields
.insert("run_id".into(), serde_json::json!(self.run_id));
if !ns.is_empty() {
link.common.extra_fields.insert("run_ns".into(), serde_json::json!(ns));
}
let link_hash = m.add(&link)?;
Ok((config_hash, link_hash))
}
#[deprecated(
note = "use persist_in_namespace(m, ns) so the run index can be scoped by namespace"
)]
pub fn persist(&self, m: &mut Areev) -> Result<(Hash, Hash)> {
self.persist_in_namespace(m, "")
}
pub fn load(m: &mut Areev, run_id: &str) -> std::result::Result<RunManifest, RunError> {
let link = m
.latest(areev_core::authz::HARNESS_NS, &format!("run:{run_id}"), "mg:harness")
.map_err(err_run)?
.ok_or_else(|| RunError::ManifestMismatch {
why: format!("no manifest link for run '{run_id}'"),
})?;
let config_hash = link
.get_str("object")
.and_then(|h| Hash::from_hex(h).ok())
.ok_or_else(|| RunError::ManifestMismatch {
why: "manifest link carries no config hash".into(),
})?;
let config = m.get(&config_hash).map_err(|e| RunError::ManifestMismatch {
why: format!("manifest config unreadable: {e}"),
})?;
let manifest = config
.fields
.get("context")
.or_else(|| config.fields.get("context_data"))
.and_then(|c| c.get("areev_run"))
.cloned()
.ok_or_else(|| RunError::ManifestMismatch {
why: "config State carries no areev_run manifest".into(),
})?;
serde_json::from_value(manifest).map_err(|e| RunError::ManifestMismatch {
why: format!("manifest does not parse: {e}"),
})
}
}
pub fn pin_from_definition(
label: &str,
h: &Hash,
g: &areev_core::format::deserialize::DeserializedGrain,
) -> std::result::Result<PinnedTool, RunError> {
let node = label;
if g.get_str("kind") != Some("definition") {
return Err(RunError::UnresolvedRef {
what: format!(
"node '{node}' binds {} which is not a Tool Definition \
(kind = {:?})",
h.to_hex(),
g.get_str("kind").unwrap_or("execution")
),
});
}
let tool_name = g
.get_str("tool_name")
.unwrap_or(node)
.to_string();
if !areev_core::types::json_schema_subset::is_valid_tool_name(&tool_name) {
return Err(RunError::UnresolvedRef {
what: format!(
"node '{node}' binds {} whose tool_name {tool_name:?} is not a valid \
tool name (1-64 chars of [A-Za-z0-9_.-])",
h.to_hex()
),
});
}
let executor = match g.get_str("executor_kind") {
Some("client") => "client",
_ => "host",
};
let executor_uri = match g.get_str("executor_uri") {
None => None,
Some(uri) if executor == "client" => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' binds a client tool carrying executor_uri {uri:?} — a \
client tool is answered by a person through `run respond`, so it has \
no executor to name"
),
})
}
Some(uri) => {
let hex = crate::executor::strip_cas(uri);
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' names executor_uri {uri:?}, which this build cannot \
dispatch — the only executable form is a content address, \
cas://sha256:<64 hex>"
),
});
}
Some(format!("cas://sha256:{}", hex.to_ascii_lowercase()))
}
};
let runtime = match g.get_str("runtime") {
None | Some("native") => None,
Some(rt) if executor_uri.is_none() => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares runtime {rt:?} but names no executor_uri — a runtime routes a code blob, and there is none"
),
})
}
Some(rt) if crate::executor::is_sandbox_runtime(rt) => Some(rt.to_string()),
Some(rt) => {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares runtime {rt:?}, which this build cannot dispatch — accepted: native, wasm32-areev, wasm32-areev-io"
),
})
}
};
let runtime_limits = if runtime.is_some() { g.fields.get("runtime_limits").cloned() } else { None };
let capabilities = match g.fields.get("capabilities") {
None => None,
Some(v) if v.is_null() => None,
Some(v) => {
if !crate::executor::runtime_allows_capabilities(runtime.as_deref()) {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares capabilities but its runtime is {:?} — \
capabilities require runtime \"wasm32-areev-io\"",
runtime.as_deref().unwrap_or("native")
),
});
}
areev_core::types::capability::Declaration::parse(v).map_err(|e| RunError::CodeExecRefused {
condition: format!("node '{node}' declares malformed capabilities: {e}"),
})?;
Some(v.clone())
}
};
if crate::executor::runtime_allows_capabilities(runtime.as_deref()) && capabilities.is_none() {
return Err(RunError::CodeExecRefused {
condition: format!(
"node '{node}' declares runtime \"wasm32-areev-io\" but no capabilities — \
a capability runtime with an empty declaration can reach nothing; \
use \"wasm32-areev\" for a pure module"
),
});
}
Ok(PinnedTool {
node: node.to_string(),
tool_hash: h.to_hex(),
tool_name,
executor: executor.into(),
executor_uri,
runtime,
runtime_limits,
capabilities,
})
}
fn find_definition_by_name(
m: &mut Areev,
ns: &str,
name: &str,
) -> Option<(Hash, areev_core::format::deserialize::DeserializedGrain)> {
const CATALOG_SCAN: usize = 200_000;
let hits = m
.recent(ns, Some(areev_core::types::GrainType::Tool), CATALOG_SCAN)
.ok()?;
let mut best: Option<(i64, String)> = None;
let mut candidates: BTreeMap<String, areev_core::format::deserialize::DeserializedGrain> =
BTreeMap::new();
for g in hits {
if g.get_str("kind") == Some("definition") && g.get_str("tool_name") == Some(name) {
let at = g.get_i64("created_at").unwrap_or(0);
let key = (at, g.hash.to_hex());
if best.as_ref().is_none_or(|b| key > *b) {
best = Some(key);
}
candidates.insert(g.hash.to_hex(), g);
}
}
let (_, hex) = best?;
let g = candidates.remove(&hex)?;
let h = Hash::from_hex(&hex).ok()?;
Some((h, g))
}
fn misrouted(m: &mut Areev, ns: &str, plan_ns: Option<&str>, node: &str) -> bool {
match plan_ns {
Some(p) if p != ns => find_definition_by_name(m, p, node).is_some(),
_ => false,
}
}
pub fn abstract_nodes(
m: &mut Areev,
ns: &str,
plan_hash: &Hash,
) -> std::result::Result<Vec<String>, String> {
let wf = m
.get(plan_hash)
.map_err(|e| format!("{e}"))?
.to_workflow()
.map_err(|e| format!("{e}"))?;
let plan = PlanGraph::build(&wf).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for (i, node) in plan.nodes.iter().enumerate() {
if plan.bindings[i].is_none() && find_definition_by_name(m, ns, node).is_none() {
out.push(node.clone());
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn bare() -> RunManifest {
RunManifest {
run_id: "r1".into(),
plan_hash: "deadbeef".into(),
principal: "user:t".into(),
pinned: vec![],
budgets: BudgetsSpec::default(),
ask_ttl_sec: None,
redaction: "none".into(),
input: json!({}),
llm_max_tokens: None,
max_effects_per_attempt: None,
llm_tool_result_chars: None,
llm_context_tokens: None,
reducers: BTreeMap::new(),
fork_of: None,
}
}
fn host_pin(node: &str, tool: &str) -> PinnedTool {
PinnedTool {
node: node.into(),
tool_hash: format!("hash-{tool}"),
tool_name: tool.into(),
executor: "host".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
capabilities: None,
}
}
#[test]
fn a_definition_bound_to_two_nodes_is_offered_once() {
let mut m = bare();
m.pinned = vec![
host_pin("parse", "parse_attachments"),
PinnedTool {
node: "extract".into(),
tool_hash: String::new(),
tool_name: "extract".into(),
executor: "abstract".into(),
executor_uri: None,
runtime: None,
runtime_limits: None,
capabilities: None,
},
host_pin("reply_done", "reply_email"),
host_pin("reply_rejected", "reply_email"),
];
let executors = m.executors();
assert_eq!(executors.len(), 4);
match &executors[1] {
NodeExecutor::Abstract { tools } => {
let names: Vec<&str> = tools.iter().map(|t| t.tool_name.as_str()).collect();
assert_eq!(names, vec!["parse_attachments", "reply_email"]);
assert_eq!(tools[1].tool_hash, "hash-reply_email");
}
other => panic!("expected the abstract node's offer, got {other:?}"),
}
for (i, node) in [(2usize, "reply_done"), (3, "reply_rejected")] {
match &executors[i] {
NodeExecutor::Host { tool_name, tool_hash } => {
assert_eq!(tool_name, "reply_email", "{node} runs its own binding");
assert_eq!(tool_hash, "hash-reply_email");
}
other => panic!("{node}: expected a host executor, got {other:?}"),
}
}
}
#[test]
fn an_unset_effect_cap_serializes_as_it_did_before_the_field_existed() {
let json = serde_json::to_string(&bare()).unwrap();
assert_eq!(
json,
r#"{"run_id":"r1","plan_hash":"deadbeef","principal":"user:t","pinned":[],"budgets":{"max_supersteps":null,"max_tokens":null,"max_usd_micros":null,"max_wall_ms":null,"max_storage_bytes":null},"ask_ttl_sec":null,"redaction":"none","input":{},"llm_max_tokens":null}"#
);
}
#[test]
fn a_pre_existing_manifest_reads_back_at_the_default_cap() {
let stored = r#"{"run_id":"r1","plan_hash":"deadbeef","principal":"user:t",
"pinned":[],"budgets":{"max_supersteps":null,"max_tokens":null,
"max_usd_micros":null,"max_wall_ms":null,"max_storage_bytes":null},
"ask_ttl_sec":null,"redaction":"none","input":{},"llm_max_tokens":null}"#;
let m: RunManifest = serde_json::from_str(stored).unwrap();
assert_eq!(m.max_effects_per_attempt, None);
assert_eq!(m.max_effects_per_attempt(), 16);
assert_eq!(
m.llm_tool_result_chars, None,
"unbounded tool results: what every pre-1.9 run did"
);
assert_eq!(areev_run_core::DEFAULT_MAX_EFFECTS_PER_ATTEMPT, 16);
}
#[test]
fn a_pinned_cap_wins_over_the_default() {
let m = RunManifest { max_effects_per_attempt: Some(40), ..bare() };
assert_eq!(m.max_effects_per_attempt(), 40);
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains(r#""max_effects_per_attempt":40"#), "{json}");
}
}