1use std::cell::RefCell;
2use std::rc::Rc;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use thiserror::Error;
8
9use crate::context::{ContextBuilder, ContextError};
10use crate::event::{EventError, EventKind, EventStore};
11use crate::falsegreen::{FalseGreenError, FalseGreenResult, FalseGreenVerifier};
12use crate::genui::{
13 ActionCatalog, ActionSourceType, HostCapabilities, HostConfirmation, NegotiatedCapabilities,
14 Surface, derive_host_workspace_state_identity,
15};
16use crate::genui_composition::{
17 AdmittedComposedSurface, AuthorityClass, CompositionContext, CompositionFailureCode,
18 CompositionLimits, LiveAdmissionSnapshot, ProductionComposition, TrustedDataHandle,
19 TrustedNegotiationContext, request_from_provider, trusted_fallback,
20};
21use crate::inference::{
22 InferenceError, InferenceProvider, InferenceRequest, ModelAction, ToolCall, parse_action,
23};
24use crate::session::{Session, SessionError, SessionState};
25use crate::tools::{NativeTools, ToolError, ToolResult};
26use crate::workspace::WorkspaceError;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29pub struct AgentLimits {
30 pub max_model_turns: u32,
31 pub max_tool_calls: u32,
32 pub max_repair_cycles: u32,
33 pub max_wall_time: Duration,
34}
35
36impl Default for AgentLimits {
37 fn default() -> Self {
38 Self {
39 max_model_turns: 40,
40 max_tool_calls: 80,
41 max_repair_cycles: 2,
42 max_wall_time: Duration::from_secs(1_800),
43 }
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct RunOutcome {
49 pub session_id: String,
50 pub state: SessionState,
51 pub model_turns: u32,
52 pub tool_calls: u32,
53 pub repair_cycles: u32,
54 pub falsegreen_result: Option<FalseGreenResult>,
55 pub metrics: RunMetrics,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub struct RunMetrics {
60 pub approximate_input_tokens: u64,
61 pub reported_input_tokens: Option<u64>,
62 pub reported_output_tokens: Option<u64>,
63 pub peak_context_tokens: u64,
64 pub failed_tool_calls: u32,
65 pub invalid_tool_calls: u32,
66 pub shell_commands: u32,
67 pub test_executions: u32,
68 pub candidate_count: u32,
69 pub falsegreen_attempts: u32,
70 pub patch_attempts: u32,
71 pub rejected_patch_attempts: u32,
72 pub total_wall_time_ms: u64,
73}
74
75#[derive(Debug, Error)]
76pub enum AgentError {
77 #[error(transparent)]
78 Event(#[from] EventError),
79 #[error(transparent)]
80 Session(#[from] SessionError),
81 #[error(transparent)]
82 Context(#[from] ContextError),
83 #[error(transparent)]
84 Workspace(#[from] WorkspaceError),
85 #[error(transparent)]
86 Tool(#[from] ToolError),
87 #[error("system clock is before the session start")]
88 InvalidClock,
89 #[error("event store must be outside the implementation workspace: {0}")]
90 EventStoreInsideWorkspace(String),
91 #[error(
92 "workspace fingerprint changed since the persisted turn boundary (expected {expected}, found {actual})"
93 )]
94 ResumeFingerprintMismatch { expected: String, actual: String },
95 #[error("replacement session must be in candidate_ready state, found {0:?}")]
96 InvalidReplacementState(SessionState),
97 #[error("GenUI composition failed: {0}")]
98 Composition(String),
99}
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102enum VerificationMode {
103 Ordinary,
104 ReplacementOnly,
105}
106
107type CompositionAdmissionHook = Box<dyn FnOnce(&mut CompositionContext, &mut ActionCatalog)>;
108type LiveCompositionAdmissionHook = Box<dyn FnOnce(&mut NativeTools)>;
109type LiveCompositionAuthorityHook = Box<dyn FnOnce(&mut NativeTools, &mut EventStore)>;
110type LiveCompositionPublicationHook = Box<dyn FnOnce(&mut ActionCatalog)>;
111type LiveCompositionPublicationAuthorityHook =
112 Box<dyn FnOnce(&mut NativeTools, &mut EventStore, &mut ActionCatalog)>;
113
114pub struct Agent<'a, P, V>
115where
116 P: InferenceProvider,
117 V: FalseGreenVerifier,
118{
119 store: &'a mut EventStore,
120 session: Session,
121 provider: P,
122 verifier: V,
123 tools: NativeTools,
124 context_builder: ContextBuilder,
125 limits: AgentLimits,
126 temperature: f32,
127 latest_falsegreen: Option<FalseGreenResult>,
128 pause_after_model_turns: Option<u32>,
129 composition_admission_hook: Option<CompositionAdmissionHook>,
130 live_composition_admission_hook: Option<LiveCompositionAdmissionHook>,
131 live_composition_authority_hook: Option<LiveCompositionAuthorityHook>,
132 live_composition_publication_hook: Option<LiveCompositionPublicationHook>,
133 live_composition_publication_authority_hook: Option<LiveCompositionPublicationAuthorityHook>,
134}
135
136impl<'a, P, V> Agent<'a, P, V>
137where
138 P: InferenceProvider,
139 V: FalseGreenVerifier,
140{
141 #[allow(clippy::too_many_arguments)]
142 pub const fn new(
143 store: &'a mut EventStore,
144 session: Session,
145 provider: P,
146 verifier: V,
147 tools: NativeTools,
148 context_builder: ContextBuilder,
149 limits: AgentLimits,
150 ) -> Self {
151 Self {
152 store,
153 session,
154 provider,
155 verifier,
156 tools,
157 context_builder,
158 limits,
159 temperature: 0.0,
160 latest_falsegreen: None,
161 pause_after_model_turns: None,
162 composition_admission_hook: None,
163 live_composition_admission_hook: None,
164 live_composition_authority_hook: None,
165 live_composition_publication_hook: None,
166 live_composition_publication_authority_hook: None,
167 }
168 }
169
170 pub fn execute_genui_action(
173 &mut self,
174 principal: &str,
175 surface: &Surface,
176 catalog: &ActionCatalog,
177 action_id: &str,
178 payload: Value,
179 confirmation: Option<&HostConfirmation>,
180 ) -> Result<ToolResult, AgentError> {
181 catalog
182 .validate_surface(surface, &HostCapabilities::default())
183 .map_err(|error| AgentError::Composition(error.to_string()))?;
184 let source_type = catalog
185 .resolve(action_id)
186 .map(|binding| binding.source_type())
187 .ok_or_else(|| AgentError::Composition("action is not in host catalog".to_owned()))?;
188 match source_type {
189 ActionSourceType::Mcp => self
190 .tools
191 .execute_genui_action(
192 self.store,
193 &self.session.id,
194 principal,
195 surface,
196 catalog,
197 action_id,
198 payload,
199 confirmation,
200 )
201 .map_err(AgentError::from),
202 ActionSourceType::HostLocal => self.execute_host_local_genui_action_direct(
203 principal,
204 catalog,
205 action_id,
206 payload,
207 confirmation,
208 ),
209 ActionSourceType::Auto => Err(AgentError::Composition(
210 "source_unresolved: executable dispatch requires an explicit transport".to_owned(),
211 )),
212 }
213 }
214
215 #[allow(clippy::too_many_arguments)]
220 pub fn execute_admitted_genui_action(
221 &mut self,
222 context: &CompositionContext,
223 admitted: &AdmittedComposedSurface,
224 catalog: &ActionCatalog,
225 action_id: &str,
226 payload: Value,
227 confirmation: Option<&HostConfirmation>,
228 ) -> Result<ToolResult, AgentError> {
229 if !admitted
230 .surface()
231 .actions
232 .iter()
233 .any(|action| action.id == action_id)
234 {
235 return Err(AgentError::Composition(
236 "action is not present in the admitted composition".to_owned(),
237 ));
238 }
239 admitted
240 .validate_for_execution(context, catalog, self.store)
241 .map_err(|error| AgentError::Composition(error.to_string()))?;
242 match context.action_source_type_for_action(action_id) {
243 Some(crate::genui_composition::ActionSourceType::HostLocal) => self
244 .execute_host_local_genui_action(
245 context,
246 catalog,
247 action_id,
248 payload,
249 confirmation,
250 ),
251 Some(crate::genui_composition::ActionSourceType::Mcp) => self.execute_genui_action(
252 context.principal(),
253 admitted.surface(),
254 catalog,
255 action_id,
256 payload,
257 confirmation,
258 ),
259 Some(crate::genui_composition::ActionSourceType::Auto) | None => {
260 Err(AgentError::Composition(
261 "source_unresolved: executable dispatch requires an explicit transport"
262 .to_owned(),
263 ))
264 }
265 }
266 }
267
268 fn execute_host_local_genui_action(
272 &mut self,
273 context: &CompositionContext,
274 catalog: &ActionCatalog,
275 action_id: &str,
276 payload: Value,
277 confirmation: Option<&HostConfirmation>,
278 ) -> Result<ToolResult, AgentError> {
279 self.execute_host_local_genui_action_direct(
280 context.principal(),
281 catalog,
282 action_id,
283 payload,
284 confirmation,
285 )
286 }
287
288 fn execute_host_local_genui_action_direct(
289 &mut self,
290 principal: &str,
291 catalog: &ActionCatalog,
292 action_id: &str,
293 payload: Value,
294 confirmation: Option<&HostConfirmation>,
295 ) -> Result<ToolResult, AgentError> {
296 let binding = catalog.resolve(action_id).ok_or_else(|| {
297 AgentError::Composition("host-local action is not in the catalog".to_owned())
298 })?;
299 if binding.source_type() != ActionSourceType::HostLocal {
300 return Err(AgentError::Composition(
301 "catalog binding is not host-local".to_owned(),
302 ));
303 }
304 if binding.session_id() != self.session.id || binding.principal() != principal {
305 return Err(AgentError::Composition(
306 "session or principal changed".to_owned(),
307 ));
308 }
309 let identity = catalog
310 .identity_for(action_id, &payload)
311 .map_err(|error| AgentError::Composition(error.to_string()))?;
312 if binding.requires_confirmation() {
313 let supplied = confirmation.ok_or_else(|| {
314 AgentError::Composition("host confirmation is required".to_owned())
315 })?;
316 if supplied.identity() != &identity {
317 return Err(AgentError::Composition(
318 "confirmation is stale or bound to another action".to_owned(),
319 ));
320 }
321 }
322 catalog
323 .validate_durable_current_action(self.store, action_id)
324 .map_err(|error| AgentError::Composition(error.to_string()))?;
325 self.store
326 .start_genui_action(
327 &self.session.id,
328 action_id,
329 &identity,
330 binding.requires_confirmation(),
331 )
332 .map_err(|error| AgentError::Composition(error.to_string()))?;
333 catalog
334 .validate_current_action(action_id)
335 .map_err(|error| AgentError::Composition(error.to_string()))?;
336 catalog
337 .validate_durable_current_action(self.store, action_id)
338 .map_err(|error| AgentError::Composition(error.to_string()))?;
339 let metadata = json!({
340 "authority_state": "host_local",
341 "transport": "host_local_adapter",
342 "session_id": self.session.id,
343 "principal": principal,
344 });
345 let result_digest = crate::genui::digest_value(&metadata);
346 self.store
347 .complete_genui_action(&self.session.id, action_id, &identity, true, &result_digest)
348 .map_err(|error| AgentError::Composition(error.to_string()))?;
349 Ok(ToolResult {
350 tool: action_id.to_owned(),
351 tool_call_id: None,
352 ok: true,
353 exit_code: Some(0),
354 timed_out: false,
355 stdout: String::new(),
356 stderr: String::new(),
357 output_truncated: false,
358 metadata,
359 })
360 }
361
362 pub fn compose_genui(
368 &mut self,
369 context: &CompositionContext,
370 untrusted_task_data: &str,
371 host: &HostCapabilities,
372 _capabilities: &NegotiatedCapabilities,
373 catalog: &mut ActionCatalog,
374 limits: CompositionLimits,
375 ) -> Result<ProductionComposition, AgentError> {
376 let admission_hook = self.composition_admission_hook.take();
380 let admission_context = context.clone();
381 let context = &admission_context;
382 let negotiated = TrustedNegotiationContext::from_host(host)
383 .map_err(|error| AgentError::Composition(error.to_string()))?;
384 if context.session_id() != self.session.id {
385 return Err(AgentError::Composition(
386 "composition context is bound to a different Agent session".to_owned(),
387 ));
388 }
389 let (workspace_identity, workspace_generation) = self
393 .tools
394 .live_workspace_token(context.session_id(), context.principal())?;
395 let context_digest = context
396 .identity_digest_for_audit()
397 .map_err(|error| AgentError::Composition(error.to_string()))?;
398 self.store.append(
399 &self.session.id,
400 EventKind::CompositionRequested,
401 &json!({
402 "context_id": context.context_id(),
403 "context_digest": context_digest,
404 "session_id": context.session_id(),
405 "principal": context.principal(),
406 "generation": context.generation(),
407 }),
408 )?;
409
410 let proposal =
411 request_from_provider(&mut self.provider, context, untrusted_task_data, limits);
412 let composed: Result<(), crate::genui_composition::CompositionError> = match proposal {
413 Ok(envelope) => {
414 let digest = crate::genui::mcp_schema_digest(
415 &serde_json::to_value(&envelope)
416 .map_err(|error| AgentError::Composition(error.to_string()))?,
417 );
418 let raw_size = serde_json::to_vec(&envelope)
419 .map_err(|error| AgentError::Composition(error.to_string()))?
420 .len();
421 self.store.append(
422 &self.session.id,
423 EventKind::ModelProposalReceived,
424 &json!({
425 "proposal_digest": digest,
426 "proposal_bytes": raw_size,
427 "provider": crate::genui_composition::bounded_audit_identity(
428 &self.provider.capabilities().provider,
429 ),
430 }),
431 )?;
432 match crate::genui_composition::compose(context, &envelope, host, limits) {
433 Ok(composed) => {
434 self.store.append(
435 &self.session.id,
436 EventKind::TrustedHandleResolved,
437 &json!({
438 "context_id": context.context_id(),
439 "data_refs": composed.resolved_data_ref_count(),
440 "action_refs": composed.resolved_action_ref_count(),
441 }),
442 )?;
443 let mut post_resolution_context = admission_context.clone();
444 if let Some(hook) = admission_hook {
445 hook(&mut post_resolution_context, catalog);
446 }
447 let live_hook = self.live_composition_admission_hook.take();
448 if let Some(hook) = live_hook {
449 hook(&mut self.tools);
450 }
451 let authority_hook = self.live_composition_authority_hook.take();
452 if let Some(hook) = authority_hook {
453 hook(&mut self.tools, self.store);
454 }
455 let publication_hook = self.live_composition_publication_hook.take();
456 let publication_authority_hook =
457 self.live_composition_publication_authority_hook.take();
458 let tools_cell = Rc::new(RefCell::new(&mut self.tools));
464 let live_tools_cell = Rc::clone(&tools_cell);
465 let publication_tools_cell = Rc::clone(&tools_cell);
466 let expected_workspace_identity = workspace_identity.clone();
467 let expected_workspace_generation = workspace_generation;
468 let (expected_catalog_generation, expected_catalog_digest) =
469 crate::genui_composition::action_catalog_authority(catalog);
470 match composed.admit_with_store_and_live_fence_and_agent_publication(
471 &post_resolution_context,
472 host,
473 negotiated.capabilities(),
474 catalog,
475 self.store,
476 |staged, store, previous| {
477 let tools = live_tools_cell.borrow();
478 Self::validate_live_admission_authority(
479 &tools,
480 &composed,
481 &post_resolution_context,
482 staged,
483 store,
484 &expected_workspace_identity,
485 expected_workspace_generation,
486 previous,
487 )
488 },
489 move |catalog, staged, final_snapshot, live_fence, store| {
490 let (catalog_generation, catalog_digest) =
495 crate::genui_composition::action_catalog_authority(catalog);
496 if catalog_digest != expected_catalog_digest
497 || catalog_generation != expected_catalog_generation
498 {
499 return Err(
500 crate::genui_composition::CompositionError::LiveAdmission(
501 CompositionFailureCode::LiveDurableStateChanged,
502 ),
503 );
504 }
505 if let Some(hook) = publication_authority_hook {
506 let mut tools = publication_tools_cell.borrow_mut();
511 hook(&mut tools, store, catalog);
512 }
513 let _guard = crate::mcp::acquire_publication_guard();
519 let commit_snapshot = live_fence(
520 &staged,
521 store,
522 Some(&final_snapshot),
523 )?;
524 if !commit_snapshot.authority_eq(&final_snapshot)
525 || commit_snapshot.catalog_digest() != staged.digest()
526 {
527 return Err(
528 crate::genui_composition::CompositionError::LiveAdmission(
529 classify_live_snapshot_change(
530 &final_snapshot,
531 &commit_snapshot,
532 ),
533 ),
534 );
535 }
536 if let Some(hook) = publication_hook {
537 hook(catalog);
542 }
543 crate::genui_composition::commit_staged_catalog_with_cas_for_publication(
544 catalog,
545 staged,
546 &commit_snapshot,
547 )
548 },
549 ) {
550 Ok(admitted) => {
551 let surface_digest = admitted
552 .surface()
553 .digest()
554 .map_err(|error| AgentError::Composition(error.to_string()))?;
555 self.store.append(
556 &self.session.id,
557 EventKind::CompositionAccepted,
558 &json!({
559 "context_id": context.context_id(),
560 "surface_digest": surface_digest,
561 }),
562 )?;
563 return Ok(ProductionComposition::Admitted(admitted));
564 }
565 Err(error) => Err(error),
566 }
567 }
568 Err(error) => Err(error),
569 }
570 }
571 Err(error) => Err(error),
572 };
573
574 let error = composed.expect_err("composition result is an error");
575 let provider_id = self.provider.capabilities().provider;
579 let audit = error.audit_metadata(&provider_id, &context_digest);
580 self.store.append(
581 &self.session.id,
582 EventKind::CompositionRejected,
583 &json!({
584 "context_id": context.context_id(),
585 "context_digest": audit.context_digest.clone(),
586 "reason_code": audit.reason_code,
587 "provider": audit.provider.clone(),
588 "http_status": audit.http_status,
589 "response_bytes": audit.response_bytes,
590 }),
591 )?;
592 let safe_reason = audit.reason_code.as_str().to_owned();
593 let fallback = trusted_fallback(context, &safe_reason, host, limits)
594 .map_err(|fallback_error| AgentError::Composition(fallback_error.to_string()))?;
595 if !context.required_data_renderable(&negotiated.capabilities().supported_components) {
596 return Err(AgentError::Composition(
597 "negotiated renderer cannot display required authoritative data".to_owned(),
598 ));
599 }
600 let rendered = crate::genui::render_surface_with_capabilities_and_catalog_with_store(
601 &fallback,
602 negotiated.capabilities(),
603 catalog,
604 self.store,
605 )
606 .map_err(|fallback_error| AgentError::Composition(fallback_error.to_string()))?;
607 self.store.append(
608 &self.session.id,
609 EventKind::CompositionFallbackUsed,
610 &json!({
611 "context_id": context.context_id(),
612 "context_digest": audit.context_digest.clone(),
613 "reason_code": audit.reason_code,
614 "provider": audit.provider.clone(),
615 "http_status": audit.http_status,
616 "response_bytes": audit.response_bytes,
617 }),
618 )?;
619 Ok(ProductionComposition::Fallback {
620 surface: fallback,
621 rendered,
622 reason: safe_reason,
623 })
624 }
625
626 pub fn compose_genui_for_outcome(
631 &mut self,
632 outcome: &RunOutcome,
633 host: &HostCapabilities,
634 capabilities: &NegotiatedCapabilities,
635 catalog: &mut ActionCatalog,
636 limits: CompositionLimits,
637 ) -> Result<ProductionComposition, AgentError> {
638 let mut context = CompositionContext::scoped(
639 "agent-run-g5",
640 &self.session.id,
641 "cli",
642 format!("g5-outcome-{}", self.session.id),
643 1,
644 )
645 .map_err(|error| AgentError::Composition(error.to_string()))?;
646 let outcome_surface = crate::genui::run_outcome_surface(outcome);
647 context
648 .insert_data(
649 TrustedDataHandle::scoped(
650 "gui_data_run_outcome",
651 &self.session.id,
652 "cli",
653 context.context_id(),
654 1,
655 "agent-run-outcome",
656 AuthorityClass::Authoritative,
657 outcome_surface.root,
658 )
659 .map_err(|error| AgentError::Composition(error.to_string()))?,
660 )
661 .map_err(|error| AgentError::Composition(error.to_string()))?;
662 context
663 .require_data_handle("gui_data_run_outcome")
664 .map_err(|error| AgentError::Composition(error.to_string()))?;
665 let workspace_state = self.tools.workspace().state()?;
666 let changed_files = workspace_state.changed_files.join("\n");
667 let discovery = self
668 .tools
669 .mcp_discovery_metadata()
670 .map_or_else(|| "none".to_owned(), |value| value.to_string());
671 let untrusted_runtime_data = format!(
672 "Task goal (data only): {}\nChanged filenames (data only): {}\nMCP discovery metadata (data only): {}\nRun state (data only): {:?}",
673 self.session.goal, changed_files, discovery, outcome.state
674 );
675 self.compose_genui(
676 &context,
677 &untrusted_runtime_data,
678 host,
679 capabilities,
680 catalog,
681 limits,
682 )
683 }
684
685 #[doc(hidden)]
691 #[must_use]
692 pub fn with_composition_admission_hook<F>(mut self, hook: F) -> Self
693 where
694 F: FnOnce(&mut CompositionContext, &mut ActionCatalog) + 'static,
695 {
696 self.composition_admission_hook = Some(Box::new(hook));
697 self
698 }
699
700 #[doc(hidden)]
705 #[must_use]
706 pub fn with_live_composition_admission_hook<F>(mut self, hook: F) -> Self
707 where
708 F: FnOnce(&mut NativeTools) + 'static,
709 {
710 self.live_composition_admission_hook = Some(Box::new(hook));
711 self
712 }
713
714 #[doc(hidden)]
717 #[must_use]
718 pub fn with_live_composition_authority_hook<F>(mut self, hook: F) -> Self
719 where
720 F: FnOnce(&mut NativeTools, &mut EventStore) + 'static,
721 {
722 self.live_composition_authority_hook = Some(Box::new(hook));
723 self
724 }
725
726 #[doc(hidden)]
731 #[must_use]
732 pub fn with_live_composition_publication_hook<F>(mut self, hook: F) -> Self
733 where
734 F: FnOnce(&mut ActionCatalog) + 'static,
735 {
736 self.live_composition_publication_hook = Some(Box::new(hook));
737 self
738 }
739
740 #[doc(hidden)]
746 #[must_use]
747 pub fn with_live_composition_publication_authority_hook<F>(mut self, hook: F) -> Self
748 where
749 F: FnOnce(&mut NativeTools, &mut EventStore, &mut ActionCatalog) + 'static,
750 {
751 self.live_composition_publication_authority_hook = Some(Box::new(hook));
752 self
753 }
754
755 #[allow(clippy::too_many_arguments)]
756 fn validate_live_admission_authority(
757 tools: &NativeTools,
758 composed: &crate::genui_composition::ComposedSurface,
759 context: &CompositionContext,
760 staged: &ActionCatalog,
761 store: &EventStore,
762 expected_workspace_identity: &(String, String),
763 expected_workspace_generation: u64,
764 previous: Option<&LiveAdmissionSnapshot>,
765 ) -> Result<LiveAdmissionSnapshot, crate::genui_composition::CompositionError> {
766 let (live_workspace_identity, live_workspace_generation) = tools
767 .live_workspace_token(context.session_id(), context.principal())
768 .map_err(|_| {
769 crate::genui_composition::CompositionError::LiveAdmission(
770 CompositionFailureCode::LiveWorkspaceChanged,
771 )
772 })?;
773 if &live_workspace_identity != expected_workspace_identity
774 || live_workspace_generation != expected_workspace_generation
775 {
776 return Err(crate::genui_composition::CompositionError::LiveAdmission(
777 CompositionFailureCode::LiveWorkspaceChanged,
778 ));
779 }
780
781 let mut source_entries = Vec::new();
782 let mut durable_states = Vec::new();
783 let live_registry_digest = tools.live_mcp_registry_digest();
784 let live_registry_generation = tools.live_mcp_registry_generation();
785 for expectation in composed.action_authority_expectations() {
786 if context.session_id() != expectation.session_id()
787 || context.principal() != expectation.principal()
788 || context.context_id() != expectation.context_id()
789 || context.generation() != expectation.generation()
790 {
791 return Err(crate::genui_composition::CompositionError::LiveAdmission(
792 CompositionFailureCode::LiveDurableStateChanged,
793 ));
794 }
795 if let Some(binding) = staged.resolve(expectation.action_id())
796 && (binding.session_id() != expectation.session_id()
797 || binding.principal() != expectation.principal()
798 || binding.state_digest() != expectation.state_digest())
799 {
800 return Err(crate::genui_composition::CompositionError::LiveAdmission(
801 CompositionFailureCode::LiveDurableStateChanged,
802 ));
803 }
804 let expected_authorization = format!(
805 "agent-session:{}:principal:{}",
806 expectation.session_id(),
807 expectation.principal()
808 );
809 let Some((generation, state_digest, authorization_context)) = store
810 .genui_current_state(expectation.session_id(), expectation.principal())
811 .map_err(|_| {
812 crate::genui_composition::CompositionError::LiveAdmission(
813 CompositionFailureCode::LiveDurableStateChanged,
814 )
815 })?
816 else {
817 return Err(crate::genui_composition::CompositionError::LiveAdmission(
818 CompositionFailureCode::LiveDurableStateChanged,
819 ));
820 };
821 if generation != expectation.generation()
822 || state_digest != expectation.state_digest()
823 || authorization_context != expected_authorization
824 {
825 return Err(crate::genui_composition::CompositionError::LiveAdmission(
826 CompositionFailureCode::LiveDurableStateChanged,
827 ));
828 }
829 durable_states.push(serde_json::json!({
830 "action_id": expectation.action_id(),
831 "session_id": expectation.session_id(),
832 "principal": expectation.principal(),
833 "generation": generation,
834 "state_digest": state_digest,
835 "authorization_context": authorization_context,
836 }));
837
838 let source = expectation.source();
839 source
840 .validate_for_action(&crate::genui::Action {
841 id: expectation.action_id().to_owned(),
842 label: String::new(),
843 kind: expectation.action_kind(),
844 state_digest: expectation.state_digest().to_owned(),
845 })
846 .map_err(|_| {
847 crate::genui_composition::CompositionError::LiveAdmission(
848 CompositionFailureCode::InvalidActionSource,
849 )
850 })?;
851 let canonical_source_type =
852 expectation
853 .resolve_canonical_source_type(false)
854 .map_err(|_| {
855 crate::genui_composition::CompositionError::LiveAdmission(
856 CompositionFailureCode::SourceUnresolved,
857 )
858 })?;
859 let mcp_backed = matches!(
860 canonical_source_type,
861 crate::genui_composition::ActionSourceType::Mcp
862 );
863 let source_identity = if mcp_backed {
864 let Some(live) = tools.live_mcp_tool_identity(source.tool_name()) else {
865 return Err(crate::genui_composition::CompositionError::LiveAdmission(
866 CompositionFailureCode::LiveRouteMissing,
867 ));
868 };
869 if live.provider_id != source.provider_id()
870 || live.server_id != source.server_id()
871 || live.exposed_tool != source.tool_name()
872 || live.remote_tool_name != source.remote_tool_name()
873 {
874 return Err(crate::genui_composition::CompositionError::LiveAdmission(
875 CompositionFailureCode::LiveSourceChanged,
876 ));
877 }
878 if live.schema_digest != source.schema_digest() {
879 return Err(crate::genui_composition::CompositionError::LiveAdmission(
880 CompositionFailureCode::LiveSourceChanged,
881 ));
882 }
883 if live.policy_version != source.policy_version()
884 || live.policy_digest != source.policy_digest()
885 {
886 return Err(crate::genui_composition::CompositionError::LiveAdmission(
887 CompositionFailureCode::LivePolicyChanged,
888 ));
889 }
890 if live.requires_confirmation != source.requires_confirmation() {
891 return Err(crate::genui_composition::CompositionError::LiveAdmission(
892 CompositionFailureCode::LivePolicyChanged,
893 ));
894 }
895 serde_json::json!({
896 "source_type": canonical_source_type,
897 "exposed_tool": live.exposed_tool,
898 "provider_id": live.provider_id,
899 "server_id": live.server_id,
900 "remote_tool_name": live.remote_tool_name,
901 "schema_digest": live.schema_digest,
902 "policy_version": live.policy_version,
903 "policy_digest": live.policy_digest,
904 "requires_confirmation": live.requires_confirmation,
905 })
906 } else {
907 serde_json::json!({
908 "source_type": canonical_source_type,
909 "provider_id": source.provider_id(),
910 "server_id": source.server_id(),
911 "tool_name": source.tool_name(),
912 "remote_tool_name": source.remote_tool_name(),
913 "schema_digest": source.schema_digest(),
914 "policy_version": source.policy_version(),
915 "policy_digest": source.policy_digest(),
916 "requires_confirmation": source.requires_confirmation(),
917 })
918 };
919 source_entries.push(serde_json::json!({
920 "action_id": expectation.action_id(),
921 "action_kind": expectation.action_kind(),
922 "identity": source_identity,
923 }));
924 }
925 let durable_state_digest = crate::genui::mcp_schema_digest(&serde_json::json!({
926 "session_id": context.session_id(),
927 "principal": context.principal(),
928 "states": durable_states,
929 "event_store_generation": store
930 .authority_generation()
931 .map_err(|_| crate::genui_composition::CompositionError::LiveAdmission(
932 CompositionFailureCode::LiveDurableStateChanged,
933 ))?,
934 }));
935 let event_store_generation = store.authority_generation().map_err(|_| {
936 crate::genui_composition::CompositionError::LiveAdmission(
937 CompositionFailureCode::LiveDurableStateChanged,
938 )
939 })?;
940 let (catalog_generation, _catalog_digest) =
941 crate::genui_composition::action_catalog_authority(staged);
942 let catalog_base_generation =
943 crate::genui_composition::action_catalog_base_generation(staged);
944 let snapshot = LiveAdmissionSnapshot::new_with_all_authority_tokens(
945 live_workspace_identity,
946 live_workspace_generation,
947 crate::genui::mcp_schema_digest(&serde_json::json!({
948 "registry_digest": live_registry_digest,
949 "registry_generation": live_registry_generation,
950 "actions": source_entries,
951 })),
952 live_registry_generation,
953 event_store_generation,
954 durable_state_digest,
955 context.session_id().to_owned(),
956 context.principal().to_owned(),
957 context.context_id().to_owned(),
958 context.generation(),
959 staged.digest(),
960 catalog_generation,
961 catalog_base_generation,
962 );
963 if let Some(previous) = previous
964 && !previous.authority_eq(&snapshot)
965 {
966 return Err(crate::genui_composition::CompositionError::LiveAdmission(
967 classify_live_snapshot_change(previous, &snapshot),
968 ));
969 }
970 Ok(snapshot)
971 }
972
973 pub fn present_genui_action(
974 &mut self,
975 catalog: &ActionCatalog,
976 action_id: &str,
977 ) -> Result<(), AgentError> {
978 catalog
979 .present_action(self.store, &self.session.id, action_id)
980 .map_err(|error| AgentError::Tool(ToolError::GenUi(error.to_string())))
981 }
982
983 pub fn request_genui_confirmation(
984 &mut self,
985 catalog: &ActionCatalog,
986 action_id: &str,
987 payload: &Value,
988 ) -> Result<HostConfirmation, AgentError> {
989 catalog
990 .request_confirmation(self.store, &self.session.id, action_id, payload)
991 .map_err(|error| AgentError::Tool(ToolError::GenUi(error.to_string())))
992 }
993
994 pub fn confirm_genui_action(
995 &mut self,
996 catalog: &ActionCatalog,
997 confirmation: &HostConfirmation,
998 ) -> Result<(), AgentError> {
999 let host_local = confirmation
1000 .identity()
1001 .get("action_id")
1002 .and_then(Value::as_str)
1003 .and_then(|action_id| catalog.resolve(action_id))
1004 .is_some_and(|binding| binding.source_type() == ActionSourceType::HostLocal);
1005 if host_local {
1006 return catalog
1007 .append_confirmed_after_live_validation(self.store, confirmation)
1008 .map_err(|error| AgentError::Tool(ToolError::GenUi(error.to_string())));
1009 }
1010 self.tools
1011 .confirm_genui_action(self.store, catalog, confirmation)
1012 .map_err(|error| AgentError::Tool(ToolError::GenUi(error.to_string())))
1013 }
1014
1015 pub fn advance_genui_current_state(
1019 &mut self,
1020 principal: &str,
1021 catalog: &mut ActionCatalog,
1022 ) -> Result<(u64, String, String), AgentError> {
1023 if principal.is_empty() {
1024 return Err(AgentError::Tool(ToolError::GenUi(
1025 "principal must not be empty".to_owned(),
1026 )));
1027 }
1028 let workspace_state = self.tools.workspace().state()?;
1029 let (digest, authorization_context) =
1030 derive_host_workspace_state_identity(&self.session.id, principal, &workspace_state);
1031 let generation = self
1032 .store
1033 .advance_genui_current_state(
1034 &self.session.id,
1035 principal,
1036 &digest,
1037 &authorization_context,
1038 )
1039 .map_err(AgentError::Event)?;
1040 catalog
1041 .set_current_state(
1042 &self.session.id,
1043 principal,
1044 generation,
1045 &digest,
1046 &authorization_context,
1047 )
1048 .map_err(|error| AgentError::Tool(ToolError::GenUi(error.to_string())))?;
1049 Ok((generation, digest, authorization_context))
1050 }
1051
1052 #[must_use]
1053 pub fn with_temperature(mut self, temperature: f32) -> Self {
1054 self.temperature = temperature;
1055 self
1056 }
1057
1058 #[must_use]
1061 pub const fn with_pause_after_model_turns(mut self, turns: Option<u32>) -> Self {
1062 self.pause_after_model_turns = turns;
1063 self
1064 }
1065
1066 pub fn run(&mut self) -> Result<RunOutcome, AgentError> {
1067 if let Some(path) = self.store.path()
1068 && path.starts_with(self.tools.workspace().root())
1069 {
1070 return Err(AgentError::EventStoreInsideWorkspace(
1071 path.display().to_string(),
1072 ));
1073 }
1074 let replacement = self
1075 .session
1076 .validate_replacement_resume(self.store, self.tools.workspace())?;
1077 if self.session.state.is_terminal() {
1078 return self.outcome();
1079 }
1080 if let Some(replacement) = &replacement {
1081 self.record_replacement_resume_validation(replacement)?;
1082 self.verify_preserved_candidate(&replacement.candidate_sha256)?;
1083 return self.outcome();
1084 }
1085 self.validate_resume_fingerprint()?;
1086 if self.session.state == SessionState::Paused {
1087 self.session.transition(
1088 self.store,
1089 SessionState::Working,
1090 "resumed_from_safe_turn_boundary",
1091 )?;
1092 }
1093 if replacement.is_none() {
1094 self.record_initial_checkpoint()?;
1095 }
1096 loop {
1097 if self
1098 .pause_after_model_turns
1099 .is_some_and(|turns| self.session.model_turns >= turns)
1100 {
1101 self.session.transition(
1102 self.store,
1103 SessionState::Paused,
1104 "requested_safe_turn_boundary",
1105 )?;
1106 return self.outcome();
1107 }
1108 if let Some(reason) = self.exhausted_reason()? {
1109 self.session
1110 .transition(self.store, SessionState::BudgetExhausted, reason)?;
1111 return self.outcome();
1112 }
1113
1114 let context =
1115 self.context_builder
1116 .build(&self.session, self.store, self.tools.workspace())?;
1117 let messages = conversation_messages(
1118 &self.store.events(&self.session.id)?,
1119 self.context_builder.remaining_input_bytes(&context),
1120 );
1121 let history_bytes = serde_json::to_string(&messages)
1122 .expect("conversation messages are serializable")
1123 .len();
1124 let capabilities = self.provider.capabilities();
1125 self.store.append(
1126 &self.session.id,
1127 EventKind::ModelRequest,
1128 &json!({
1129 "turn": self.session.model_turns + 1,
1130 "provider": capabilities,
1131 "approximate_input_tokens": (context.len() + history_bytes).div_ceil(4),
1132 "max_output_tokens": self.context_builder.max_output_tokens()
1133 }),
1134 )?;
1135 self.session.model_turns += 1;
1136 let request = InferenceRequest {
1137 context,
1138 messages,
1139 max_output_tokens: self.context_builder.max_output_tokens(),
1140 temperature: self.temperature,
1141 };
1142 let response = match self.provider.complete(&request) {
1143 Ok(response) => response,
1144 Err(InferenceError::Malformed(message)) => {
1145 self.store.append(
1146 &self.session.id,
1147 EventKind::ModelResponse,
1148 &json!({
1149 "protocol_error": InferenceError::Malformed(message).audit_metadata(),
1150 "retryable": true,
1151 "instruction": "return valid structured output on the next turn"
1152 }),
1153 )?;
1154 self.record_turn_boundary_checkpoint()?;
1155 continue;
1156 }
1157 Err(error) => {
1158 self.record_inference_failure(&error)?;
1159 self.session.transition(
1160 self.store,
1161 SessionState::Failed,
1162 "inference_failure",
1163 )?;
1164 return self.outcome();
1165 }
1166 };
1167 self.store.append(
1168 &self.session.id,
1169 EventKind::ModelResponse,
1170 &serde_json::to_value(&response).expect("model response is serializable"),
1171 )?;
1172 let action = match parse_action(&response) {
1173 Ok(action) => action,
1174 Err(error) => {
1175 self.store.append(
1176 &self.session.id,
1177 EventKind::ModelResponse,
1178 &json!({"protocol_error": error.to_string(), "retryable": true}),
1179 )?;
1180 self.record_turn_boundary_checkpoint()?;
1181 continue;
1182 }
1183 };
1184 match action {
1185 ModelAction::Tool {
1186 tool_call_id,
1187 tool,
1188 arguments,
1189 } => {
1190 if self.session.tool_calls >= self.limits.max_tool_calls {
1191 self.session.transition(
1192 self.store,
1193 SessionState::BudgetExhausted,
1194 "max_tool_calls",
1195 )?;
1196 return self.outcome();
1197 }
1198 self.execute_tool(ToolCall {
1199 id: tool_call_id,
1200 name: tool,
1201 arguments,
1202 })?;
1203 self.record_turn_boundary_checkpoint()?;
1204 }
1205 ModelAction::ToolBatch { calls } => {
1206 let requested = u32::try_from(calls.len()).unwrap_or(u32::MAX);
1207 if self.session.tool_calls.saturating_add(requested)
1208 > self.limits.max_tool_calls
1209 {
1210 self.session.transition(
1211 self.store,
1212 SessionState::BudgetExhausted,
1213 "max_tool_calls",
1214 )?;
1215 return self.outcome();
1216 }
1217 for call in calls {
1218 self.execute_tool(call)?;
1219 }
1220 self.record_turn_boundary_checkpoint()?;
1221 }
1222 ModelAction::CandidateReady { summary } => {
1223 if self.verify_candidate(&summary)? {
1224 return self.outcome();
1225 }
1226 self.record_turn_boundary_checkpoint()?;
1227 }
1228 }
1229 }
1230 }
1231
1232 fn execute_tool(&mut self, call: ToolCall) -> Result<(), AgentError> {
1233 self.store.append(
1234 &self.session.id,
1235 EventKind::ToolRequest,
1236 &serde_json::to_value(&call).expect("tool call is serializable"),
1237 )?;
1238 self.session.tool_calls += 1;
1239 self.session
1240 .transition(self.store, SessionState::WaitingForTool, "tool_requested")?;
1241 let before_candidate = if self.tools.may_mutate_workspace(&call) {
1242 let workspace_state = self.tools.workspace().state()?;
1243 let candidate_sha256 = self.tools.workspace().candidate_sha256()?;
1244 self.store.append(
1245 &self.session.id,
1246 EventKind::Checkpoint,
1247 &json!({
1248 "checkpoint_kind": "before_source_mutation",
1249 "head": workspace_state.head,
1250 "dirty": workspace_state.dirty,
1251 "changed_files": workspace_state.changed_files,
1252 "diff": self.tools.workspace().diff()?,
1253 "candidate_sha256": candidate_sha256
1254 }),
1255 )?;
1256 Some(candidate_sha256)
1257 } else {
1258 None
1259 };
1260 let mut result = match self.tools.execute(&call) {
1261 Ok(result) => result,
1262 Err(error) => ToolResult::rejected(&call.name, &error.to_string()),
1263 };
1264 result.tool_call_id.clone_from(&call.id);
1265 self.store.append(
1266 &self.session.id,
1267 EventKind::ToolResult,
1268 &serde_json::to_value(&result).expect("tool result is serializable"),
1269 )?;
1270 if matches!(call.name.as_str(), "shell" | "search" | "git") {
1271 self.store.append(
1272 &self.session.id,
1273 EventKind::CommandExecution,
1274 &json!({"request": call, "result": result}),
1275 )?;
1276 }
1277 if result.ok
1278 && let Some(before_sha256) = before_candidate
1279 {
1280 let after_sha256 = self.tools.workspace().candidate_sha256()?;
1281 if before_sha256 != after_sha256 {
1282 self.store.append(
1283 &self.session.id,
1284 EventKind::FileMutation,
1285 &json!({
1286 "tool": call.name,
1287 "before_candidate_sha256": before_sha256,
1288 "candidate_sha256": after_sha256
1289 }),
1290 )?;
1291 }
1292 }
1293 self.session
1294 .transition(self.store, SessionState::Working, "tool_completed")?;
1295 Ok(())
1296 }
1297
1298 fn verify_candidate(&mut self, summary: &str) -> Result<bool, AgentError> {
1299 self.session.transition(
1300 self.store,
1301 SessionState::CandidateReady,
1302 "model_declared_candidate_ready",
1303 )?;
1304 let workspace_state = self.tools.workspace().state()?;
1305 let candidate_sha256 = self.tools.workspace().candidate_sha256()?;
1306 self.store.append(
1307 &self.session.id,
1308 EventKind::CandidateReady,
1309 &json!({"summary": summary, "candidate_sha256": candidate_sha256}),
1310 )?;
1311 self.store.append(
1312 &self.session.id,
1313 EventKind::GitState,
1314 &serde_json::to_value(workspace_state).expect("workspace state is serializable"),
1315 )?;
1316 self.session.transition(
1317 self.store,
1318 SessionState::Verifying,
1319 "independent_verification_started",
1320 )?;
1321 self.evaluate_verification(&candidate_sha256, VerificationMode::Ordinary)
1322 }
1323
1324 fn verify_preserved_candidate(&mut self, candidate_sha256: &str) -> Result<(), AgentError> {
1325 self.session.transition(
1326 self.store,
1327 SessionState::Verifying,
1328 "replacement_candidate_verification_started",
1329 )?;
1330 if !self.evaluate_verification(candidate_sha256, VerificationMode::ReplacementOnly)? {
1331 return Err(AgentError::InvalidReplacementState(self.session.state));
1332 }
1333 Ok(())
1334 }
1335
1336 fn evaluate_verification(
1337 &mut self,
1338 candidate_sha256: &str,
1339 mode: VerificationMode,
1340 ) -> Result<bool, AgentError> {
1341 let result = match self
1342 .verifier
1343 .verify(self.tools.workspace(), candidate_sha256)
1344 {
1345 Ok(result) => result,
1346 Err(error) => {
1347 self.record_falsegreen_failure(&error)?;
1348 self.session.transition(
1349 self.store,
1350 SessionState::Failed,
1351 "falsegreen_infrastructure_failure",
1352 )?;
1353 return Ok(true);
1354 }
1355 };
1356 self.store.append(
1357 &self.session.id,
1358 EventKind::FalsegreenResult,
1359 &serde_json::to_value(&result).expect("FalseGreen result is serializable"),
1360 )?;
1361 let repairable = result.repairable;
1362 let permits_completion = result.permits_completion();
1363 let accepted = result.verification == crate::falsegreen::FalseGreenVerdict::Accepted;
1364 self.latest_falsegreen = Some(result);
1365 if accepted {
1366 if permits_completion {
1367 self.session.transition(
1368 self.store,
1369 SessionState::Completed,
1370 "falsegreen_accepted_with_completion_authority",
1371 )?;
1372 } else {
1373 self.session.transition(
1374 self.store,
1375 SessionState::AcceptedAwaitingAuthority,
1376 "falsegreen_accepted_awaiting_completion_authority",
1377 )?;
1378 }
1379 return Ok(true);
1380 }
1381 if mode == VerificationMode::Ordinary
1382 && repairable
1383 && self.session.repair_cycles < self.limits.max_repair_cycles
1384 {
1385 let canonical_repair = match self.verifier.prepare_repair(self.tools.workspace()) {
1386 Ok(repair) => repair,
1387 Err(error) => {
1388 self.record_falsegreen_failure(&error)?;
1389 self.session.transition(
1390 self.store,
1391 SessionState::Failed,
1392 "falsegreen_repair_handoff_failure",
1393 )?;
1394 return Ok(true);
1395 }
1396 };
1397 self.session.transition(
1398 self.store,
1399 SessionState::Repairing,
1400 "falsegreen_rejected_candidate",
1401 )?;
1402 self.store.append(
1403 &self.session.id,
1404 EventKind::RepairStarted,
1405 &json!({
1406 "repair_cycle": self.session.repair_cycles + 1,
1407 "repair_packet": self.latest_falsegreen.as_ref().map(repair_packet),
1408 "canonical_repair": canonical_repair
1409 }),
1410 )?;
1411 self.session.repair_cycles += 1;
1412 self.session.transition(
1413 self.store,
1414 SessionState::Working,
1415 "repair_evidence_returned_to_worker",
1416 )?;
1417 return Ok(false);
1418 }
1419 let reason = match mode {
1420 VerificationMode::Ordinary => "falsegreen_not_accepted_and_repair_unavailable",
1421 VerificationMode::ReplacementOnly => "falsegreen_replacement_not_accepted",
1422 };
1423 self.session
1424 .transition(self.store, SessionState::Failed, reason)?;
1425 Ok(true)
1426 }
1427
1428 fn record_initial_checkpoint(&mut self) -> Result<(), AgentError> {
1429 if self.session.model_turns > 0 || self.session.tool_calls > 0 {
1430 return Ok(());
1431 }
1432 let workspace = self.tools.workspace();
1433 let state = workspace.state()?;
1434 self.store.append(
1435 &self.session.id,
1436 EventKind::Checkpoint,
1437 &json!({
1438 "checkpoint_kind": "initial_workspace",
1439 "head": state.head,
1440 "tree": state.tree,
1441 "initial_dirty": state.dirty,
1442 "changed_files": state.changed_files,
1443 "current_diff": workspace.diff()?,
1444 "candidate_sha256": workspace.candidate_sha256()?
1445 }),
1446 )?;
1447 Ok(())
1448 }
1449
1450 fn record_turn_boundary_checkpoint(&mut self) -> Result<(), AgentError> {
1451 let workspace = self.tools.workspace();
1452 let state = workspace.state()?;
1453 self.store.append(
1454 &self.session.id,
1455 EventKind::Checkpoint,
1456 &json!({
1457 "checkpoint_kind": "turn_boundary",
1458 "model_turns": self.session.model_turns,
1459 "tool_calls": self.session.tool_calls,
1460 "head": state.head,
1461 "tree": state.tree,
1462 "dirty": state.dirty,
1463 "changed_files": state.changed_files,
1464 "candidate_sha256": workspace.candidate_sha256()?
1465 }),
1466 )?;
1467 Ok(())
1468 }
1469
1470 fn record_replacement_resume_validation(
1471 &mut self,
1472 replacement: &crate::event::SessionReplacementRecord,
1473 ) -> Result<(), AgentError> {
1474 let actual = self.tools.workspace().candidate_sha256()?;
1475 if replacement.candidate_sha256 != actual {
1476 return Err(AgentError::ResumeFingerprintMismatch {
1477 expected: replacement.candidate_sha256.clone(),
1478 actual,
1479 });
1480 }
1481 self.store.append(
1482 &self.session.id,
1483 EventKind::Checkpoint,
1484 &json!({
1485 "checkpoint_kind": "replacement_resume_validation",
1486 "predecessor_session_id": replacement.predecessor_session_id,
1487 "expected_candidate_sha256": replacement.candidate_sha256,
1488 "actual_candidate_sha256": actual,
1489 "matched": true
1490 }),
1491 )?;
1492 Ok(())
1493 }
1494
1495 fn validate_resume_fingerprint(&mut self) -> Result<(), AgentError> {
1496 if self.session.model_turns == 0 {
1497 return Ok(());
1498 }
1499 let events = self.store.events(&self.session.id)?;
1500 let expected = events.iter().rev().find_map(|event| {
1501 (event.kind == EventKind::Checkpoint
1502 && event.payload["checkpoint_kind"] == "turn_boundary")
1503 .then(|| {
1504 event.payload["candidate_sha256"]
1505 .as_str()
1506 .map(str::to_owned)
1507 })
1508 .flatten()
1509 });
1510 let actual = self.tools.workspace().candidate_sha256()?;
1511 if let Some(expected) = &expected
1512 && expected != &actual
1513 {
1514 return Err(AgentError::ResumeFingerprintMismatch {
1515 expected: expected.clone(),
1516 actual,
1517 });
1518 }
1519 self.store.append(
1520 &self.session.id,
1521 EventKind::Checkpoint,
1522 &json!({
1523 "checkpoint_kind": "resume_validation",
1524 "turn_boundary_available": expected.is_some(),
1525 "expected_candidate_sha256": expected,
1526 "actual_candidate_sha256": actual,
1527 "matched": true
1528 }),
1529 )?;
1530 Ok(())
1531 }
1532
1533 fn record_inference_failure(&mut self, error: &InferenceError) -> Result<(), EventError> {
1534 self.store.append(
1535 &self.session.id,
1536 EventKind::ModelResponse,
1537 &error.audit_metadata(),
1538 )?;
1539 Ok(())
1540 }
1541
1542 fn record_falsegreen_failure(&mut self, error: &FalseGreenError) -> Result<(), EventError> {
1543 self.store.append(
1544 &self.session.id,
1545 EventKind::FalsegreenResult,
1546 &json!({"verdict": "insufficient_evidence", "error": error.to_string()}),
1547 )?;
1548 Ok(())
1549 }
1550
1551 fn exhausted_reason(&self) -> Result<Option<&'static str>, AgentError> {
1552 if self.session.model_turns >= self.limits.max_model_turns {
1553 return Ok(Some("max_model_turns"));
1554 }
1555 if self.session.tool_calls >= self.limits.max_tool_calls {
1556 return Ok(Some("max_tool_calls"));
1557 }
1558 let now_ms = SystemTime::now()
1559 .duration_since(UNIX_EPOCH)
1560 .map_err(|_| AgentError::InvalidClock)?
1561 .as_millis();
1562 let started = u128::from(self.session.started_at_ms);
1563 let elapsed_ms = now_ms
1564 .checked_sub(started)
1565 .ok_or(AgentError::InvalidClock)?;
1566 if elapsed_ms >= self.limits.max_wall_time.as_millis() {
1567 return Ok(Some("max_wall_time"));
1568 }
1569 Ok(None)
1570 }
1571
1572 fn outcome(&self) -> Result<RunOutcome, AgentError> {
1573 let events = self.store.events(&self.session.id)?;
1574 let now_ms = current_time_ms()?;
1575 let persisted_falsegreen = events.iter().rev().find_map(|event| {
1576 (event.kind == EventKind::FalsegreenResult)
1577 .then(|| serde_json::from_value(event.payload.clone()).ok())
1578 .flatten()
1579 });
1580 Ok(RunOutcome {
1581 session_id: self.session.id.clone(),
1582 state: self.session.state,
1583 model_turns: self.session.model_turns,
1584 tool_calls: self.session.tool_calls,
1585 repair_cycles: self.session.repair_cycles,
1586 falsegreen_result: self.latest_falsegreen.clone().or(persisted_falsegreen),
1587 metrics: derive_metrics(&events, now_ms.saturating_sub(self.session.started_at_ms)),
1588 })
1589 }
1590}
1591
1592fn classify_live_snapshot_change(
1593 previous: &LiveAdmissionSnapshot,
1594 current: &LiveAdmissionSnapshot,
1595) -> CompositionFailureCode {
1596 if previous.workspace_identity() != current.workspace_identity() {
1597 CompositionFailureCode::LiveWorkspaceChanged
1598 } else if previous.source_digest() != current.source_digest() {
1599 CompositionFailureCode::LiveSourceChanged
1600 } else {
1601 CompositionFailureCode::LiveDurableStateChanged
1602 }
1603}
1604
1605fn derive_metrics(events: &[crate::event::Event], total_wall_time_ms: u64) -> RunMetrics {
1606 let mut metrics = RunMetrics {
1607 approximate_input_tokens: 0,
1608 reported_input_tokens: None,
1609 reported_output_tokens: None,
1610 peak_context_tokens: 0,
1611 failed_tool_calls: 0,
1612 invalid_tool_calls: 0,
1613 shell_commands: 0,
1614 test_executions: 0,
1615 candidate_count: 0,
1616 falsegreen_attempts: 0,
1617 patch_attempts: 0,
1618 rejected_patch_attempts: 0,
1619 total_wall_time_ms,
1620 };
1621 for event in events {
1622 match event.kind {
1623 EventKind::ModelRequest => {
1624 let tokens = event.payload["approximate_input_tokens"]
1625 .as_u64()
1626 .unwrap_or(0);
1627 metrics.approximate_input_tokens += tokens;
1628 metrics.peak_context_tokens = metrics.peak_context_tokens.max(tokens);
1629 }
1630 EventKind::ModelResponse => {
1631 if event.payload.get("protocol_error").is_some() {
1632 metrics.invalid_tool_calls += 1;
1633 }
1634 if let Some(usage) = event.payload.get("usage") {
1635 add_optional(
1636 &mut metrics.reported_input_tokens,
1637 usage
1638 .get("prompt_tokens")
1639 .or_else(|| usage.get("input_tokens"))
1640 .and_then(Value::as_u64),
1641 );
1642 add_optional(
1643 &mut metrics.reported_output_tokens,
1644 usage
1645 .get("completion_tokens")
1646 .or_else(|| usage.get("output_tokens"))
1647 .and_then(Value::as_u64),
1648 );
1649 }
1650 }
1651 EventKind::ToolRequest => {
1652 if event.payload.get("name").and_then(Value::as_str) == Some("apply_patch") {
1653 metrics.patch_attempts += 1;
1654 }
1655 if event.payload.get("name").and_then(Value::as_str) == Some("shell") {
1656 metrics.shell_commands += 1;
1657 if is_test_command(&event.payload["arguments"]["argv"]) {
1658 metrics.test_executions += 1;
1659 }
1660 }
1661 }
1662 EventKind::ToolResult => {
1663 if event.payload.get("tool").and_then(Value::as_str) == Some("apply_patch")
1664 && event.payload.get("ok").and_then(Value::as_bool) == Some(false)
1665 {
1666 metrics.rejected_patch_attempts += 1;
1667 }
1668 if event.payload.get("ok").and_then(Value::as_bool) == Some(false) {
1669 metrics.failed_tool_calls += 1;
1670 }
1671 if event
1672 .payload
1673 .pointer("/metadata/validation_error")
1674 .is_some()
1675 {
1676 metrics.invalid_tool_calls += 1;
1677 }
1678 }
1679 EventKind::CandidateReady => metrics.candidate_count += 1,
1680 EventKind::FalsegreenResult => metrics.falsegreen_attempts += 1,
1681 _ => {}
1682 }
1683 }
1684 metrics
1685}
1686
1687fn add_optional(total: &mut Option<u64>, value: Option<u64>) {
1688 if let Some(value) = value {
1689 *total = Some(total.unwrap_or(0) + value);
1690 }
1691}
1692
1693fn is_test_command(argv: &Value) -> bool {
1694 let Some(arguments) = argv.as_array() else {
1695 return false;
1696 };
1697 let words: Vec<&str> = arguments.iter().filter_map(Value::as_str).collect();
1698 matches!(
1699 words.as_slice(),
1700 ["pytest" | "cargo-nextest" | "ctest" | "rspec", ..]
1701 | ["cargo" | "go" | "npm" | "pnpm" | "yarn", "test", ..]
1702 ) || matches!(
1703 words.as_slice(),
1704 ["python" | "python3", "-m", "unittest" | "pytest", ..]
1705 ) || matches!(words.as_slice(), ["python" | "python3", script, ..] if script.contains("test"))
1706}
1707
1708fn current_time_ms() -> Result<u64, AgentError> {
1709 let millis = SystemTime::now()
1710 .duration_since(UNIX_EPOCH)
1711 .map_err(|_| AgentError::InvalidClock)?
1712 .as_millis();
1713 u64::try_from(millis).map_err(|_| AgentError::InvalidClock)
1714}
1715
1716fn conversation_messages(events: &[crate::event::Event], maximum_bytes: usize) -> Vec<Value> {
1717 let mut conversational_events: Vec<&crate::event::Event> = events
1718 .iter()
1719 .rev()
1720 .filter(|event| matches!(event.kind, EventKind::ModelResponse | EventKind::ToolResult))
1721 .take(48)
1722 .collect();
1723 conversational_events.reverse();
1724 let mut messages = Vec::new();
1725 let mut native_call_ids = std::collections::BTreeSet::new();
1726 for event in conversational_events {
1727 match event.kind {
1728 EventKind::ModelResponse => {
1729 let Ok(response) = serde_json::from_value::<crate::inference::ModelResponse>(
1730 event.payload.clone(),
1731 ) else {
1732 continue;
1733 };
1734 if response.tool_calls.is_empty() {
1735 if let Some(content) = response.content
1736 && !content.is_empty()
1737 {
1738 messages.push(json!({"role": "assistant", "content": content}));
1739 }
1740 continue;
1741 }
1742 if response.tool_calls.len() == 1
1743 && response.tool_calls[0].name == "candidate_ready"
1744 {
1745 messages.push(json!({
1746 "role": "assistant",
1747 "content": format!(
1748 "CANDIDATE_READY: {}",
1749 response.tool_calls[0].arguments["summary"]
1750 .as_str()
1751 .unwrap_or("submitted for independent verification")
1752 )
1753 }));
1754 continue;
1755 }
1756 let tool_calls: Vec<Value> = response
1757 .tool_calls
1758 .iter()
1759 .filter_map(|call| {
1760 let id = call.id.as_ref()?;
1761 native_call_ids.insert(id.clone());
1762 Some(json!({
1763 "id": id,
1764 "type": "function",
1765 "function": {
1766 "name": call.name,
1767 "arguments": serde_json::to_string(&call.arguments)
1768 .expect("tool arguments are serializable")
1769 }
1770 }))
1771 })
1772 .collect();
1773 if !tool_calls.is_empty() {
1774 messages.push(json!({
1775 "role": "assistant",
1776 "content": response.content,
1777 "tool_calls": tool_calls
1778 }));
1779 }
1780 }
1781 EventKind::ToolResult => {
1782 let call_id = event.payload.get("tool_call_id").and_then(Value::as_str);
1783 let content = truncate_utf8(
1784 &serde_json::to_string(&event.payload)
1785 .expect("persisted tool result is serializable"),
1786 16 * 1024,
1787 );
1788 if let Some(call_id) = call_id.filter(|id| native_call_ids.contains(*id)) {
1789 messages.push(json!({
1790 "role": "tool",
1791 "tool_call_id": call_id,
1792 "content": content
1793 }));
1794 } else {
1795 messages.push(json!({
1796 "role": "user",
1797 "content": format!("TOOL_RESULT: {content}")
1798 }));
1799 }
1800 }
1801 _ => {}
1802 }
1803 }
1804 while serde_json::to_vec(&messages)
1805 .expect("conversation messages are serializable")
1806 .len()
1807 > maximum_bytes
1808 && !messages.is_empty()
1809 {
1810 messages.remove(0);
1811 }
1812 while messages
1813 .first()
1814 .and_then(|message| message.get("role"))
1815 .and_then(Value::as_str)
1816 == Some("tool")
1817 {
1818 messages.remove(0);
1819 }
1820 messages
1821}
1822
1823fn truncate_utf8(value: &str, maximum_bytes: usize) -> String {
1824 if value.len() <= maximum_bytes {
1825 return value.to_owned();
1826 }
1827 let marker = "\n...[tool result truncated]";
1828 let content_limit = maximum_bytes.saturating_sub(marker.len());
1829 let boundary = value
1830 .char_indices()
1831 .take_while(|(index, _)| *index <= content_limit)
1832 .map(|(index, _)| index)
1833 .last()
1834 .unwrap_or(0);
1835 format!("{}{}", &value[..boundary], marker)
1836}
1837
1838fn repair_packet(result: &FalseGreenResult) -> Value {
1839 let verification = result
1840 .evidence
1841 .get("verification")
1842 .unwrap_or(&result.evidence);
1843 json!({
1844 "candidate_sha256": result.candidate_sha256,
1845 "authoritative_source_sha256": result.authoritative_source_sha256,
1846 "verification_status": result.verification_status,
1847 "failed_release_requirements": verification.get("failed_release_requirements"),
1848 "failed_regressions": verification.get("failed_regressions"),
1849 "invalid_commands": verification.get("invalid_commands"),
1850 "command_results": verification.get("command_results"),
1851 "canonical_evidence": result.evidence
1852 })
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857 use std::collections::VecDeque;
1858 use std::time::Duration;
1859
1860 use serde_json::json;
1861
1862 use crate::context::{ContextBudget, ContextBuilder};
1863 use crate::event::{EventKind, EventStore};
1864 use crate::falsegreen::{FalseGreenError, FalseGreenResult, FalseGreenVerifier};
1865 use crate::inference::{
1866 InferenceError, InferenceProvider, InferenceRequest, ModelAction, ModelCapabilities,
1867 ModelResponse, ProviderCapabilities,
1868 };
1869 use crate::session::{Session, SessionState};
1870 use crate::tools::{NativeTools, ToolLimits};
1871 use crate::workspace::Workspace;
1872 use crate::workspace::tests::git_fixture;
1873
1874 use super::{Agent, AgentLimits};
1875
1876 struct FakeModel {
1877 actions: VecDeque<ModelAction>,
1878 }
1879
1880 impl InferenceProvider for FakeModel {
1881 fn capabilities(&self) -> ProviderCapabilities {
1882 ProviderCapabilities {
1883 provider: "deterministic-fake".to_owned(),
1884 wire_protocol: "in-process".to_owned(),
1885 model: ModelCapabilities {
1886 identifier: "deterministic-fake".to_owned(),
1887 repository: None,
1888 artifact: None,
1889 artifact_sha256: None,
1890 quantization: None,
1891 chat_template: None,
1892 context_window_tokens: None,
1893 native_tools: false,
1894 qualification: None,
1895 },
1896 streaming: false,
1897 runtime_provenance: None,
1898 }
1899 }
1900
1901 fn complete(
1902 &mut self,
1903 _request: &InferenceRequest,
1904 ) -> Result<ModelResponse, InferenceError> {
1905 let action = self.actions.pop_front().expect("fake action available");
1906 Ok(ModelResponse {
1907 content: Some(serde_json::to_string(&action).expect("serialize action")),
1908 tool_calls: Vec::new(),
1909 usage: None,
1910 })
1911 }
1912 }
1913
1914 struct FakeVerifier {
1915 results: VecDeque<FalseGreenResult>,
1916 }
1917
1918 impl FalseGreenVerifier for FakeVerifier {
1919 fn verify(
1920 &mut self,
1921 _workspace: &Workspace,
1922 candidate_sha256: &str,
1923 ) -> Result<FalseGreenResult, FalseGreenError> {
1924 let mut result = self.results.pop_front().expect("fake result available");
1925 result.candidate_sha256 = candidate_sha256.to_owned();
1926 Ok(result)
1927 }
1928 }
1929
1930 fn action(tool: &str, arguments: serde_json::Value) -> ModelAction {
1931 ModelAction::Tool {
1932 tool_call_id: None,
1933 tool: tool.to_owned(),
1934 arguments,
1935 }
1936 }
1937
1938 #[test]
1939 fn deterministic_lifecycle_repairs_until_falsegreen_accepts() {
1940 let directory = git_fixture();
1941 let workspace = Workspace::open(directory.path()).expect("workspace");
1942 let tools = NativeTools::new(workspace, ToolLimits::default());
1943 let patch_one = "diff --git a/hello.txt b/hello.txt\n--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-old\n+new\n";
1944 let patch_two = "diff --git a/hello.txt b/hello.txt\n--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-new\n+fixed\n";
1945 let model = FakeModel {
1946 actions: VecDeque::from(vec![
1947 action("read_file", json!({"path": "hello.txt"})),
1948 action("apply_patch", json!({"patch": patch_one})),
1949 action(
1950 "shell",
1951 json!({"argv": ["sh", "-c", "test \"$(cat hello.txt)\" = new"]}),
1952 ),
1953 ModelAction::CandidateReady {
1954 summary: "first candidate".to_owned(),
1955 },
1956 action("apply_patch", json!({"patch": patch_two})),
1957 action(
1958 "shell",
1959 json!({"argv": ["sh", "-c", "test \"$(cat hello.txt)\" = fixed"]}),
1960 ),
1961 ModelAction::CandidateReady {
1962 summary: "repaired candidate".to_owned(),
1963 },
1964 ]),
1965 };
1966 let verifier = FakeVerifier {
1967 results: VecDeque::from(vec![
1968 FalseGreenResult::failed(
1969 "ignored",
1970 json!({
1971 "failed_release_requirements": ["AC-1"],
1972 "command_results": [{"criterion_id": "AC-1", "passed": false}]
1973 }),
1974 ),
1975 FalseGreenResult::accepted("ignored"),
1976 ]),
1977 };
1978 let mut store = EventStore::open_memory().expect("store");
1979 let session = Session::create(&mut store, "make hello fixed").expect("session");
1980 let session_id = session.id.clone();
1981 let mut agent = Agent::new(
1982 &mut store,
1983 session,
1984 model,
1985 verifier,
1986 tools,
1987 ContextBuilder::new(ContextBudget::default()),
1988 AgentLimits::default(),
1989 );
1990 let outcome = agent.run().expect("run");
1991 assert_eq!(outcome.state, SessionState::Completed);
1992 assert_eq!(outcome.model_turns, 7);
1993 assert_eq!(outcome.tool_calls, 5);
1994 assert_eq!(outcome.repair_cycles, 1);
1995 assert_eq!(outcome.metrics.candidate_count, 2);
1996 assert_eq!(outcome.metrics.falsegreen_attempts, 2);
1997 assert_eq!(outcome.metrics.shell_commands, 2);
1998 assert_eq!(outcome.metrics.test_executions, 0);
1999 drop(agent);
2000 let events = store.events(&session_id).expect("events");
2001 let kinds: Vec<EventKind> = events.iter().map(|event| event.kind).collect();
2002 assert_eq!(
2003 kinds
2004 .iter()
2005 .filter(|kind| **kind == EventKind::CandidateReady)
2006 .count(),
2007 2
2008 );
2009 assert_eq!(
2010 kinds
2011 .iter()
2012 .filter(|kind| **kind == EventKind::FalsegreenResult)
2013 .count(),
2014 2
2015 );
2016 assert_eq!(events.last().expect("last").kind, EventKind::TerminalState);
2017 assert_eq!(
2018 events.last().expect("last").payload["state"],
2019 json!(SessionState::Completed)
2020 );
2021 }
2022
2023 #[test]
2024 fn model_done_does_not_override_falsegreen_failure() {
2025 let directory = git_fixture();
2026 let workspace = Workspace::open(directory.path()).expect("workspace");
2027 let model = FakeModel {
2028 actions: VecDeque::from([ModelAction::CandidateReady {
2029 summary: "done".to_owned(),
2030 }]),
2031 };
2032 let verifier = FakeVerifier {
2033 results: VecDeque::from([FalseGreenResult::failed(
2034 "ignored",
2035 json!({"failed_release_requirements": ["AC-1"]}),
2036 )]),
2037 };
2038 let mut store = EventStore::open_memory().expect("store");
2039 let session = Session::create(&mut store, "goal").expect("session");
2040 let mut agent = Agent::new(
2041 &mut store,
2042 session,
2043 model,
2044 verifier,
2045 NativeTools::new(workspace, ToolLimits::default()),
2046 ContextBuilder::new(ContextBudget::default()),
2047 AgentLimits {
2048 max_repair_cycles: 0,
2049 max_wall_time: Duration::from_secs(5),
2050 ..AgentLimits::default()
2051 },
2052 );
2053 let outcome = agent.run().expect("run");
2054 assert_eq!(outcome.state, SessionState::Failed);
2055 assert_ne!(outcome.state, SessionState::Completed);
2056 }
2057
2058 #[test]
2059 fn records_workspace_mutation_caused_by_shell() {
2060 let directory = git_fixture();
2061 let workspace = Workspace::open(directory.path()).expect("workspace");
2062 let model = FakeModel {
2063 actions: VecDeque::from([
2064 action(
2065 "shell",
2066 json!({"argv": ["sh", "-c", "printf changed\\n > hello.txt"]}),
2067 ),
2068 ModelAction::CandidateReady {
2069 summary: "shell mutation complete".to_owned(),
2070 },
2071 ]),
2072 };
2073 let verifier = FakeVerifier {
2074 results: VecDeque::from([FalseGreenResult::accepted("ignored")]),
2075 };
2076 let mut store = EventStore::open_memory().expect("store");
2077 let session = Session::create(&mut store, "change the file").expect("session");
2078 let session_id = session.id.clone();
2079 let mut agent = Agent::new(
2080 &mut store,
2081 session,
2082 model,
2083 verifier,
2084 NativeTools::new(workspace, ToolLimits::default()),
2085 ContextBuilder::new(ContextBudget::default()),
2086 AgentLimits::default(),
2087 );
2088 assert_eq!(agent.run().expect("run").state, SessionState::Completed);
2089 drop(agent);
2090 let mutations: Vec<_> = store
2091 .events(&session_id)
2092 .expect("events")
2093 .into_iter()
2094 .filter(|event| event.kind == EventKind::FileMutation)
2095 .collect();
2096 assert_eq!(mutations.len(), 1);
2097 assert_eq!(mutations[0].payload["tool"], json!("shell"));
2098 }
2099
2100 #[test]
2101 fn accepted_verification_without_completion_authority_is_not_completed() {
2102 let directory = git_fixture();
2103 let workspace = Workspace::open(directory.path()).expect("workspace");
2104 let model = FakeModel {
2105 actions: VecDeque::from([ModelAction::CandidateReady {
2106 summary: "ready for verification".to_owned(),
2107 }]),
2108 };
2109 let verifier = FakeVerifier {
2110 results: VecDeque::from([FalseGreenResult::accepted_awaiting_authority("ignored")]),
2111 };
2112 let mut store = EventStore::open_memory().expect("store");
2113 let session = Session::create(&mut store, "goal").expect("session");
2114 let mut agent = Agent::new(
2115 &mut store,
2116 session,
2117 model,
2118 verifier,
2119 NativeTools::new(workspace, ToolLimits::default()),
2120 ContextBuilder::new(ContextBudget::default()),
2121 AgentLimits::default(),
2122 );
2123 let outcome = agent.run().expect("run");
2124 assert_eq!(outcome.state, SessionState::AcceptedAwaitingAuthority);
2125 assert_eq!(
2126 outcome
2127 .falsegreen_result
2128 .expect("verification")
2129 .verification,
2130 crate::falsegreen::FalseGreenVerdict::Accepted
2131 );
2132 }
2133
2134 #[test]
2135 fn persists_safe_pause_and_resumes_without_duplicate_actions() {
2136 let directory = git_fixture();
2137 let state_directory = tempfile::tempdir().expect("state tempdir");
2138 let database = state_directory.path().join("events.db");
2139 let mut store = EventStore::open(&database).expect("store");
2140 let session = Session::create(&mut store, "make hello new").expect("session");
2141 let session_id = session.id.clone();
2142 let first_model = FakeModel {
2143 actions: VecDeque::from([action("read_file", json!({"path": "hello.txt"}))]),
2144 };
2145 let first_verifier = FakeVerifier {
2146 results: VecDeque::new(),
2147 };
2148 let workspace = Workspace::open(directory.path()).expect("workspace");
2149 let mut first_agent = Agent::new(
2150 &mut store,
2151 session,
2152 first_model,
2153 first_verifier,
2154 NativeTools::new(workspace, ToolLimits::default()),
2155 ContextBuilder::new(ContextBudget::default()),
2156 AgentLimits::default(),
2157 )
2158 .with_pause_after_model_turns(Some(1));
2159 let paused = first_agent.run().expect("pause");
2160 assert_eq!(paused.state, SessionState::Paused);
2161 drop(first_agent);
2162 drop(store);
2163
2164 let patch = "diff --git a/hello.txt b/hello.txt\n--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-old\n+new\n";
2165 let second_model = FakeModel {
2166 actions: VecDeque::from([
2167 action("apply_patch", json!({"patch": patch})),
2168 ModelAction::CandidateReady {
2169 summary: "resumed candidate".to_owned(),
2170 },
2171 ]),
2172 };
2173 let second_verifier = FakeVerifier {
2174 results: VecDeque::from([FalseGreenResult::accepted("ignored")]),
2175 };
2176 let mut store = EventStore::open(&database).expect("reopen store");
2177 let resumed_session = Session::reconstruct(&store, &session_id).expect("reconstruct");
2178 assert_eq!(resumed_session.state, SessionState::Paused);
2179 let workspace = Workspace::open(directory.path()).expect("workspace");
2180 let mut second_agent = Agent::new(
2181 &mut store,
2182 resumed_session,
2183 second_model,
2184 second_verifier,
2185 NativeTools::new(workspace, ToolLimits::default()),
2186 ContextBuilder::new(ContextBudget::default()),
2187 AgentLimits::default(),
2188 );
2189 let outcome = second_agent.run().expect("resume");
2190 assert_eq!(outcome.state, SessionState::Completed);
2191 assert_eq!(outcome.model_turns, 3);
2192 assert_eq!(outcome.tool_calls, 2);
2193 assert_eq!(outcome.metrics.candidate_count, 1);
2194 assert_eq!(outcome.metrics.falsegreen_attempts, 1);
2195 drop(second_agent);
2196 let events = store.events(&session_id).expect("events");
2197 assert_eq!(
2198 events
2199 .iter()
2200 .filter(|event| event.kind == EventKind::ToolRequest)
2201 .count(),
2202 2
2203 );
2204 assert!(events.iter().any(|event| {
2205 event.kind == EventKind::Checkpoint
2206 && event.payload["checkpoint_kind"] == "resume_validation"
2207 && event.payload["matched"] == true
2208 }));
2209 }
2210
2211 #[test]
2212 fn resume_refuses_a_workspace_that_changed_after_the_turn_checkpoint() {
2213 let directory = git_fixture();
2214 let state_directory = tempfile::tempdir().expect("state tempdir");
2215 let database = state_directory.path().join("events.db");
2216 let mut store = EventStore::open(&database).expect("store");
2217 let session = Session::create(&mut store, "inspect hello").expect("session");
2218 let session_id = session.id.clone();
2219 let model = FakeModel {
2220 actions: VecDeque::from([action("read_file", json!({"path": "hello.txt"}))]),
2221 };
2222 let verifier = FakeVerifier {
2223 results: VecDeque::new(),
2224 };
2225 let workspace = Workspace::open(directory.path()).expect("workspace");
2226 let mut agent = Agent::new(
2227 &mut store,
2228 session,
2229 model,
2230 verifier,
2231 NativeTools::new(workspace, ToolLimits::default()),
2232 ContextBuilder::new(ContextBudget::default()),
2233 AgentLimits::default(),
2234 )
2235 .with_pause_after_model_turns(Some(1));
2236 assert_eq!(agent.run().expect("pause").state, SessionState::Paused);
2237 drop(agent);
2238 std::fs::write(
2239 directory.path().join("hello.txt"),
2240 "changed outside agent\n",
2241 )
2242 .expect("external mutation");
2243
2244 let resumed = Session::reconstruct(&store, &session_id).expect("reconstruct");
2245 let model = FakeModel {
2246 actions: VecDeque::new(),
2247 };
2248 let verifier = FakeVerifier {
2249 results: VecDeque::new(),
2250 };
2251 let workspace = Workspace::open(directory.path()).expect("workspace");
2252 let mut resumed_agent = Agent::new(
2253 &mut store,
2254 resumed,
2255 model,
2256 verifier,
2257 NativeTools::new(workspace, ToolLimits::default()),
2258 ContextBuilder::new(ContextBudget::default()),
2259 AgentLimits::default(),
2260 );
2261 assert!(matches!(
2262 resumed_agent.run(),
2263 Err(super::AgentError::ResumeFingerprintMismatch { .. })
2264 ));
2265 }
2266}