pub mod acceptance;
pub mod ask;
pub mod attempt;
pub mod converge;
pub mod host;
pub mod model_config;
pub mod monitor;
pub mod near_miss;
pub mod propose;
pub mod provenance;
pub mod run_config;
pub mod shape;
use std::path::Path;
use std::sync::Arc;
use agentplane::core::CorrelationKey;
use agentplane::core::{ProtectedField, SourceId};
use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
use agentplane::model::ModelProvider;
use agentplane::prelude::*;
use agentplane::runtime::{Agent, RunOutcome, RunTerms};
use serde_json::Value;
use self::model_config::ModelConfig;
use self::propose::{CAPABILITY, Propose};
pub const AGENTPLANE_VERSION: &str = "0.57.0";
pub const JOURNAL: &str = "journal.redb";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlaneError(pub String);
pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
manifest_with(model, &[])
}
pub fn manifest_with(
model: &ModelConfig,
mcp: &[crate::mcp::McpTool],
) -> Result<Manifest, PlaneError> {
Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
.configure(|spec| {
spec.capabilities.provides.push(CAPABILITY.to_owned());
spec.capabilities
.provides
.push(attempt::CAPABILITY.to_owned());
spec.capabilities
.provides
.push(acceptance::CAPABILITY.to_owned());
spec.capabilities
.provides
.push(converge::CAPABILITY.to_owned());
spec.capabilities
.provides
.push(monitor::CAPABILITY.to_owned());
spec.capabilities
.provides
.push(near_miss::CAPABILITY.to_owned());
spec.capabilities
.provides
.push(shape::CAPABILITY.to_owned());
spec.capabilities.provides.push(host::CAPABILITY.to_owned());
spec.models = Some(Models {
privileged: Some(ModelRef {
max_tokens: Some(model.max_output_tokens),
..ModelRef::new(model.provider.clone(), model.model.clone())
}),
..Models::default()
});
spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
spec.budgets = Some(Budgets {
max_tokens: Some(model.max_run_tokens),
..Budgets::default()
});
let author =
agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
.source();
let catalog = crate::tools::catalog();
let mut sources: Vec<SourceId> = vec![
author,
SourceId::new(provenance::VERIFIER),
SourceId::new(provenance::WORKTREE),
SourceId::new(provenance::PERSON),
];
sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
for t in catalog.iter().chain(crate::tools::internal().iter()) {
let mut grant = ToolGrant {
description: Some(t.description.to_owned()),
max_sensitivity: Some(Sensitivity::Internal),
mutates: t.mutates,
..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
};
grant.protected_fields = t
.model_fields
.iter()
.map(|f| ProtectedField::from_sources(*f, sources.clone()))
.collect();
spec.tools.push(grant);
}
for t in mcp {
let mut grant = ToolGrant {
description: Some(t.description.clone()),
max_sensitivity: Some(Sensitivity::Internal),
mutates: true,
..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
};
grant.protected_fields = if t.fields.is_empty() {
vec![ProtectedField::one_of("", [serde_json::json!({})])]
} else {
t.fields
.iter()
.map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
.collect()
};
spec.tools.push(grant);
}
})
.build()
.map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
}
#[derive(Debug, Clone, Default)]
pub struct Hooks {
pub progress: Option<Arc<dyn crate::progress::Progress>>,
pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
pub mcp_tools: Vec<crate::mcp::McpTool>,
pub format: Option<&'static dyn crate::format::Adapter>,
pub present: crate::approvals::PresentContext,
}
#[derive(Debug, Clone, Default)]
pub struct RecordedCalls {
pub calls: Vec<RecordedCall>,
pub unreadable: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct RecordedCall {
pub arguments: Value,
pub at: u64,
}
#[derive(Debug)]
pub struct Inline {
pub outcome: RunOutcome,
pub waiting: Option<agentplane::core::Task>,
pub always: Vec<Always>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Always {
pub task: String,
pub call: String,
pub via: speccoder_core::actions::Via,
}
pub struct Plane {
runtime: Arc<Runtime>,
pub replaced_journal: Option<std::path::PathBuf>,
_run: Option<RunClaim>,
}
static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
struct RunClaim(std::path::PathBuf);
impl RunClaim {
fn take(dir: &Path) -> Result<Self, PlaneError> {
let path = dir.join(JOURNAL);
let mut runs = RUNS
.lock()
.map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
if runs.contains(&path) {
return Err(PlaneError(format!(
"another speccoder is working on this project (a run in this process holds its journal in {})",
dir.display()
)));
}
runs.push(path.clone());
Ok(Self(path))
}
}
impl Drop for RunClaim {
fn drop(&mut self) {
if let Ok(mut runs) = RUNS.lock() {
runs.retain(|p| *p != self.0);
}
}
}
static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
std::sync::Mutex::new(Vec::new());
fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
let path = dir.join(JOURNAL);
let mut open = OPEN
.lock()
.map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
open.retain(|(_, w)| w.strong_count() > 0);
if let Some(store) = open
.iter()
.find(|(p, _)| *p == path)
.and_then(|(_, w)| w.upgrade())
{
return Ok((store, None));
}
let (store, aside) = open_fresh(dir, &path)?;
open.push((path, Arc::downgrade(&store)));
Ok((store, aside))
}
fn open_fresh(
dir: &Path,
path: &Path,
) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
match RedbStore::open_shared(path) {
Ok(store) => Ok((Arc::new(store), None)),
Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
"another speccoder is working on this project (its journal in {} is in use): {e}",
dir.display()
))),
Err(agentplane::core::StoreError::Unreadable(e)) => {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
std::fs::rename(path, &aside).map_err(|r| {
PlaneError(format!(
"the journal is unreadable ({e}) and cannot be moved aside: {r}"
))
})?;
RedbStore::open_shared(path)
.map(|store| (Arc::new(store), Some(aside)))
.map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
}
Err(e) => Err(PlaneError(format!(
"cannot open the journal in {}: {e}",
dir.display()
))),
}
}
pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
let path = dir.join(JOURNAL);
if !path.exists() {
return Ok(None);
}
match RedbStore::open_read_only(&path) {
Ok(store) => Ok(Some(Arc::new(store))),
Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
"another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
dir.display()
))),
Err(agentplane::core::StoreError::NeedsRepair(e)) => Err(PlaneError(format!(
"the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
dir.display()
))),
Err(e) => Err(PlaneError(format!(
"cannot read the journal in {}: {e}",
dir.display()
))),
}
}
fn holds_a_pre_054_decision(value: &Value) -> bool {
match value {
Value::Object(o) => {
(o.contains_key("approved") && o.contains_key("decided") && !o.contains_key("verdict"))
|| o.values().any(holds_a_pre_054_decision)
}
Value::Array(a) => a.iter().any(holds_a_pre_054_decision),
Value::String(s) if s.contains("\"approved\"") => {
serde_json::from_str::<Value>(s).is_ok_and(|v| holds_a_pre_054_decision(&v))
}
_ => false,
}
}
pub const PRE_054_DECISION: &str = "this attempt waited on a decision recorded by speccoder 0.2.0, which this build cannot read (agentplane 0.54 records a decision as a verdict); the attempt is set aside — run the task again to start it afresh";
#[must_use]
pub fn provider_refusal(failure: &str) -> Option<String> {
let start = failure.find("refused the request: ")? + "refused the request: ".len();
let rest = &failure[start..];
let end = rest
.find("); ")
.or_else(|| rest.rfind(')'))
.unwrap_or(rest.len());
Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
}
#[must_use]
pub fn exhausted(limit: &agentplane::core::BudgetExceeded) -> String {
let said = match limit {
agentplane::core::BudgetExceeded::Recorded { limit, .. } => limit.clone(),
other => other.to_string(),
};
let tokens = limit.ceiling().map_or_else(
|| said.starts_with("token"),
|c| c == agentplane::core::Ceiling::Tokens,
);
let raise = if tokens {
"; raise `[model] max_run_tokens`, or split the task"
} else {
""
};
format!("the attempt's budget ran out: {said}{raise}")
}
#[must_use]
pub fn why_not(status: &RunStatus) -> String {
let RunStatus::Failed(failure) = status else {
return status.as_str().to_owned();
};
let said = ["consumed resources and failed: ", "failed: "]
.iter()
.find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
.or_else(|| {
failure
.split_once(" of ")
.and_then(|(_, r)| r.split_once(": "))
.map(|(_, r)| r)
})
.unwrap_or(failure);
let cut: String = said.chars().take(300).collect();
if cut.len() < said.len() {
format!("{cut}…")
} else {
cut
}
}
pub async fn refused_account(
dir: &Path,
) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
use agentplane::journal::JournalStore as _;
let Some(store) = open_reader(dir)? else {
return Ok(None);
};
let mut after: Option<(u64, agentplane::core::RunId)> = None;
loop {
let page = store
.recent_runs(after, 500)
.await
.map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
let Some(last) = page.last().cloned() else {
return Ok(None);
};
for (run, _) in &page {
let records = store
.read(*run, 0)
.await
.map_err(|e| PlaneError(format!("run {run}: {e}")))?;
if let Some(failed) = agentplane::journal::failed_effect(&records)
.filter(agentplane::journal::FailedEffect::refuses_the_account)
{
return Ok(Some(failed));
}
}
after = Some((last.1, last.0));
}
}
fn builder(
store: Arc<RedbStore>,
model: &ModelConfig,
hooks: Hooks,
) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
let manifest = manifest_with(model, &hooks.mcp_tools)?;
let format = hooks.format.unwrap_or_else(crate::format::reference);
let agent = Agent::new(&manifest)
.skill(Propose {
cache_ttl: model.cache_ttl,
style: model.prompt_style(),
format,
})
.skill(attempt::Attempt {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
progress: hooks.progress.clone(),
format,
present: hooks.present.clone(),
})
.skill(host::Host {
style: model.prompt_style(),
progress: hooks.progress,
format,
present: hooks.present,
})
.skill(acceptance::Acceptance {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
format,
})
.skill(converge::Converge {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
format,
})
.skill(monitor::Monitor {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
format,
})
.skill(near_miss::NearMiss {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
format,
})
.skill(shape::Shape {
style: model.prompt_style(),
cache_ttl: model.cache_ttl,
});
let builder = Runtime::builder_on(store).agent(agent);
Ok((builder, manifest))
}
fn build(
store: Arc<RedbStore>,
model: &ModelConfig,
provider: Arc<dyn ModelProvider>,
tools: Option<Arc<crate::tools::WorkspaceTools>>,
hooks: Hooks,
) -> Result<Arc<Runtime>, PlaneError> {
let mcp = hooks.mcp.clone();
let (mut builder, _) = builder(store, model, hooks)?;
builder = builder.provider(model.provider.clone(), provider);
let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
Some(t) => t,
None => Arc::new(Unbound),
};
for server in ["fs", "proc", "git"] {
builder = builder.tool_server(server, Arc::clone(&client));
}
for (server, client) in &mcp {
builder = builder.tool_server(server.as_str(), Arc::clone(client));
}
builder
.try_build()
.map_err(|e| PlaneError(format!("building the plane: {e}")))
}
impl Plane {
pub fn open(
dir: &Path,
model: &ModelConfig,
provider: Arc<dyn ModelProvider>,
) -> Result<Self, PlaneError> {
Self::open_with_tools(dir, model, provider, None)
}
pub fn open_with_tools(
dir: &Path,
model: &ModelConfig,
provider: Arc<dyn ModelProvider>,
tools: Option<Arc<crate::tools::WorkspaceTools>>,
) -> Result<Self, PlaneError> {
Self::open_with(dir, model, provider, tools, Hooks::default())
}
pub fn open_with(
dir: &Path,
model: &ModelConfig,
provider: Arc<dyn ModelProvider>,
tools: Option<Arc<crate::tools::WorkspaceTools>>,
hooks: Hooks,
) -> Result<Self, PlaneError> {
let claim = RunClaim::take(dir)?;
let (store, replaced_journal) = open_or_replace(dir)?;
Ok(Self {
runtime: build(store, model, provider, tools, hooks)?,
replaced_journal,
_run: Some(claim),
})
}
pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
let (store, replaced_journal) = open_or_replace(dir)?;
let runtime = Runtime::builder_on(store)
.try_build()
.map_err(|e| PlaneError(format!("building the plane: {e}")))?;
Ok(Self {
runtime,
replaced_journal,
_run: None,
})
}
pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
let Some(store) = open_reader(dir)? else {
return Ok(None);
};
let runtime = Runtime::builder_on(store)
.try_build()
.map_err(|e| PlaneError(format!("building the plane: {e}")))?;
Ok(Some(Self {
runtime,
replaced_journal: None,
_run: None,
}))
}
pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
self.runtime
.sweep(now, std::time::Duration::from_secs(7 * 86_400))
.await
.map(|_| ())
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn withdraw(
&self,
task: &agentplane::core::Task,
by: &agentplane::core::Operator,
reason: &str,
) -> Result<(), PlaneError> {
let err = |e: String| PlaneError(e);
let _ = self
.runtime
.request_cancel(task.run, by, reason)
.await
.map_err(|e| err(e.to_string()))?;
if let Some(tasks) = self.runtime.tasks() {
tasks
.withdraw_run(task.run, &[task.id])
.await
.map_err(|e| err(e.to_string()))?;
}
Ok(())
}
pub async fn waiting_on(
&self,
outcome: &RunOutcome,
) -> Result<Option<agentplane::core::Task>, PlaneError> {
let RunStatus::Suspended(reason) = &outcome.status else {
return Ok(None);
};
let Some(id) = reason.awaited_task() else {
return Ok(None);
};
self.task(id).await
}
pub async fn task(
&self,
id: agentplane::core::TaskId,
) -> Result<Option<agentplane::core::Task>, PlaneError> {
let Some(tasks) = self.runtime.tasks() else {
return Ok(None);
};
tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
}
pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
let journal = self.runtime.journal();
let mut out = RecordedCalls::default();
let mut after: Option<(u64, agentplane::core::RunId)> = None;
loop {
let page = journal
.recent_runs(after, 500)
.await
.map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
let Some(last) = page.last().cloned() else {
break;
};
for (run, at) in &page {
let records = match journal.read(*run, 0).await {
Ok(r) => r,
Err(e) => {
out.unreadable.push(format!("run {run}: {e}"));
continue;
}
};
let mut seen = std::collections::BTreeSet::new();
for record in records {
let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
else {
continue;
};
let args = &descriptor.args;
if descriptor.kind != "tool.call"
|| args["server"] != server
|| args["tool"] != tool
{
continue;
}
let key = record
.body
.effect_key
.as_ref()
.map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
if seen.insert(key) {
out.calls.push(RecordedCall {
arguments: args["arguments"].clone(),
at: *at,
});
}
}
}
after = Some((last.1, last.0));
}
Ok(out)
}
pub async fn holds_a_pre_054_decision(&self, run: agentplane::core::RunId) -> bool {
let Ok(records) = self.runtime.journal().read(run, 0).await else {
return false;
};
records.iter().any(|r| {
serde_json::to_value(r.kind())
.ok()
.is_some_and(|v| holds_a_pre_054_decision(&v))
})
}
pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
let Some(tasks) = self.runtime.tasks() else {
return Ok(Vec::new());
};
let mut open = tasks
.queue(&[], 1000)
.await
.map_err(|e| PlaneError(e.to_string()))?;
open.sort_by_key(|t| t.created_at);
Ok(open)
}
pub async fn decide(
&self,
task: agentplane::core::TaskId,
decision: &agentplane::core::Decision,
) -> Result<agentplane::core::Delivery, PlaneError> {
self.decide_at(task, decision, None).await
}
pub async fn decide_at(
&self,
task: agentplane::core::TaskId,
decision: &agentplane::core::Decision,
expected: Option<agentplane::core::Digest>,
) -> Result<agentplane::core::Delivery, PlaneError> {
self.runtime
.decide_task_at(task, decision, &[], expected)
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn decide_inline(
&self,
mut outcome: RunOutcome,
approver: Option<&dyn crate::approvals::Approver>,
context: &crate::approvals::PresentContext,
) -> Result<Inline, PlaneError> {
let mut always = Vec::new();
while let Some(task) = self.waiting_on(&outcome).await? {
let Some((decision, answer)) =
approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
else {
return Ok(Inline {
outcome,
waiting: Some(task),
always,
});
};
self.decide(task.id, &decision).await?;
if answer.always && decision.approved() {
let via = answer
.by
.as_ref()
.and_then(|b| speccoder_core::actions::Via::read(&b.via))
.map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
crate::approvals::answered_always(&task.id.to_hex());
always.push(Always {
task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
.map(|r| r.task)
.unwrap_or_default(),
call: task.id.to_hex(),
via,
});
}
match self.outcome(outcome.run_id).await? {
Some(o) => outcome = o,
None => break,
}
}
Ok(Inline {
outcome,
waiting: None,
always,
})
}
pub async fn outcome(
&self,
run: agentplane::core::RunId,
) -> Result<Option<RunOutcome>, PlaneError> {
if self
.runtime
.recorded_outcome(run)
.await
.map_err(|e| PlaneError(e.to_string()))?
.is_none()
{
return Ok(None);
}
self.runtime
.replay(run, agentplane::runtime::Mode::Strict)
.await
.map(Some)
.map_err(|e| PlaneError(e.to_string()))
}
#[doc(hidden)]
pub fn on_store(
store: Arc<RedbStore>,
model: &ModelConfig,
provider: Arc<dyn ModelProvider>,
tools: Option<Arc<crate::tools::WorkspaceTools>>,
) -> Result<Self, PlaneError> {
Ok(Self {
runtime: build(store, model, provider, tools, Hooks::default())?,
replaced_journal: None,
_run: None,
})
}
#[doc(hidden)]
pub async fn resume(
&self,
run: agentplane::core::RunId,
) -> Result<RunOutcome, agentplane::RuntimeError> {
self.runtime
.replay(run, agentplane::runtime::Mode::Resume)
.await
}
pub async fn converge(
&self,
feature: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(converge::CAPABILITY, input)
.terms(RunTerms::default().correlated(
"feature",
&[CorrelationKey::new("feature", feature.to_owned())],
))
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(shape::CAPABILITY, input)
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(host::CAPABILITY, input)
.terms(
RunTerms::default()
.correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
)
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn monitor(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.judge_once(monitor::CAPABILITY, feature, task, input)
.await
}
pub async fn near_miss(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.judge_once(near_miss::CAPABILITY, feature, task, input)
.await
}
async fn judge_once(
&self,
capability: &str,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
use agentplane::runtime::{Admission, Mode};
use sha2::Digest as _;
let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
let digest = sha2::Sha256::digest(
format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
);
let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
let key = format!("judge-{key}");
let key = key.as_str();
let terms = || {
RunTerms::default().correlated(
"task",
&[CorrelationKey::new("task", format!("{feature}/{task}"))],
)
};
let admitted = self
.runtime
.start(capability, input.clone())
.terms(terms())
.once(key)
.run()
.await
.map_err(err)?;
match admitted {
Admission::Fresh(o) => Ok(o),
Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
.runtime
.start(capability, input)
.terms(terms())
.run()
.await
.map_err(err),
Admission::Replayed(o) => self
.runtime
.replay(o.run_id, Mode::Strict)
.await
.map_err(err),
Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
}
}
pub async fn acceptance(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(acceptance::CAPABILITY, input)
.terms(RunTerms::default().correlated(
"task",
&[CorrelationKey::new("task", format!("{feature}/{task}"))],
))
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn attempt(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(attempt::CAPABILITY, input)
.terms(RunTerms::default().correlated(
"task",
&[CorrelationKey::new("task", format!("{feature}/{task}"))],
))
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
pub async fn attempt_keyed(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
key: &str,
) -> Result<(RunOutcome, bool), PlaneError> {
use agentplane::runtime::{Admission, Mode};
let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
let admitted = self
.runtime
.start(attempt::CAPABILITY, input)
.terms(RunTerms::default().correlated(
"task",
&[CorrelationKey::new("task", format!("{feature}/{task}"))],
))
.once(key)
.run()
.await
.map_err(err)?;
match admitted {
Admission::Fresh(o) => Ok((o, false)),
Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
.runtime
.replay(o.run_id, Mode::Resume)
.await
.map(|o| (o, true))
.map_err(err),
Admission::Replayed(o) => self
.runtime
.replay(o.run_id, Mode::Strict)
.await
.map(|o| (o, true))
.map_err(err),
Admission::InFlight(run) => self
.runtime
.replay(run, Mode::Resume)
.await
.map(|o| (o, true))
.map_err(err),
}
}
pub async fn propose(
&self,
feature: &str,
task: &str,
input: Tainted<Value>,
) -> Result<RunOutcome, PlaneError> {
self.runtime
.start(CAPABILITY, input)
.terms(RunTerms::default().correlated(
"task",
&[CorrelationKey::new("task", format!("{feature}/{task}"))],
))
.run()
.await
.map_err(|e| PlaneError(e.to_string()))
}
}
pub async fn admitted(
dir: &Path,
run: &str,
) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
let id = agentplane::core::RunId::parse(run)
.map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
let Some(store) = open_reader(dir)? else {
return Ok(None);
};
let history = store
.read(id, 1)
.await
.map_err(|e| PlaneError(e.to_string()))?;
Ok(history
.iter()
.find_map(agentplane::journal::Record::admitted_input))
}
pub async fn replay_strict(
dir: &Path,
model: &ModelConfig,
run: &str,
format: &'static dyn crate::format::Adapter,
) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
let id = agentplane::core::RunId::parse(run)
.map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
let Some(store) = open_reader(dir)? else {
return Ok(None);
};
let history = store
.read(id, 1)
.await
.map_err(|e| PlaneError(e.to_string()))?;
if history.is_empty() {
return Ok(None);
}
if history.iter().any(|r| {
serde_json::to_value(r.kind())
.ok()
.is_some_and(|v| holds_a_pre_054_decision(&v))
}) {
return Err(PlaneError(format!(
"run {run} read a person's decision recorded by speccoder 0.2.0, which this build cannot replay (agentplane 0.54 records a decision as a verdict); its ticks and attempt refs in git are unaffected"
)));
}
let mcp_tools: Vec<crate::mcp::McpTool> = history
.iter()
.find_map(agentplane::journal::Record::admitted_input)
.and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
.unwrap_or_default();
let hooks = Hooks {
mcp_tools,
format: Some(format),
..Hooks::default()
};
let (builder, manifest) = builder(store, model, hooks)?;
let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
.try_build()
.map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
runtime
.verify(id)
.await
.map(Some)
.map_err(|e| PlaneError(e.to_string()))
}
#[must_use]
pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
let commentary = completion.commentary.as_deref().map(str::trim);
let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
let parts: Vec<&str> = [commentary, text]
.into_iter()
.flatten()
.filter(|t| !t.is_empty())
.collect();
(!parts.is_empty()).then(|| parts.join("\n\n"))
}
#[must_use]
pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
if let Some(blocked) = completion
.tool_calls
.iter()
.find(|c| c.name == attempt::REPORT_BLOCKED)
{
let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
return Some(match blocked.arguments["kind"].as_str() {
Some(kind) => format!("Blocked ({kind}): {reason}"),
None => format!("Blocked: {reason}"),
});
}
if !completion.tool_calls.is_empty() {
return None;
}
let summary = completion
.structured
.as_ref()
.and_then(|s| s["summary"].as_str());
let text = summary.unwrap_or(&completion.text).trim();
(!text.is_empty()).then(|| text.to_owned())
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct AccountTurn {
pub turn: u32,
pub reasoning: Option<String>,
pub said: Option<String>,
pub answer: Option<String>,
}
pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
let Ok(id) = agentplane::core::RunId::parse(run) else {
return Err(PlaneError(format!("{run} is not a run id")));
};
let lanes = std::fs::read_dir(dir.join("lanes"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path());
let mut any = false;
let mut history = Vec::new();
for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
let Some(store) = open_reader(&d)? else {
continue;
};
any = true;
history = store
.read(id, 1)
.await
.map_err(|e| PlaneError(e.to_string()))?;
if !history.is_empty() {
break;
}
}
if !any {
return Err(PlaneError(
"no journal of this project's runs is here".to_owned(),
));
}
if history.is_empty() {
return Err(PlaneError(format!(
"run {run} is not in this project's journal"
)));
}
let text = |t: &str| {
let t = t.trim();
(!t.is_empty()).then(|| crate::approvals::present::escape(t))
};
Ok(agentplane::model::turns(&history)
.into_iter()
.zip(1u32..)
.filter_map(|(t, turn)| {
let c = t.completion?;
let said = if c.tool_calls.is_empty() {
c.commentary.as_deref().and_then(text)
} else {
said(&c).as_deref().and_then(text)
};
let reasoning = c.reasoning.as_deref().and_then(text);
let answer = answered(&c).as_deref().and_then(text);
(said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
turn,
reasoning,
said,
answer,
})
})
.collect())
}
pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
let Ok(id) = agentplane::core::RunId::parse(run) else {
return Ok(None);
};
let Some(store) = open_reader(dir)? else {
return Ok(None);
};
let history = store
.read(id, 1)
.await
.map_err(|e| PlaneError(e.to_string()))?;
let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
.into_iter()
.filter_map(|t| t.completion.map(|c| c.usage))
.collect();
Ok((!used.is_empty()).then(|| {
let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
serde_json::json!({
"input_tokens": sum(|u| u.input_tokens),
"output_tokens": sum(|u| u.output_tokens),
"cache_read_tokens": sum(|u| u.cache_read_tokens),
"cache_write_tokens": sum(|u| u.cache_write_tokens),
})
}))
}
#[derive(Debug)]
struct Unbound;
#[async_trait]
impl agentplane::tools::ToolClient for Unbound {
async fn call(
&self,
tool: &agentplane::tools::ToolId,
_arguments: &Value,
_provenance: Option<&agentplane::core::Provenance>,
) -> Result<Value, agentplane::tools::ToolError> {
Err(agentplane::tools::ToolError::Refused {
tool: tool.clone(),
detail: "no worktree is bound to this plane".to_owned(),
})
}
fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
agentplane::tools::Destination::Local
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
let workspace = include_str!("../../../../Cargo.toml");
assert!(
workspace.contains(&format!(
"agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
)),
"AGENTPLANE_VERSION is not the workspace's pin"
);
}
#[test]
fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
use agentplane::model::{Completion, ToolCall};
let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
let done = Completion {
text: report.to_string(),
structured: Some(report),
..Completion::default()
};
assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
let plain = Completion {
text: " All done. ".to_owned(),
..Completion::default()
};
assert_eq!(answered(&plain).as_deref(), Some("All done."));
let call = |name: &str, arguments: Value| Completion {
tool_calls: vec![ToolCall {
id: "c".to_owned(),
name: name.to_owned(),
arguments,
}],
..Completion::default()
};
let blocked = call(
attempt::REPORT_BLOCKED,
serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
);
assert_eq!(
answered(&blocked).as_deref(),
Some("Blocked (wrong_test): it asserts the old format")
);
assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
}
#[test]
fn a_provider_refusal_is_read_from_the_run_failure() {
let failure = "Failed(\"effect ek:21 was refused on attempt 1, and the refusal is an answer rather than a fault (effect refused: 'openai/gpt-5.4-mini' refused the request: credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/.); no retry would change it\")";
let r = provider_refusal(failure).unwrap();
assert_eq!(
r,
"credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
);
let own = "effect ek:21 was refused on attempt 1, and the refusal is an answer rather than a fault ('openai/gpt-5.4-mini' refused the request: insufficient_quota: You exceeded your current quota.); no retry would change it";
assert_eq!(
provider_refusal(own).as_deref(),
Some("insufficient_quota: You exceeded your current quota.")
);
assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
}
#[test]
fn a_failed_run_says_why_in_the_failure_s_own_words() {
let unusable = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: effect consumed resources and failed: 'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531".to_owned());
assert_eq!(
why_not(&unusable),
"'openai/gpt-5.4-mini' returned an unusable answer: a schema was required and the answer is not JSON: trailing characters at line 1 column 2531"
);
let timeout =
RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
assert_eq!(why_not(&timeout), "timed out");
let own = RunStatus::Failed("effect ek:774b failed on attempt 1 of 1: 'openai/gpt-5.4-mini' returned an unusable answer: not JSON".to_owned());
assert_eq!(
why_not(&own),
"'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
);
assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
}
#[test]
fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
std::fs::write(dir.path().join(JOURNAL), b"written by another format")
.unwrap_or_else(|e| panic!("{e}"));
let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
let aside = aside.unwrap_or_else(|| panic!("moved aside"));
assert!(aside.exists() && dir.path().join(JOURNAL).exists());
let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
drop((store, shared));
let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
let started = std::time::Instant::now();
let second = open_or_replace(dir.path())
.err()
.map(|e| e.0)
.unwrap_or_default();
assert!(second.contains("another speccoder"), "{second}");
assert!(started.elapsed() < std::time::Duration::from_millis(500));
assert!(open_reader(dir.path()).is_err());
drop(held);
assert!(
open_or_replace(dir.path())
.unwrap_or_else(|e| panic!("{}", e.0))
.1
.is_none()
);
}
#[test]
fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
assert!(
open_reader(dir.path())
.unwrap_or_else(|e| panic!("{}", e.0))
.is_none()
);
assert!(
Plane::open_for_reading(dir.path())
.unwrap_or_else(|e| panic!("{}", e.0))
.is_none()
);
assert!(
!dir.path().join(JOURNAL).exists(),
"a read created a journal"
);
let model =
model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
.unwrap_or_else(|e| panic!("{e}"))
.unwrap_or_else(|| panic!("no model"));
let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
.unwrap_or_else(|e| panic!("{}", e.0));
let reader = open_reader(dir.path())
.unwrap_or_else(|e| panic!("{}", e.0))
.unwrap_or_else(|| panic!("no journal"));
assert!(reader.is_read_only());
assert!(Plane::open(dir.path(), &model, fake).is_err());
drop(run);
}
}