1pub mod acceptance;
9pub mod attempt;
10pub mod converge;
11pub mod host;
12pub mod model_config;
13pub mod monitor;
14pub mod near_miss;
15pub mod propose;
16pub mod provenance;
17pub mod run_config;
18
19use std::path::Path;
20use std::sync::Arc;
21
22use agentplane::core::CorrelationKey;
23use agentplane::core::{ProtectedField, SourceId};
24use agentplane::manifest::{Budgets, Manifest, ModelRef, Models, ToolGrant};
25use agentplane::model::ModelProvider;
26use agentplane::prelude::*;
27use agentplane::runtime::{Agent, RunOutcome, RunTerms};
28use serde_json::Value;
29
30use self::model_config::ModelConfig;
31use self::propose::{CAPABILITY, Propose};
32
33pub const AGENTPLANE_VERSION: &str = "0.53.0";
36
37pub const JOURNAL: &str = "journal.redb";
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct PlaneError(pub String);
43
44pub fn manifest(model: &ModelConfig) -> Result<Manifest, PlaneError> {
47 manifest_with(model, &[])
48}
49
50pub fn manifest_with(
53 model: &ModelConfig,
54 mcp: &[crate::mcp::McpTool],
55) -> Result<Manifest, PlaneError> {
56 Manifest::builder("speccoder", env!("CARGO_PKG_VERSION"))
57 .configure(|spec| {
58 spec.capabilities.provides.push(CAPABILITY.to_owned());
59 spec.capabilities
60 .provides
61 .push(attempt::CAPABILITY.to_owned());
62 spec.capabilities
63 .provides
64 .push(acceptance::CAPABILITY.to_owned());
65 spec.capabilities
66 .provides
67 .push(converge::CAPABILITY.to_owned());
68 spec.capabilities
69 .provides
70 .push(monitor::CAPABILITY.to_owned());
71 spec.capabilities
72 .provides
73 .push(near_miss::CAPABILITY.to_owned());
74 spec.capabilities.provides.push(host::CAPABILITY.to_owned());
75 spec.models = Some(Models {
76 privileged: Some(ModelRef {
77 max_tokens: Some(model.max_output_tokens),
78 ..ModelRef::new(model.provider.clone(), model.model.clone())
79 }),
80 ..Models::default()
81 });
82 spec.security.max_sensitivity_egress = Some(Sensitivity::Internal);
84 spec.budgets = Some(Budgets {
85 max_tokens: Some(model.max_run_tokens),
86 ..Budgets::default()
87 });
88 let author =
95 agentplane::model::ModelId::new(model.provider.clone(), model.model.clone())
96 .source();
97 let catalog = crate::tools::catalog();
98 let mut sources: Vec<SourceId> = vec![
99 author,
100 SourceId::new(provenance::VERIFIER),
101 SourceId::new(provenance::WORKTREE),
102 SourceId::new(provenance::PERSON),
104 ];
105 sources.extend(catalog.iter().map(|t| provenance::tool(t.server, t.name)));
106 sources.extend(mcp.iter().map(|t| provenance::tool(&t.server, &t.tool)));
107 for t in catalog.iter().chain(crate::tools::internal().iter()) {
110 let mut grant = ToolGrant {
111 description: Some(t.description.to_owned()),
112 max_sensitivity: Some(Sensitivity::Internal),
113 mutates: t.mutates,
114 ..ToolGrant::new(agentplane::tools::ToolId::new(t.server, t.name).reference())
115 };
116 grant.protected_fields = t
117 .model_fields
118 .iter()
119 .map(|f| ProtectedField::from_sources(*f, sources.clone()))
120 .collect();
121 spec.tools.push(grant);
122 }
123 for t in mcp {
124 let mut grant = ToolGrant {
125 description: Some(t.description.clone()),
126 max_sensitivity: Some(Sensitivity::Internal),
127 mutates: true,
128 ..ToolGrant::new(agentplane::tools::ToolId::new(&t.server, &t.tool).reference())
129 };
130 grant.protected_fields = if t.fields.is_empty() {
131 vec![ProtectedField::one_of("", [serde_json::json!({})])]
134 } else {
135 t.fields
136 .iter()
137 .map(|f| ProtectedField::from_sources(f.as_str(), sources.clone()))
138 .collect()
139 };
140 spec.tools.push(grant);
141 }
142 })
143 .build()
144 .map_err(|e| PlaneError(format!("speccoder manifest: {e}")))
145}
146
147#[derive(Debug, Clone, Default)]
151pub struct Hooks {
152 pub progress: Option<Arc<dyn crate::progress::Progress>>,
154 pub mcp: Vec<(String, Arc<dyn agentplane::tools::ToolClient>)>,
156 pub mcp_tools: Vec<crate::mcp::McpTool>,
158 pub format: Option<&'static dyn crate::format::Adapter>,
160 pub present: crate::approvals::PresentContext,
162}
163
164#[derive(Debug, Clone, Default)]
166pub struct RecordedCalls {
167 pub calls: Vec<RecordedCall>,
169 pub unreadable: Vec<String>,
171}
172
173#[derive(Debug, Clone)]
175pub struct RecordedCall {
176 pub arguments: Value,
178 pub at: u64,
180}
181
182#[derive(Debug)]
184pub struct Inline {
185 pub outcome: RunOutcome,
187 pub waiting: Option<agentplane::core::Task>,
189 pub always: Vec<Always>,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct Always {
196 pub task: String,
198 pub call: String,
200 pub via: speccoder_core::actions::Via,
202}
203
204pub struct Plane {
206 runtime: Arc<Runtime>,
207 pub replaced_journal: Option<std::path::PathBuf>,
209 _run: Option<RunClaim>,
211}
212
213static RUNS: std::sync::Mutex<Vec<std::path::PathBuf>> = std::sync::Mutex::new(Vec::new());
216
217struct RunClaim(std::path::PathBuf);
218
219impl RunClaim {
220 fn take(dir: &Path) -> Result<Self, PlaneError> {
221 let path = dir.join(JOURNAL);
222 let mut runs = RUNS
223 .lock()
224 .map_err(|_| PlaneError("the run registry is poisoned".to_owned()))?;
225 if runs.contains(&path) {
226 return Err(PlaneError(format!(
227 "another speccoder is working on this project (a run in this process holds its journal in {})",
228 dir.display()
229 )));
230 }
231 runs.push(path.clone());
232 Ok(Self(path))
233 }
234}
235
236impl Drop for RunClaim {
237 fn drop(&mut self) {
238 if let Ok(mut runs) = RUNS.lock() {
239 runs.retain(|p| *p != self.0);
240 }
241 }
242}
243
244fn open_store(dir: &Path) -> Result<Arc<RedbStore>, PlaneError> {
245 open_or_replace(dir).map(|(store, _)| store)
246}
247
248static OPEN: std::sync::Mutex<Vec<(std::path::PathBuf, std::sync::Weak<RedbStore>)>> =
252 std::sync::Mutex::new(Vec::new());
253
254fn open_or_replace(dir: &Path) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
260 std::fs::create_dir_all(dir).map_err(|e| PlaneError(format!("{}: {e}", dir.display())))?;
261 let path = dir.join(JOURNAL);
262 let mut open = OPEN
263 .lock()
264 .map_err(|_| PlaneError("the journal registry is poisoned".to_owned()))?;
265 open.retain(|(_, w)| w.strong_count() > 0);
266 if let Some(store) = open
267 .iter()
268 .find(|(p, _)| *p == path)
269 .and_then(|(_, w)| w.upgrade())
270 {
271 return Ok((store, None));
272 }
273 let (store, aside) = open_fresh(dir, &path)?;
274 open.push((path, Arc::downgrade(&store)));
275 Ok((store, aside))
276}
277
278fn open_fresh(
279 dir: &Path,
280 path: &Path,
281) -> Result<(Arc<RedbStore>, Option<std::path::PathBuf>), PlaneError> {
282 let mut opened = RedbStore::open(path);
283 for _ in 0..20 {
284 if !matches!(opened, Err(agentplane::core::StoreError::InUse(_))) {
285 break;
286 }
287 std::thread::sleep(std::time::Duration::from_millis(100));
288 opened = RedbStore::open(path);
289 }
290 match opened {
291 Ok(store) => Ok((Arc::new(store), None)),
292 Err(agentplane::core::StoreError::InUse(e)) => Err(PlaneError(format!(
294 "another speccoder is working on this project (its journal in {} is in use): {e}",
295 dir.display()
296 ))),
297 Err(agentplane::core::StoreError::Unreadable(e)) => {
300 let stamp = std::time::SystemTime::now()
301 .duration_since(std::time::UNIX_EPOCH)
302 .map_or(0, |d| d.as_secs());
303 let aside = dir.join(format!("journal.{stamp}.unreadable.redb"));
304 std::fs::rename(path, &aside).map_err(|r| {
305 PlaneError(format!(
306 "the journal is unreadable ({e}) and cannot be moved aside: {r}"
307 ))
308 })?;
309 RedbStore::open(path)
310 .map(|store| (Arc::new(store), Some(aside)))
311 .map_err(|e| PlaneError(format!("cannot open a fresh journal: {e}")))
312 }
313 Err(e) => Err(PlaneError(format!(
315 "cannot open the journal in {}: {e}",
316 dir.display()
317 ))),
318 }
319}
320
321#[must_use]
324pub fn provider_refusal(failure: &str) -> Option<String> {
325 let start = failure.find("refused the request: ")? + "refused the request: ".len();
326 let rest = &failure[start..];
327 let end = rest
328 .find("); ")
329 .or_else(|| rest.rfind(')'))
330 .unwrap_or(rest.len());
331 Some(rest[..end].trim_end_matches(['"', ')']).trim().to_owned())
332}
333
334#[must_use]
338pub fn account_refusal(refusal: &str) -> bool {
339 let r = refusal.to_lowercase();
340 [
341 "credit",
342 "billing",
343 "quota",
344 "insufficient",
345 "api key",
346 "api_key",
347 "authentication",
348 "unauthorized",
349 ]
350 .iter()
351 .any(|w| r.contains(w))
352}
353
354fn builder(
358 store: Arc<RedbStore>,
359 model: &ModelConfig,
360 hooks: Hooks,
361) -> Result<(agentplane::runtime::RuntimeBuilder, Manifest), PlaneError> {
362 let manifest = manifest_with(model, &hooks.mcp_tools)?;
363 let format = hooks.format.unwrap_or_else(crate::format::reference);
364 let agent = Agent::new(&manifest)
365 .skill(Propose {
366 cache_ttl: model.cache_ttl,
367 style: model.prompt_style(),
368 format,
369 })
370 .skill(attempt::Attempt {
371 style: model.prompt_style(),
372 cache_ttl: model.cache_ttl,
373 progress: hooks.progress.clone(),
374 format,
375 present: hooks.present.clone(),
376 })
377 .skill(host::Host {
378 style: model.prompt_style(),
379 progress: hooks.progress,
380 format,
381 present: hooks.present,
382 })
383 .skill(acceptance::Acceptance {
384 style: model.prompt_style(),
385 cache_ttl: model.cache_ttl,
386 format,
387 })
388 .skill(converge::Converge {
389 style: model.prompt_style(),
390 cache_ttl: model.cache_ttl,
391 format,
392 })
393 .skill(monitor::Monitor {
394 style: model.prompt_style(),
395 cache_ttl: model.cache_ttl,
396 format,
397 })
398 .skill(near_miss::NearMiss {
399 style: model.prompt_style(),
400 cache_ttl: model.cache_ttl,
401 format,
402 });
403 let builder = Runtime::builder_on(store).agent(agent);
406 Ok((builder, manifest))
407}
408
409fn build(
410 store: Arc<RedbStore>,
411 model: &ModelConfig,
412 provider: Arc<dyn ModelProvider>,
413 tools: Option<Arc<crate::tools::WorkspaceTools>>,
414 hooks: Hooks,
415) -> Result<Arc<Runtime>, PlaneError> {
416 let mcp = hooks.mcp.clone();
417 let (mut builder, _) = builder(store, model, hooks)?;
418 builder = builder.provider(model.provider.clone(), provider);
419 let client: Arc<dyn agentplane::tools::ToolClient> = match tools {
422 Some(t) => t,
423 None => Arc::new(Unbound),
424 };
425 for server in ["fs", "proc", "git"] {
426 builder = builder.tool_server(server, Arc::clone(&client));
427 }
428 for (server, client) in &mcp {
429 builder = builder.tool_server(server.as_str(), Arc::clone(client));
430 }
431 builder
432 .try_build()
433 .map_err(|e| PlaneError(format!("building the plane: {e}")))
434}
435
436impl Plane {
437 pub fn open(
440 dir: &Path,
441 model: &ModelConfig,
442 provider: Arc<dyn ModelProvider>,
443 ) -> Result<Self, PlaneError> {
444 Self::open_with_tools(dir, model, provider, None)
445 }
446
447 pub fn open_with_tools(
449 dir: &Path,
450 model: &ModelConfig,
451 provider: Arc<dyn ModelProvider>,
452 tools: Option<Arc<crate::tools::WorkspaceTools>>,
453 ) -> Result<Self, PlaneError> {
454 Self::open_with(dir, model, provider, tools, Hooks::default())
455 }
456
457 pub fn open_with(
459 dir: &Path,
460 model: &ModelConfig,
461 provider: Arc<dyn ModelProvider>,
462 tools: Option<Arc<crate::tools::WorkspaceTools>>,
463 hooks: Hooks,
464 ) -> Result<Self, PlaneError> {
465 let claim = RunClaim::take(dir)?;
466 let (store, replaced_journal) = open_or_replace(dir)?;
467 Ok(Self {
468 runtime: build(store, model, provider, tools, hooks)?,
469 replaced_journal,
470 _run: Some(claim),
471 })
472 }
473
474 pub fn open_for_decisions(dir: &Path) -> Result<Self, PlaneError> {
478 let (store, replaced_journal) = open_or_replace(dir)?;
479 let runtime = Runtime::builder_on(store)
480 .try_build()
481 .map_err(|e| PlaneError(format!("building the plane: {e}")))?;
482 Ok(Self {
483 runtime,
484 replaced_journal,
485 _run: None,
486 })
487 }
488
489 pub async fn expire_due(&self, now: agentplane::core::Timestamp) -> Result<(), PlaneError> {
494 self.runtime
495 .sweep(now, std::time::Duration::from_secs(7 * 86_400))
496 .await
497 .map(|_| ())
498 .map_err(|e| PlaneError(e.to_string()))
499 }
500
501 pub async fn withdraw(
504 &self,
505 task: &agentplane::core::Task,
506 by: &agentplane::core::Operator,
507 reason: &str,
508 ) -> Result<(), PlaneError> {
509 let err = |e: String| PlaneError(e);
510 let _ = self
511 .runtime
512 .request_cancel(task.run, by, reason)
513 .await
514 .map_err(|e| err(e.to_string()))?;
515 if let Some(tasks) = self.runtime.tasks() {
516 tasks
517 .withdraw_run(task.run, &[task.id])
518 .await
519 .map_err(|e| err(e.to_string()))?;
520 }
521 Ok(())
522 }
523
524 pub async fn waiting_on(
526 &self,
527 outcome: &RunOutcome,
528 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
529 let RunStatus::Suspended(reason) = &outcome.status else {
530 return Ok(None);
531 };
532 let Some(id) = reason.awaited_task() else {
533 return Ok(None);
534 };
535 self.task(id).await
536 }
537
538 pub async fn task(
540 &self,
541 id: agentplane::core::TaskId,
542 ) -> Result<Option<agentplane::core::Task>, PlaneError> {
543 let Some(tasks) = self.runtime.tasks() else {
544 return Ok(None);
545 };
546 tasks.task(id).await.map_err(|e| PlaneError(e.to_string()))
547 }
548
549 pub async fn tool_calls(&self, server: &str, tool: &str) -> Result<RecordedCalls, PlaneError> {
554 let journal = self.runtime.journal();
555 let mut out = RecordedCalls::default();
556 let mut after: Option<(u64, agentplane::core::RunId)> = None;
557 loop {
558 let page = journal
559 .recent_runs(after, 500)
560 .await
561 .map_err(|e| PlaneError(format!("listing the journal's runs: {e}")))?;
562 let Some(last) = page.last().cloned() else {
563 break;
564 };
565 for (run, at) in &page {
566 let records = match journal.read(*run, 0).await {
567 Ok(r) => r,
568 Err(e) => {
569 out.unreadable.push(format!("run {run}: {e}"));
570 continue;
571 }
572 };
573 let mut seen = std::collections::BTreeSet::new();
574 for record in records {
575 let agentplane::RecordKind::EffectStarted { descriptor, .. } = record.kind()
576 else {
577 continue;
578 };
579 let args = &descriptor.args;
580 if descriptor.kind != "tool.call"
581 || args["server"] != server
582 || args["tool"] != tool
583 {
584 continue;
585 }
586 let key = record
588 .body
589 .effect_key
590 .as_ref()
591 .map_or_else(|| record.body.seq.to_string(), |k| format!("{k:?}"));
592 if seen.insert(key) {
593 out.calls.push(RecordedCall {
594 arguments: args["arguments"].clone(),
595 at: *at,
596 });
597 }
598 }
599 }
600 after = Some((last.1, last.0));
601 }
602 Ok(out)
603 }
604
605 pub async fn open_tasks(&self) -> Result<Vec<agentplane::core::Task>, PlaneError> {
607 let Some(tasks) = self.runtime.tasks() else {
608 return Ok(Vec::new());
609 };
610 let mut open = tasks
611 .queue(&[], 1000)
612 .await
613 .map_err(|e| PlaneError(e.to_string()))?;
614 open.sort_by_key(|t| t.created_at);
615 Ok(open)
616 }
617
618 pub async fn decide(
621 &self,
622 task: agentplane::core::TaskId,
623 decision: &agentplane::core::Decision,
624 ) -> Result<agentplane::core::Delivery, PlaneError> {
625 self.runtime
626 .decide_task(task, decision, &[])
627 .await
628 .map_err(|e| PlaneError(e.to_string()))
629 }
630
631 pub async fn decide_inline(
639 &self,
640 mut outcome: RunOutcome,
641 approver: Option<&dyn crate::approvals::Approver>,
642 context: &crate::approvals::PresentContext,
643 ) -> Result<Inline, PlaneError> {
644 let mut always = Vec::new();
645 while let Some(task) = self.waiting_on(&outcome).await? {
646 let Some((decision, answer)) =
647 approver.and_then(|a| crate::approvals::tasks::ask(a, &task, context))
648 else {
649 return Ok(Inline {
650 outcome,
651 waiting: Some(task),
652 always,
653 });
654 };
655 self.decide(task.id, &decision).await?;
656 if answer.always && decision.approved {
657 let via = answer
658 .by
659 .as_ref()
660 .and_then(|b| speccoder_core::actions::Via::read(&b.via))
661 .map_or(speccoder_core::actions::Via::Terminal, |(v, _)| v);
662 crate::approvals::answered_always(&task.id.to_hex());
663 always.push(Always {
664 task: crate::approvals::tasks::request_of(&task.justification.proposed_action)
665 .map(|r| r.task)
666 .unwrap_or_default(),
667 call: task.id.to_hex(),
668 via,
669 });
670 }
671 match self.outcome(outcome.run_id).await? {
672 Some(o) => outcome = o,
673 None => break,
674 }
675 }
676 Ok(Inline {
677 outcome,
678 waiting: None,
679 always,
680 })
681 }
682
683 pub async fn outcome(
687 &self,
688 run: agentplane::core::RunId,
689 ) -> Result<Option<RunOutcome>, PlaneError> {
690 if self
691 .runtime
692 .recorded_outcome(run)
693 .await
694 .map_err(|e| PlaneError(e.to_string()))?
695 .is_none()
696 {
697 return Ok(None);
698 }
699 self.runtime
700 .replay(run, agentplane::runtime::Mode::Strict)
701 .await
702 .map(Some)
703 .map_err(|e| PlaneError(e.to_string()))
704 }
705
706 #[doc(hidden)]
709 pub fn on_store(
710 store: Arc<RedbStore>,
711 model: &ModelConfig,
712 provider: Arc<dyn ModelProvider>,
713 tools: Option<Arc<crate::tools::WorkspaceTools>>,
714 ) -> Result<Self, PlaneError> {
715 Ok(Self {
716 runtime: build(store, model, provider, tools, Hooks::default())?,
717 replaced_journal: None,
718 _run: None,
719 })
720 }
721
722 #[doc(hidden)]
725 pub async fn resume(
726 &self,
727 run: agentplane::core::RunId,
728 ) -> Result<RunOutcome, agentplane::RuntimeError> {
729 self.runtime
730 .replay(run, agentplane::runtime::Mode::Resume)
731 .await
732 }
733
734 pub async fn converge(
736 &self,
737 feature: &str,
738 input: Tainted<Value>,
739 ) -> Result<RunOutcome, PlaneError> {
740 self.runtime
741 .start(converge::CAPABILITY, input)
742 .terms(RunTerms::default().correlated(
743 "feature",
744 &[CorrelationKey::new("feature", feature.to_owned())],
745 ))
746 .run()
747 .await
748 .map_err(|e| PlaneError(e.to_string()))
749 }
750
751 pub async fn skill(&self, name: &str, input: Tainted<Value>) -> Result<RunOutcome, PlaneError> {
753 self.runtime
754 .start(host::CAPABILITY, input)
755 .terms(
756 RunTerms::default()
757 .correlated("skill", &[CorrelationKey::new("skill", name.to_owned())]),
758 )
759 .run()
760 .await
761 .map_err(|e| PlaneError(e.to_string()))
762 }
763
764 pub async fn monitor(
768 &self,
769 feature: &str,
770 task: &str,
771 input: Tainted<Value>,
772 ) -> Result<RunOutcome, PlaneError> {
773 self.judge_once(monitor::CAPABILITY, feature, task, input)
774 .await
775 }
776
777 pub async fn near_miss(
781 &self,
782 feature: &str,
783 task: &str,
784 input: Tainted<Value>,
785 ) -> Result<RunOutcome, PlaneError> {
786 self.judge_once(near_miss::CAPABILITY, feature, task, input)
787 .await
788 }
789
790 async fn judge_once(
794 &self,
795 capability: &str,
796 feature: &str,
797 task: &str,
798 input: Tainted<Value>,
799 ) -> Result<RunOutcome, PlaneError> {
800 use agentplane::runtime::{Admission, Mode};
801 use sha2::Digest as _;
802 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
803 let digest = sha2::Sha256::digest(
804 format!("{capability}\n{feature}/{task}\n{}", input.peek()).as_bytes(),
805 );
806 let key: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
807 let key = format!("judge-{key}");
808 let key = key.as_str();
809 let terms = || {
810 RunTerms::default().correlated(
811 "task",
812 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
813 )
814 };
815 let admitted = self
816 .runtime
817 .start(capability, input.clone())
818 .terms(terms())
819 .once(key)
820 .run()
821 .await
822 .map_err(err)?;
823 match admitted {
824 Admission::Fresh(o) => Ok(o),
825 Admission::Replayed(o) if !matches!(o.status, RunStatus::Succeeded) => self
828 .runtime
829 .start(capability, input)
830 .terms(terms())
831 .run()
832 .await
833 .map_err(err),
834 Admission::Replayed(o) => self
835 .runtime
836 .replay(o.run_id, Mode::Strict)
837 .await
838 .map_err(err),
839 Admission::InFlight(run) => self.runtime.replay(run, Mode::Resume).await.map_err(err),
840 }
841 }
842
843 pub async fn acceptance(
845 &self,
846 feature: &str,
847 task: &str,
848 input: Tainted<Value>,
849 ) -> Result<RunOutcome, PlaneError> {
850 self.runtime
851 .start(acceptance::CAPABILITY, input)
852 .terms(RunTerms::default().correlated(
853 "task",
854 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
855 ))
856 .run()
857 .await
858 .map_err(|e| PlaneError(e.to_string()))
859 }
860
861 pub async fn attempt(
863 &self,
864 feature: &str,
865 task: &str,
866 input: Tainted<Value>,
867 ) -> Result<RunOutcome, PlaneError> {
868 self.runtime
869 .start(attempt::CAPABILITY, input)
870 .terms(RunTerms::default().correlated(
871 "task",
872 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
873 ))
874 .run()
875 .await
876 .map_err(|e| PlaneError(e.to_string()))
877 }
878
879 pub async fn attempt_keyed(
887 &self,
888 feature: &str,
889 task: &str,
890 input: Tainted<Value>,
891 key: &str,
892 ) -> Result<(RunOutcome, bool), PlaneError> {
893 use agentplane::runtime::{Admission, Mode};
894 let err = |e: agentplane::RuntimeError| PlaneError(e.to_string());
895 let admitted = self
896 .runtime
897 .start(attempt::CAPABILITY, input)
898 .terms(RunTerms::default().correlated(
899 "task",
900 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
901 ))
902 .once(key)
903 .run()
904 .await
905 .map_err(err)?;
906 match admitted {
907 Admission::Fresh(o) => Ok((o, false)),
908 Admission::Replayed(o) if matches!(o.status, RunStatus::Suspended(_)) => self
913 .runtime
914 .replay(o.run_id, Mode::Resume)
915 .await
916 .map(|o| (o, true))
917 .map_err(err),
918 Admission::Replayed(o) => self
919 .runtime
920 .replay(o.run_id, Mode::Strict)
921 .await
922 .map(|o| (o, true))
923 .map_err(err),
924 Admission::InFlight(run) => self
925 .runtime
926 .replay(run, Mode::Resume)
927 .await
928 .map(|o| (o, true))
929 .map_err(err),
930 }
931 }
932
933 pub async fn propose(
935 &self,
936 feature: &str,
937 task: &str,
938 input: Tainted<Value>,
939 ) -> Result<RunOutcome, PlaneError> {
940 self.runtime
941 .start(CAPABILITY, input)
942 .terms(RunTerms::default().correlated(
943 "task",
944 &[CorrelationKey::new("task", format!("{feature}/{task}"))],
945 ))
946 .run()
947 .await
948 .map_err(|e| PlaneError(e.to_string()))
949 }
950}
951
952pub async fn admitted(
955 dir: &Path,
956 run: &str,
957) -> Result<Option<agentplane::core::Tainted<Value>>, PlaneError> {
958 let id = agentplane::core::RunId::parse(run)
959 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
960 let store = open_store(dir)?;
961 let history = store
962 .read(id, 1)
963 .await
964 .map_err(|e| PlaneError(e.to_string()))?;
965 Ok(history
966 .iter()
967 .find_map(agentplane::journal::Record::admitted_input))
968}
969
970pub async fn replay_strict(
978 dir: &Path,
979 model: &ModelConfig,
980 run: &str,
981 format: &'static dyn crate::format::Adapter,
982) -> Result<Option<agentplane::runtime::Verdict>, PlaneError> {
983 let id = agentplane::core::RunId::parse(run)
984 .map_err(|_| PlaneError(format!("`{run}` is not a run id")))?;
985 let store = open_store(dir)?;
986 let history = store
987 .read(id, 1)
988 .await
989 .map_err(|e| PlaneError(e.to_string()))?;
990 if history.is_empty() {
991 return Ok(None);
992 }
993 let mcp_tools: Vec<crate::mcp::McpTool> = history
997 .iter()
998 .find_map(agentplane::journal::Record::admitted_input)
999 .and_then(|a| serde_json::from_value(a.peek()["settings"]["mcp"].clone()).ok())
1000 .unwrap_or_default();
1001 let hooks = Hooks {
1002 mcp_tools,
1003 format: Some(format),
1004 ..Hooks::default()
1005 };
1006 let (builder, manifest) = builder(store, model, hooks)?;
1007 let runtime = agentplane::runtime::replay_only::wire(builder, &[manifest], &history)
1008 .try_build()
1009 .map_err(|e| PlaneError(format!("building the replay plane: {e}")))?;
1010 runtime
1011 .verify(id)
1012 .await
1013 .map(Some)
1014 .map_err(|e| PlaneError(e.to_string()))
1015}
1016
1017#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1021pub struct AccountTurn {
1022 pub turn: u32,
1024 pub reasoning: Option<String>,
1026 pub said: Option<String>,
1029}
1030
1031pub async fn account(dir: &Path, run: &str) -> Result<Vec<AccountTurn>, PlaneError> {
1038 let Ok(id) = agentplane::core::RunId::parse(run) else {
1039 return Ok(Vec::new());
1040 };
1041 let store = open_store(dir)?;
1042 let history = store
1043 .read(id, 1)
1044 .await
1045 .map_err(|e| PlaneError(e.to_string()))?;
1046 let text = |t: &str| {
1047 let t = t.trim();
1048 (!t.is_empty()).then(|| crate::approvals::present::escape(t))
1049 };
1050 Ok(agentplane::model::turns(&history)
1051 .into_iter()
1052 .zip(1u32..)
1053 .filter_map(|(t, turn)| {
1054 let c = t.completion?;
1055 let said = (!c.tool_calls.is_empty()).then(|| text(&c.text)).flatten();
1056 let reasoning = c.reasoning.as_deref().and_then(text);
1057 (said.is_some() || reasoning.is_some()).then_some(AccountTurn {
1058 turn,
1059 reasoning,
1060 said,
1061 })
1062 })
1063 .collect())
1064}
1065
1066#[derive(Debug)]
1068struct Unbound;
1069
1070#[async_trait]
1071impl agentplane::tools::ToolClient for Unbound {
1072 async fn call(
1073 &self,
1074 tool: &agentplane::tools::ToolId,
1075 _arguments: &Value,
1076 _provenance: Option<&agentplane::core::Provenance>,
1077 ) -> Result<Value, agentplane::tools::ToolError> {
1078 Err(agentplane::tools::ToolError::Refused {
1079 tool: tool.clone(),
1080 detail: "no worktree is bound to this plane".to_owned(),
1081 })
1082 }
1083
1084 fn destination(&self, _tool: &agentplane::tools::ToolId) -> agentplane::tools::Destination {
1085 agentplane::tools::Destination::Local
1086 }
1087}
1088
1089#[cfg(test)]
1090mod tests {
1091 use super::*;
1092
1093 #[test]
1094 fn the_agentplane_version_named_in_summaries_is_the_pinned_one() {
1095 let workspace = include_str!("../../../../Cargo.toml");
1096 assert!(
1097 workspace.contains(&format!(
1098 "agentplane = {{ version = \"={AGENTPLANE_VERSION}\""
1099 )),
1100 "AGENTPLANE_VERSION is not the workspace's pin"
1101 );
1102 }
1103
1104 #[test]
1105 fn a_provider_refusal_is_read_from_the_run_failure() {
1106 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\")";
1107 let r = provider_refusal(failure).unwrap();
1108 assert_eq!(
1109 r,
1110 "credit_balance_exhausted: You have no credits remaining. Add credits at https://platform.openai.com/settings/organization/billing/."
1111 );
1112 assert!(account_refusal(&r));
1113 assert!(!account_refusal(
1114 "the continuation was not an Anthropic assistant-content array"
1115 ));
1116 assert_eq!(provider_refusal("Failed(\"timeout\")"), None);
1117 }
1118
1119 #[test]
1120 fn an_unreadable_journal_is_moved_aside_and_a_locked_one_is_an_error() {
1121 let dir = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1122 std::fs::write(dir.path().join(JOURNAL), b"written by another format")
1123 .unwrap_or_else(|e| panic!("{e}"));
1124 let (store, aside) = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1125 let aside = aside.unwrap_or_else(|| panic!("moved aside"));
1126 assert!(aside.exists() && dir.path().join(JOURNAL).exists());
1127 let shared = open_or_replace(dir.path()).unwrap_or_else(|e| panic!("{}", e.0));
1129 assert!(Arc::ptr_eq(&store, &shared.0) && shared.1.is_none());
1130 drop((store, shared));
1131 let held = RedbStore::open(dir.path().join(JOURNAL)).unwrap_or_else(|e| panic!("{e}"));
1134 let second = open_or_replace(dir.path())
1135 .err()
1136 .map(|e| e.0)
1137 .unwrap_or_default();
1138 assert!(second.contains("another speccoder"), "{second}");
1139 drop(held);
1140 assert!(
1141 open_or_replace(dir.path())
1142 .unwrap_or_else(|e| panic!("{}", e.0))
1143 .1
1144 .is_none()
1145 );
1146 }
1147}