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 Ok(Vec::new());
1276 };
1277 let Some(store) = open_reader(dir)? else {
1278 return Ok(Vec::new());
1279 };
1280 let history = store
1281 .read(id, 1)
1282 .await
1283 .map_err(|e| PlaneError(e.to_string()))?;
1284 let text = |t: &str| {
1285 let t = t.trim();
1286 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1287 };
1288 Ok(agentplane::model::turns(&history)
1289 .into_iter()
1290 .zip(1u32..)
1291 .filter_map(|(t, turn)| {
1292 let c = t.completion?;
1293 let said = if c.tool_calls.is_empty() {
1296 c.commentary.as_deref().and_then(text)
1297 } else {
1298 said(&c).as_deref().and_then(text)
1299 };
1300 let reasoning = c.reasoning.as_deref().and_then(text);
1301 let answer = answered(&c).as_deref().and_then(text);
1302 (said.is_some() || reasoning.is_some() || answer.is_some()).then_some(AccountTurn {
1303 turn,
1304 reasoning,
1305 said,
1306 answer,
1307 })
1308 })
1309 .collect())
1310}
1311
1312pub async fn spent(dir: &Path, run: &str) -> Result<Option<Value>, PlaneError> {
1320 let Ok(id) = agentplane::core::RunId::parse(run) else {
1321 return Ok(None);
1322 };
1323 let Some(store) = open_reader(dir)? else {
1324 return Ok(None);
1325 };
1326 let history = store
1327 .read(id, 1)
1328 .await
1329 .map_err(|e| PlaneError(e.to_string()))?;
1330 let used: Vec<agentplane::model::Usage> = agentplane::model::turns(&history)
1331 .into_iter()
1332 .filter_map(|t| t.completion.map(|c| c.usage))
1333 .collect();
1334 Ok((!used.is_empty()).then(|| {
1335 let sum = |f: fn(&agentplane::model::Usage) -> u64| used.iter().map(f).sum::<u64>();
1336 serde_json::json!({
1337 "input_tokens": sum(|u| u.input_tokens),
1338 "output_tokens": sum(|u| u.output_tokens),
1339 "cache_read_tokens": sum(|u| u.cache_read_tokens),
1340 "cache_write_tokens": sum(|u| u.cache_write_tokens),
1341 })
1342 }))
1343}
1344
1345#[derive(Debug)]
1347struct Unbound;
1348
1349#[async_trait]
1350impl agentplane::tools::ToolClient for Unbound {
1351 async fn call(
1352 &self,
1353 tool: &agentplane::tools::ToolId,
1354 _arguments: &Value,
1355 _provenance: Option<&agentplane::core::Provenance>,
1356 ) -> Result<Value, agentplane::tools::ToolError> {
1357 Err(agentplane::tools::ToolError::Refused {
1358 tool: tool.clone(),
1359 detail: "no worktree is bound to this plane".to_owned(),
1360 })
1361 }
1362
1363 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1364 agentplane::tools::Destination::Local
1365 }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371
1372 #[test]
1373 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1374 let workspace = include_str!("../../../../Cargo.toml");
1375 assert!(
1376 workspace.contains(&format!(
1377 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1378 )),
1379 "AGENTPLANE_VERSION is not the workspace's pin"
1380 );
1381 }
1382
1383 #[test]
1384 fn an_attempt_ends_in_its_answer_s_summary_or_why_it_stopped() {
1385 use agentplane::model::{Completion, ToolCall};
1386 let report = serde_json::json!({ "summary": "Added the parser.", "status": "done" });
1387 let done = Completion {
1388 text: report.to_string(),
1389 structured: Some(report),
1390 ..Completion::default()
1391 };
1392 assert_eq!(answered(&done).as_deref(), Some("Added the parser."));
1393 let plain = Completion {
1394 text: " All done. ".to_owned(),
1395 ..Completion::default()
1396 };
1397 assert_eq!(answered(&plain).as_deref(), Some("All done."));
1398 let call = |name: &str, arguments: Value| Completion {
1399 tool_calls: vec![ToolCall {
1400 id: "c".to_owned(),
1401 name: name.to_owned(),
1402 arguments,
1403 }],
1404 ..Completion::default()
1405 };
1406 let blocked = call(
1407 attempt::REPORT_BLOCKED,
1408 serde_json::json!({ "kind": "wrong_test", "evidence": "tests/a.py", "reason": "it asserts the old format" }),
1409 );
1410 assert_eq!(
1411 answered(&blocked).as_deref(),
1412 Some("Blocked (wrong_test): it asserts the old format")
1413 );
1414 assert_eq!(answered(&call("read_file", serde_json::json!({}))), None);
1415 }
1416
1417 #[test]
1418 fn a_provider_refusal_is_read_from_the_run_failure() {
1419 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\")";
1420 let r = provider_refusal(failure).unwrap();
1421 assert_eq!(
1422 r,
1423 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1424 );
1425 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";
1427 assert_eq!(
1428 provider_refusal(own).as_deref(),
1429 Some("insufficient_quota: You exceeded your current quota.")
1430 );
1431 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1432 }
1433
1434 #[test]
1435 fn a_failed_run_says_why_in_the_failure_s_own_words() {
1436 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());
1437 assert_eq!(
1438 why_not(&unusable),
1439 "'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"
1440 );
1441 let timeout =
1442 RunStatus::Failed("effect ek:9 failed on attempt 2 of 2: timed out".to_owned());
1443 assert_eq!(why_not(&timeout), "timed out");
1444 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());
1445 assert_eq!(
1446 why_not(&own),
1447 "'openai/gpt-5.4-mini' returned an unusable answer: not JSON"
1448 );
1449 assert_eq!(why_not(&RunStatus::Succeeded), "succeeded");
1450 }
1451
1452 #[test]
1453 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1454 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1455 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1456 .unwrap_or_else(|e| panic!("{e}"));
1457 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1458 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1459 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1460 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1462 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1463 drop((store, shared));
1464 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1467 let started = std::time::Instant::now();
1468 let second = open_or_replace(dir.path())
1469 .err()
1470 .map(|e| e.0)
1471 .unwrap_or_default();
1472 assert!(second.contains("another speccoder"), "{second}");
1473 assert!(started.elapsed() < std::time::Duration::from_millis(500));
1474 assert!(open_reader(dir.path()).is_err());
1477 drop(held);
1478 assert!(
1479 open_or_replace(dir.path())
1480 .unwrap_or_else(|e| panic!("{}", e.0))
1481 .1
1482 .is_none()
1483 );
1484 }
1485
1486 #[test]
1487 fn a_reader_creates_nothing_and_reads_beside_this_process_s_writer() {
1488 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1489 assert!(
1491 open_reader(dir.path())
1492 .unwrap_or_else(|e| panic!("{}", e.0))
1493 .is_none()
1494 );
1495 assert!(
1496 Plane::open_for_reading(dir.path())
1497 .unwrap_or_else(|e| panic!("{}", e.0))
1498 .is_none()
1499 );
1500 assert!(
1501 !dir.path().join(JOURNAL).exists(),
1502 "a read created a journal"
1503 );
1504 let model =
1507 model_config::parse("[model]\nprovider = \"openai\"\nmodel = \"gpt-5.4-mini\"\n")
1508 .unwrap_or_else(|e| panic!("{e}"))
1509 .unwrap_or_else(|| panic!("no model"));
1510 let fake: Arc<dyn ModelProvider> = agentplane::model::fake::FakeProvider::new();
1511 let run = Plane::open(dir.path(), &model, Arc::clone(&fake))
1512 .unwrap_or_else(|e| panic!("{}", e.0));
1513 let reader = open_reader(dir.path())
1514 .unwrap_or_else(|e| panic!("{}", e.0))
1515 .unwrap_or_else(|| panic!("no journal"));
1516 assert!(reader.is_read_only());
1517 assert!(Plane::open(dir.path(), &model, fake).is_err());
1518 drop(run);
1519 }
1520}