1pub mod acceptance;
9pub mod answers;
10pub mod ask;
11pub mod attempt;
12pub mod converge;
13pub mod host;
14pub mod model_config;
15pub mod monitor;
16pub mod near_miss;
17pub mod propose;
18pub mod provenance;
19pub mod run_config;
20pub mod shape;
21
22use std::path::Path;
23use std::sync::Arc;
24
25use agentplane::core::CorrelationKey;
26use agentplane::core::{ProtectedField, SourceId};
27use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
28use agentplane::model::ModelProvider;
29use agentplane::prelude::*;
30use agentplane::runtime::{Agent, RunOutcome, RunTerms};
31use serde_json::Value;
32
33use self::model_config::ModelConfig;
34use self::propose::{CAPABILITY, Propose};
35
36pub const AGENTPLANE_VERSION: &str = "0.60.0";
39
40pub const JOURNAL: &str = "journal.redb";
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PlaneError(pub String);
46
47pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
50 manifest_with(model, &[])
51}
52
53pub fn manifest_with(
56 model: &ModelConfig,
57 mcp: &[crate::mcp::McpTool],
58) -> Result<Manifest, PlaneError> {
59 Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
60 .configure(|spec| {
61 spec.capabilities.provides.push(CAPABILITY.to_owned());
62 spec.capabilities
63 .provides
64 .push(attempt::CAPABILITY.to_owned());
65 spec.capabilities
66 .provides
67 .push(acceptance::CAPABILITY.to_owned());
68 spec.capabilities
69 .provides
70 .push(converge::CAPABILITY.to_owned());
71 spec.capabilities
72 .provides
73 .push(monitor::CAPABILITY.to_owned());
74 spec.capabilities
75 .provides
76 .push(near_miss::CAPABILITY.to_owned());
77 spec.capabilities
78 .provides
79 .push(shape::CAPABILITY.to_owned());
80 spec.capabilities.provides.push(host::CAPABILITY.to_owned());
81 spec.models = Some(Models {
82 privileged: Some(ModelRef {
83 max_tokens: Some(model.max_output_tokens),
84 ..ModelRef::new(model.provider.clone(), model.model.clone())
85 }),
86 ..Models::default()
87 });
88 spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
90 spec.budgets = Some(Budgets {
91 max_tokens: Some(model.max_run_tokens),
92 ..Budgets::default()
93 });
94 let author =
101 agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
102 .source();
103 let catalog = crate::tools::catalog();
104 let mut sources: Vec<SourceId> = vec![
105 author,
106 SourceId::new(provenance::VERIFIER),
107 SourceId::new(provenance::WORKTREE),
108 SourceId::new(provenance::PERSON),
110 ];
111 sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
112 sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
113 for t in catalog.iter().chain(crate::tools::internal().iter()) {
116 let mut grant = ToolGrant {
117 description: Some(t.description.to_owned()),
118 max_sensitivity: Some(Sensitivity::Internal),
119 mutates: t.mutates,
120 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
121 };
122 grant.protected_fields = t
123 .model_fields
124 .iter()
125 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
126 .collect();
127 spec.tools.push(grant);
128 }
129 for t in mcp {
130 let mut grant = ToolGrant {
131 description: Some(t.description.clone()),
132 max_sensitivity: Some(Sensitivity::Internal),
133 mutates: true,
134 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
135 };
136 grant.protected_fields = if t.fields.is_empty() {
137 vec![ProtectedField::one_of("", [serde_json::json!({})])]
140 } else {
141 t.fields
142 .iter()
143 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
144 .collect()
145 };
146 spec.tools.push(grant);
147 }
148 })
149 .build()
150 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
151}
152
153#[derive(Debug, Clone, Default)]
157pub struct Hooks {
158 pub progress: Option<Arc<dyn crate::progress::Progress>>,
160 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
162 pub mcp_tools: Vec<crate::mcp::McpTool>,
164 pub format: Option<&'static dyn crate::format::Adapter>,
166 pub present: crate::approvals::PresentContext,
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct RecordedCalls {
173 pub calls: Vec<RecordedCall>,
175 pub unreadable: Vec<String>,
177}
178
179#[derive(Debug, Clone)]
181pub struct RecordedCall {
182 pub arguments: Value,
184 pub at: u64,
186}
187
188#[derive(Debug)]
190pub struct Inline {
191 pub outcome: RunOutcome,
193 pub waiting: Option<agentplane::core::Task>,
195 pub always: Vec<Always>,
197}
198
199#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct Always {
202 pub task: String,
204 pub call: String,
206 pub via: speccoder_core::actions::Via,
208}
209
210pub struct Plane {
212 runtime: Arc<Runtime>,
213 pub replaced_journal: Option<std::path::PathBuf>,
215 _run: Option<RunClaim>,
217}
218
219static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
222
223struct RunClaim(std::path::PathBuf);
224
225impl RunClaim {
226 fn take(dir: &Path) -> Result<Self, PlaneError> {
227 let path = dir.join(JOURNAL);
228 let mut runs = RUNS
229 .lock()
230 .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
231 if runs.contains(&path) {
232 return Err(PlaneError(format!(
233 "another speccoder is working on this project (a run in this process holds its journal in {})",
234 dir.display()
235 )));
236 }
237 runs.push(path.clone());
238 Ok(Self(path))
239 }
240}
241
242impl Drop for RunClaim {
243 fn drop(&mut self) {
244 if let Ok(mut runs) = RUNS.lock() {
245 runs.retain(|p| *p != self.0);
246 }
247 }
248}
249
250static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
254 std::sync::Mutex::new(Vec::new());
255
256fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
263 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
264 let path = dir.join(JOURNAL);
265 let mut open = OPEN
266 .lock()
267 .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
268 open.retain(|(_, w)| w.strong_count() > 0);
269 if let Some(store) = open
270 .iter()
271 .find(|(p, _)| *p == path)
272 .and_then(|(_, w)| w.upgrade())
273 {
274 return Ok((store, None));
275 }
276 let (store, aside) = open_fresh(dir, &path)?;
277 open.push((path, Arc::downgrade(&store)));
278 Ok((store, aside))
279}
280
281fn open_fresh(
282 dir: &Path,
283 path: &Path,
284) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
285 match RedbStore::open_shared(path) {
286 Ok(store) => Ok((Arc::new(store), None)),
287 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
289 "another speccoder is working on this project (its journal in {} is in use): {e}",
290 dir.display()
291 ))),
292 Err(agentplane::core::StoreError::Unreadable(e)) => {
295 let stamp = std::time::SystemTime::now()
296 .duration_since(std::time::UNIX_EPOCH)
297 .map_or(0, |d| d.as_secs());
298 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
299 std::fs::rename(path, &aside).map_err(|r| {
300 PlaneError(format!(
301 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
302 ))
303 })?;
304 RedbStore::open_shared(path)
305 .map(|store| (Arc::new(store), Some(aside)))
306 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
307 }
308 Err(e) => Err(PlaneError(format!(
310 "cannot open the journal in {}: {e}",
311 dir.display()
312 ))),
313 }
314}
315
316pub fn open_reader(dir: &Path) -> Result<Option<Arc<RedbStore>>, PlaneError> {
325 let path = dir.join(JOURNAL);
326 if !path.exists() {
327 return Ok(None);
328 }
329 match RedbStore::open_read_only(&path) {
330 Ok(store) => Ok(Some(Arc::new(store))),
331 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
332 "another process holds the journal in {} alone (a speccoder build that does not share it): {e}",
333 dir.display()
334 ))),
335 Err(agentplane::core::StoreError::NeedsRepair(e)) => Err(PlaneError(format!(
338 "the last run ended without closing its journal in {}; the next run or decision repairs it ({e})",
339 dir.display()
340 ))),
341 Err(e) => Err(PlaneError(format!(
342 "cannot read the journal in {}: {e}",
343 dir.display()
344 ))),
345 }
346}
347
348#[must_use]
351pub fn provider_refusal(failure: &str) -> Option<String> {
352 let start = failure.find("refused the request: ")? + "refused the request: ".len();
353 let rest = &failure[start..];
354 let end = rest
355 .find("); ")
356 .or_else(|| rest.rfind(')'))
357 .unwrap_or(rest.len());
358 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
359}
360
361#[must_use]
364pub fn exhausted(limit: &agentplane::core::BudgetExceeded) -> String {
365 let said = match limit {
366 agentplane::core::BudgetExceeded::Recorded { limit, .. } => limit.clone(),
367 other => other.to_string(),
368 };
369 let tokens = limit.ceiling().map_or_else(
372 || said.starts_with("token"),
373 |c| c == agentplane::core::Ceiling::Tokens,
374 );
375 let raise = if tokens {
376 "; raise `[model] max_run_tokens`, or split the task"
377 } else {
378 ""
379 };
380 format!("the attempt's budget ran out: {said}{raise}")
381}
382
383#[must_use]
388pub fn why_not(status: &RunStatus) -> String {
389 let RunStatus::Failed(failure) = status else {
390 return status.as_str().to_owned();
391 };
392 let said = ["consumed resources and failed: ", "failed: "]
393 .iter()
394 .find_map(|m| failure.find(m).map(|i| &failure[i + m.len()..]))
395 .or_else(|| {
396 failure
397 .split_once(" of ")
398 .and_then(|(_, r)| r.split_once(": "))
399 .map(|(_, r)| r)
400 })
401 .unwrap_or(failure);
402 let cut: String = said.chars().take(300).collect();
403 if cut.len() < said.len() {
404 format!("{cut}…")
405 } else {
406 cut
407 }
408}
409
410pub async fn refused_account(
421 dir: &Path,
422) -> Result<Option<agentplane::journal::FailedEffect>, PlaneError> {
423 use agentplane::journal::JournalStore as _;
424 let Some(store) = open_reader(dir)? else {
425 return Ok(None);
426 };
427 let mut after: Option<(u64, agentplane::core::RunId)> = None;
428 loop {
429 let page = store
430 .recent_runs(after, 500)
431 .await
432 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
433 let Some(last) = page.last().cloned() else {
434 return Ok(None);
435 };
436 for (run, _) in &page {
437 let records = store
438 .read(*run, 0)
439 .await
440 .map_err(|e| PlaneError(format!("run {run}: {e}")))?;
441 if let Some(failed) = agentplane::journal::failed_effect(&records)
442 .filter(agentplane::journal::FailedEffect::refuses_the_account)
443 {
444 return Ok(Some(failed));
445 }
446 }
447 after = Some((last.1, last.0));
448 }
449}
450
451fn builder(
455 store: Arc<RedbStore>,
456 model: &ModelConfig,
457 hooks: Hooks,
458) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
459 let manifest = manifest_with(model, &hooks.mcp_tools)?;
460 let format = hooks.format.unwrap_or_else(crate::format::reference);
461 let agent = Agent::new(&manifest)
462 .skill(Propose {
463 cache_ttl: model.cache_ttl,
464 style: model.prompt_style(),
465 format,
466 })
467 .skill(attempt::Attempt {
468 style: model.prompt_style(),
469 cache_ttl: model.cache_ttl,
470 progress: hooks.progress.clone(),
471 format,
472 present: hooks.present.clone(),
473 })
474 .skill(host::Host {
475 style: model.prompt_style(),
476 progress: hooks.progress,
477 format,
478 present: hooks.present,
479 })
480 .skill(acceptance::Acceptance {
481 style: model.prompt_style(),
482 cache_ttl: model.cache_ttl,
483 format,
484 })
485 .skill(converge::Converge {
486 style: model.prompt_style(),
487 cache_ttl: model.cache_ttl,
488 format,
489 })
490 .skill(monitor::Monitor {
491 style: model.prompt_style(),
492 cache_ttl: model.cache_ttl,
493 format,
494 })
495 .skill(near_miss::NearMiss {
496 style: model.prompt_style(),
497 cache_ttl: model.cache_ttl,
498 format,
499 })
500 .skill(shape::Shape {
501 style: model.prompt_style(),
502 cache_ttl: model.cache_ttl,
503 });
504 let builder = Runtime::builder_on(store).agent(agent);
507 Ok((builder, manifest))
508}
509
510fn build(
511 store: Arc<RedbStore>,
512 model: &ModelConfig,
513 provider: Arc<dyn ModelProvider>,
514 tools: Option<Arc<crate::tools::WorkspaceTools>>,
515 hooks: Hooks,
516) -> Result<Arc<Runtime>, PlaneError> {
517 let mcp = hooks.mcp.clone();
518 let (mut builder, _) = builder(store, model, hooks)?;
519 builder = builder.provider(model.provider.clone(), provider);
520 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
523 Some(t) => t,
524 None => Arc::new(Unbound),
525 };
526 for server in ["fs", "proc", "git"] {
527 builder = builder.tool_server(server, Arc::clone(&client));
528 }
529 for (server, client) in &mcp {
530 builder = builder.tool_server(server.as_str(), Arc::clone(client));
531 }
532 builder
533 .try_build()
534 .map_err(|e| PlaneError(format!("building the plane: {e}")))
535}
536
537impl Plane {
538 pub fn open(
541 dir: &Path,
542 model: &ModelConfig,
543 provider: Arc<dyn ModelProvider>,
544 ) -> Result<Self, PlaneError> {
545 Self::open_with_tools(dir, model, provider, None)
546 }
547
548 pub fn open_with_tools(
550 dir: &Path,
551 model: &ModelConfig,
552 provider: Arc<dyn ModelProvider>,
553 tools: Option<Arc<crate::tools::WorkspaceTools>>,
554 ) -> Result<Self, PlaneError> {
555 Self::open_with(dir, model, provider, tools, Hooks::default())
556 }
557
558 pub fn open_with(
560 dir: &Path,
561 model: &ModelConfig,
562 provider: Arc<dyn ModelProvider>,
563 tools: Option<Arc<crate::tools::WorkspaceTools>>,
564 hooks: Hooks,
565 ) -> Result<Self, PlaneError> {
566 let claim = RunClaim::take(dir)?;
567 let (store, replaced_journal) = open_or_replace(dir)?;
568 Ok(Self {
569 runtime: build(store, model, provider, tools, hooks)?,
570 replaced_journal,
571 _run: Some(claim),
572 })
573 }
574
575 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
579 let (store, replaced_journal) = open_or_replace(dir)?;
580 let runtime = Runtime::builder_on(store)
581 .try_build()
582 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
583 Ok(Self {
584 runtime,
585 replaced_journal,
586 _run: None,
587 })
588 }
589
590 pub fn open_for_reading(dir: &Path) -> Result<Option<Self>, PlaneError> {
599 let Some(store) = open_reader(dir)? else {
600 return Ok(None);
601 };
602 let runtime = Runtime::builder_on(store)
603 .try_build()
604 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
605 Ok(Some(Self {
606 runtime,
607 replaced_journal: None,
608 _run: None,
609 }))
610 }
611
612 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
617 self.runtime
618 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
619 .await
620 .map(|_| ())
621 .map_err(|e| PlaneError(e.to_string()))
622 }
623
624 pub async fn withdraw(
627 &self,
628 task: &agentplane::core::Task,
629 by: &agentplane::core::Operator,
630 reason: &str,
631 ) -> Result<(), PlaneError> {
632 let err = |e: String| PlaneError(e);
633 match self.runtime.request_cancel(task.run, by, reason).await {
634 Ok(_) => {}
635 Err(agentplane::core::RuntimeError::CannotUnwind { .. }) => self
638 .runtime
639 .record_quarantine_decision(
640 task.run,
641 by,
642 reason,
643 agentplane::core::QuarantineDecision::Abandon,
644 )
645 .await
646 .map_err(|e| err(e.to_string()))?,
647 Err(e) => return Err(err(e.to_string())),
648 }
649 if let Some(tasks) = self.runtime.tasks() {
650 tasks
651 .withdraw_run(task.run, &[task.id])
652 .await
653 .map_err(|e| err(e.to_string()))?;
654 }
655 Ok(())
656 }
657
658 pub async fn waiting_on(
660 &self,
661 outcome: &RunOutcome,
662 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
663 let RunStatus::Suspended(reason) = &outcome.status else {
664 return Ok(None);
665 };
666 let Some(id) = reason.awaited_task() else {
667 return Ok(None);
668 };
669 self.task(id).await
670 }
671
672 pub async fn task(
674 &self,
675 id: agentplane::core::TaskId,
676 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
677 let Some(tasks) = self.runtime.tasks() else {
678 return Ok(None);
679 };
680 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
681 }
682
683 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
688 let journal = self.runtime.journal();
689 let mut out = RecordedCalls::default();
690 let mut after: Option<(u64, agentplane::core::RunId)> = None;
691 loop {
692 let page = journal
693 .recent_runs(after, 500)
694 .await
695 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
696 let Some(last) = page.last().cloned() else {
697 break;
698 };
699 for (run, at) in &page {
700 let records = match journal.read(*run, 0).await {
701 Ok(r) => r,
702 Err(e) => {
703 out.unreadable.push(format!("run {run}: {e}"));
704 continue;
705 }
706 };
707 let mut seen = std::collections::BTreeSet::new();
708 for record in records {
709 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
710 else {
711 continue;
712 };
713 let args = &descriptor.args;
714 if descriptor.kind != "tool.call"
715 || args["server"] != server
716 || args["tool"] != tool
717 {
718 continue;
719 }
720 let key = record
722 .body
723 .effect_key
724 .as_ref()
725 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
726 if seen.insert(key) {
727 out.calls.push(RecordedCall {
728 arguments: args["arguments"].clone(),
729 at: *at,
730 });
731 }
732 }
733 }
734 after = Some((last.1, last.0));
735 }
736 Ok(out)
737 }
738
739 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
741 let Some(tasks) = self.runtime.tasks() else {
742 return Ok(Vec::new());
743 };
744 let mut open = tasks
745 .queue(&[], 1000)
746 .await
747 .map_err(|e| PlaneError(e.to_string()))?;
748 open.sort_by_key(|t| t.created_at);
749 Ok(open)
750 }
751
752 pub async fn decide(
755 &self,
756 task: agentplane::core::TaskId,
757 decision: &agentplane::core::Decision,
758 ) -> Result<agentplane::core::Delivery, PlaneError> {
759 self.decide_at(task, decision, None).await
760 }
761
762 pub async fn decide_at(
765 &self,
766 task: agentplane::core::TaskId,
767 decision: &agentplane::core::Decision,
768 expected: Option<agentplane::core::Digest>,
769 ) -> Result<agentplane::core::Delivery, PlaneError> {
770 self.runtime
771 .decide_task_at(task, decision, &[], expected)
772 .await
773 .map_err(|e| PlaneError(e.to_string()))
774 }
775
776 pub async fn decide_inline(
784 &self,
785 mut outcome: RunOutcome,
786 approver: Option<&dyn crate::approvals::Approver>,
787 context: &crate::approvals::PresentContext,
788 ) -> Result<Inline, PlaneError> {
789 let mut always = Vec::new();
790 while let Some(task) = self.waiting_on(&outcome).await? {
791 let Some((decision, answer)) =
792 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
793 else {
794 return Ok(Inline {
795 outcome,
796 waiting: Some(task),
797 always,
798 });
799 };
800 self.decide(task.id, &decision).await?;
801 if answer.always && decision.approved() {
802 let via = answer
803 .by
804 .as_ref()
805 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
806 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
807 crate::approvals::answered_always(&task.id.to_hex());
808 always.push(Always {
809 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
810 .map(|r| r.task)
811 .unwrap_or_default(),
812 call: task.id.to_hex(),
813 via,
814 });
815 }
816 match self.outcome(outcome.run_id).await? {
817 Some(o) => outcome = o,
818 None => break,
819 }
820 }
821 Ok(Inline {
822 outcome,
823 waiting: None,
824 always,
825 })
826 }
827
828 pub async fn outcome(
832 &self,
833 run: agentplane::core::RunId,
834 ) -> Result<Option<RunOutcome>, PlaneError> {
835 if self
836 .runtime
837 .recorded_outcome(run)
838 .await
839 .map_err(|e| PlaneError(e.to_string()))?
840 .is_none()
841 {
842 return Ok(None);
843 }
844 self.runtime
845 .replay(run, agentplane::runtime::Mode::Strict)
846 .await
847 .map(Some)
848 .map_err(|e| PlaneError(e.to_string()))
849 }
850
851 #[doc(hidden)]
854 pub fn on_store(
855 store: Arc<RedbStore>,
856 model: &ModelConfig,
857 provider: Arc<dyn ModelProvider>,
858 tools: Option<Arc<crate::tools::WorkspaceTools>>,
859 ) -> Result<Self, PlaneError> {
860 Ok(Self {
861 runtime: build(store, model, provider, tools, Hooks::default())?,
862 replaced_journal: None,
863 _run: None,
864 })
865 }
866
867 #[doc(hidden)]
870 pub async fn resume(
871 &self,
872 run: agentplane::core::RunId,
873 ) -> Result<RunOutcome, agentplane::RuntimeError> {
874 self.runtime
875 .replay(run, agentplane::runtime::Mode::Resume)
876 .await
877 }
878
879 pub async fn converge(
881 &self,
882 feature: &str,
883 input: Tainted<Value>,
884 ) -> Result<RunOutcome, PlaneError> {
885 self.runtime
886 .start(converge::CAPABILITY, input)
887 .terms(RunTerms::default().correlated(
888 "feature",
889 &[CorrelationKey::new("feature", feature.to_owned())],
890 ))
891 .run()
892 .await
893 .map_err(|e| PlaneError(e.to_string()))
894 }
895
896 pub async fn shape(&self, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
899 self.runtime
900 .start(shape::CAPABILITY, input)
901 .run()
902 .await
903 .map_err(|e| PlaneError(e.to_string()))
904 }
905
906 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
908 self.runtime
909 .start(host::CAPABILITY, input)
910 .terms(
911 RunTerms::default()
912 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
913 )
914 .run()
915 .await
916 .map_err(|e| PlaneError(e.to_string()))
917 }
918
919 pub async fn monitor(
923 &self,
924 feature: &str,
925 task: &str,
926 input: Tainted<Value>,
927 ) -> Result<RunOutcome, PlaneError> {
928 self.judge_once(monitor::CAPABILITY, feature, task, input)
929 .await
930 }
931
932 pub async fn near_miss(
936 &self,
937 feature: &str,
938 task: &str,
939 input: Tainted<Value>,
940 ) -> Result<RunOutcome, PlaneError> {
941 self.judge_once(near_miss::CAPABILITY, feature, task, input)
942 .await
943 }
944
945 async fn judge_once(
949 &self,
950 capability: &str,
951 feature: &str,
952 task: &str,
953 input: Tainted<Value>,
954 ) -> Result<RunOutcome, PlaneError> {
955 use agentplane::runtime::{Admission, Mode};
956 use sha2::Digest as _;
957 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
958 let digest = sha2::Sha256::digest(
959 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
960 );
961 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
962 let key = format!("judge-{key}");
963 let key = key.as_str();
964 let terms = || {
965 RunTerms::default().correlated(
966 "task",
967 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
968 )
969 };
970 let admitted = self
971 .runtime
972 .start(capability, input.clone())
973 .terms(terms())
974 .once(key)
975 .run()
976 .await
977 .map_err(err)?;
978 match admitted {
979 Admission::Fresh(o) => Ok(o),
980 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
983 .runtime
984 .start(capability, input)
985 .terms(terms())
986 .run()
987 .await
988 .map_err(err),
989 Admission::Replayed(o) => self
990 .runtime
991 .replay(o.run_id, Mode::Strict)
992 .await
993 .map_err(err),
994 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
995 }
996 }
997
998 pub async fn acceptance(
1000 &self,
1001 feature: &str,
1002 task: &str,
1003 input: Tainted<Value>,
1004 ) -> Result<RunOutcome, PlaneError> {
1005 self.runtime
1006 .start(acceptance::CAPABILITY, input)
1007 .terms(RunTerms::default().correlated(
1008 "task",
1009 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1010 ))
1011 .run()
1012 .await
1013 .map_err(|e| PlaneError(e.to_string()))
1014 }
1015
1016 pub async fn attempt(
1018 &self,
1019 feature: &str,
1020 task: &str,
1021 input: Tainted<Value>,
1022 ) -> Result<RunOutcome, PlaneError> {
1023 self.runtime
1024 .start(attempt::CAPABILITY, input)
1025 .terms(RunTerms::default().correlated(
1026 "task",
1027 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1028 ))
1029 .run()
1030 .await
1031 .map_err(|e| PlaneError(e.to_string()))
1032 }
1033
1034 pub async fn attempt_keyed(
1042 &self,
1043 feature: &str,
1044 task: &str,
1045 input: Tainted<Value>,
1046 key: &str,
1047 ) -> Result<(RunOutcome, bool), PlaneError> {
1048 use agentplane::runtime::{Admission, Mode};
1049 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
1050 let admitted = self
1051 .runtime
1052 .start(attempt::CAPABILITY, input)
1053 .terms(RunTerms::default().correlated(
1054 "task",
1055 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1056 ))
1057 .once(key)
1058 .run()
1059 .await
1060 .map_err(err)?;
1061 match admitted {
1062 Admission::Fresh(o) => Ok((o, false)),
1063 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
1068 .runtime
1069 .replay(o.run_id, Mode::Resume)
1070 .await
1071 .map(|o| (o, true))
1072 .map_err(err),
1073 Admission::Replayed(o) => self
1074 .runtime
1075 .replay(o.run_id, Mode::Strict)
1076 .await
1077 .map(|o| (o, true))
1078 .map_err(err),
1079 Admission::InFlight(run) => self
1080 .runtime
1081 .replay(run, Mode::Resume)
1082 .await
1083 .map(|o| (o, true))
1084 .map_err(err),
1085 }
1086 }
1087
1088 pub async fn propose(
1090 &self,
1091 feature: &str,
1092 task: &str,
1093 input: Tainted<Value>,
1094 ) -> Result<RunOutcome, PlaneError> {
1095 self.runtime
1096 .start(CAPABILITY, input)
1097 .terms(RunTerms::default().correlated(
1098 "task",
1099 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
1100 ))
1101 .run()
1102 .await
1103 .map_err(|e| PlaneError(e.to_string()))
1104 }
1105}
1106
1107pub async fn admitted(
1110 dir: &Path,
1111 run: &str,
1112) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
1113 let id = agentplane::core::RunId::parse(run)
1114 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1115 let Some(store) = open_reader(dir)? else {
1116 return Ok(None);
1117 };
1118 let history = store
1119 .read(id, 1)
1120 .await
1121 .map_err(|e| PlaneError(e.to_string()))?;
1122 Ok(history
1123 .iter()
1124 .find_map(agentplane::journal::Record::admitted_input))
1125}
1126
1127pub async fn replay_strict(
1135 dir: &Path,
1136 model: &ModelConfig,
1137 run: &str,
1138 format: &'static dyn crate::format::Adapter,
1139) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
1140 let id = agentplane::core::RunId::parse(run)
1141 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
1142 let Some(store) = open_reader(dir)? else {
1143 return Ok(None);
1144 };
1145 let history = store
1146 .read(id, 1)
1147 .await
1148 .map_err(|e| PlaneError(e.to_string()))?;
1149 if history.is_empty() {
1150 return Ok(None);
1151 }
1152 let mcp_tools: Vec<crate::mcp::McpTool> = history
1156 .iter()
1157 .find_map(agentplane::journal::Record::admitted_input)
1158 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1159 .unwrap_or_default();
1160 let hooks = Hooks {
1161 mcp_tools,
1162 format: Some(format),
1163 ..Hooks::default()
1164 };
1165 let (builder, manifest) = builder(store, model, hooks)?;
1166 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1167 .try_build()
1168 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1169 runtime
1170 .verify(id)
1171 .await
1172 .map(Some)
1173 .map_err(|e| PlaneError(e.to_string()))
1174}
1175
1176#[must_use]
1181pub fn said(completion: &agentplane::model::Completion) -> Option<String> {
1182 let commentary = completion.commentary.as_deref().map(str::trim);
1183 let text = (!completion.tool_calls.is_empty()).then(|| completion.text.trim());
1184 let parts: Vec<&str> = [commentary, text]
1185 .into_iter()
1186 .flatten()
1187 .filter(|t| !t.is_empty())
1188 .collect();
1189 (!parts.is_empty()).then(|| parts.join("\n\n"))
1190}
1191
1192#[must_use]
1196pub fn answered(completion: &agentplane::model::Completion) -> Option<String> {
1197 if let Some(blocked) = completion
1198 .tool_calls
1199 .iter()
1200 .find(|c| c.name == attempt::REPORT_BLOCKED)
1201 {
1202 let reason = blocked.arguments["reason"].as_str().unwrap_or_default();
1203 return Some(match blocked.arguments["kind"].as_str() {
1204 Some(kind) => format!("Blocked ({kind}): {reason}"),
1205 None => format!("Blocked: {reason}"),
1206 });
1207 }
1208 if !completion.tool_calls.is_empty() {
1209 return None;
1210 }
1211 let summary = completion
1212 .structured
1213 .as_ref()
1214 .and_then(|s| s["summary"].as_str());
1215 let text = summary.unwrap_or(&completion.text).trim();
1216 (!text.is_empty()).then(|| text.to_owned())
1217}
1218
1219#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1223pub struct AccountTurn {
1224 pub turn: u32,
1226 pub reasoning: Option<String>,
1228 pub said: Option<String>,
1230 pub answer: Option<String>,
1232}
1233
1234pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1241 let Ok(id) = agentplane::core::RunId::parse(run) else {
1242 return Err(PlaneError(format!("{run} is not a run id")));
1243 };
1244 let lanes = std::fs::read_dir(dir.join("lanes"))
1248 .into_iter()
1249 .flatten()
1250 .flatten()
1251 .map(|e| e.path());
1252 let mut any = false;
1253 let mut history = Vec::new();
1254 for d in std::iter::once(dir.to_path_buf()).chain(lanes) {
1255 let Some(store) = open_reader(&d)? else {
1256 continue;
1257 };
1258 any = true;
1259 history = store
1260 .read(id, 1)
1261 .await
1262 .map_err(|e| PlaneError(e.to_string()))?;
1263 if !history.is_empty() {
1264 break;
1265 }
1266 }
1267 if !any {
1268 return Err(PlaneError(
1269 "no journal of this project's runs is here".to_owned(),
1270 ));
1271 }
1272 if history.is_empty() {
1273 return Err(PlaneError(format!(
1274 "run {run} is not in this project's journal"
1275 )));
1276 }
1277 let text = |t: &str| {
1278 let t = t.trim();
1279 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1280 };
1281 Ok(agentplane::model::turns(&history)
1282 .into_iter()
1283 .zip(1u32..)
1284 .filter_map(|(t, turn)| {
1285 let c = t.completion?;
1286 let said = if c.tool_calls.is_empty() {
1289 c.commentary.as_deref().and_then(text)
1290 } else {
1291 said(&c).as_deref().and_then(text)
1292 };
1293 let reasoning = c.reasoning.as_deref().and_then(text);
1294 let answer = answered(&c).as_deref().and_then(text);
1295 (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1296 turn,
1297 reasoning,
1298 said,
1299 answer,
1300 })
1301 })
1302 .collect())
1303}
1304
1305pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1313 let Ok(id) = agentplane::core::RunId::parse(run) else {
1314 return Ok(None);
1315 };
1316 let Some(store) = open_reader(dir)? else {
1317 return Ok(None);
1318 };
1319 let history = store
1320 .read(id, 1)
1321 .await
1322 .map_err(|e| PlaneError(e.to_string()))?;
1323 let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1324 .into_iter()
1325 .filter_map(|t| t.completion.map(|c| c.usage))
1326 .collect();
1327 Ok((!used.is_empty()).then(|| {
1328 let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1329 serde_json::json!({
1330 "input_tokens": sum(|u| u.input_tokens),
1331 "output_tokens": sum(|u| u.output_tokens),
1332 "cache_read_tokens": sum(|u| u.cache_read_tokens),
1333 "cache_write_tokens": sum(|u| u.cache_write_tokens),
1334 })
1335 }))
1336}
1337
1338#[derive(Debug)]
1340struct Unbound;
1341
1342#[async_trait]
1343impl agentplane::tools::ToolClient for Unbound {
1344 async fn call(
1345 &self,
1346 tool: &agentplane::tools::ToolId,
1347 _arguments: &Value,
1348 _provenance: Option<&agentplane::core::Provenance>,
1349 ) -> Result<Value, agentplane::tools::ToolError> {
1350 Err(agentplane::tools::ToolError::Refused {
1351 tool: tool.clone(),
1352 detail: "no worktree is bound to this plane".to_owned(),
1353 })
1354 }
1355
1356 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1357 agentplane::tools::Destination::Local
1358 }
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363 use super::*;
1364
1365 #[test]
1366 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1367 let workspace = include_str!("../../../../Cargo.toml");
1368 assert!(
1369 workspace.contains(&format!(
1370 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1371 )),
1372 "AGENTPLANE_VERSION is not the workspace's pin"
1373 );
1374 }
1375
1376 #[test]
1377 fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1378 use agentplane::model::{Completion, ToolCall};
1379 let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1380 let done = Completion {
1381 text: report.to_string(),
1382 structured: Some(report),
1383 ..Completion::default()
1384 };
1385 assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1386 let plain = Completion {
1387 text: " All done. ".to_owned(),
1388 ..Completion::default()
1389 };
1390 assert_eq!(answered(&plain).as_deref(), Some("All done."));
1391 let call = |name: &str, arguments: Value| Completion {
1392 tool_calls: vec![ToolCall {
1393 id: "c".to_owned(),
1394 name: name.to_owned(),
1395 arguments,
1396 }],
1397 ..Completion::default()
1398 };
1399 let blocked = call(
1400 attempt::REPORT_BLOCKED,
1401 serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1402 );
1403 assert_eq!(
1404 answered(&blocked).as_deref(),
1405 Some("Blocked (wrong_test): it asserts the old format")
1406 );
1407 assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1408 }
1409
1410 #[test]
1411 fn a_provider_refusal_is_read_from_the_run_failure() {
1412 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\")";
1413 let r = provider_refusal(failure).unwrap();
1414 assert_eq!(
1415 r,
1416 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1417 );
1418 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";
1420 assert_eq!(
1421 provider_refusal(own).as_deref(),
1422 Some("insufficient_quota: You exceeded your current quota.")
1423 );
1424 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1425 }
1426
1427 #[test]
1428 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1429 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());
1430 assert_eq!(
1431 why_not(&unusable),
1432 "'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"
1433 );
1434 let timeout =
1435 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1436 assert_eq!(why_not(&timeout), "timed out");
1437 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());
1438 assert_eq!(
1439 why_not(&own),
1440 "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1441 );
1442 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1443 }
1444
1445 #[test]
1446 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1447 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1448 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1449 .unwrap_or_else(|e| panic!("{e}"));
1450 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1451 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1452 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1453 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1455 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1456 drop((store, shared));
1457 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1460 let started = std::time::Instant::now();
1461 let second = open_or_replace(dir.path())
1462 .err()
1463 .map(|e| e.0)
1464 .unwrap_or_default();
1465 assert!(second.contains("another speccoder"), "{second}");
1466 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1467 assert!(open_reader(dir.path()).is_err());
1470 drop(held);
1471 assert!(
1472 open_or_replace(dir.path())
1473 .unwrap_or_else(|e| panic!("{}", e.0))
1474 .1
1475 .is_none()
1476 );
1477 }
1478
1479 #[test]
1480 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1481 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1482 assert!(
1484 open_reader(dir.path())
1485 .unwrap_or_else(|e| panic!("{}", e.0))
1486 .is_none()
1487 );
1488 assert!(
1489 Plane::open_for_reading(dir.path())
1490 .unwrap_or_else(|e| panic!("{}", e.0))
1491 .is_none()
1492 );
1493 assert!(
1494 !dir.path().join(JOURNAL).exists(),
1495 "a read created a journal"
1496 );
1497 let model =
1500 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1501 .unwrap_or_else(|e| panic!("{e}"))
1502 .unwrap_or_else(|| panic!("no model"));
1503 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1504 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1505 .unwrap_or_else(|e| panic!("{}", e.0));
1506 let reader = open_reader(dir.path())
1507 .unwrap_or_else(|e| panic!("{}", e.0))
1508 .unwrap_or_else(|| panic!("no journal"));
1509 assert!(reader.is_read_only());
1510 assert!(Plane::open(dir.path(), &model, fake).is_err());
1511 drop(run);
1512 }
1513}