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