Skip to main content

falsegreen_agent/
agent.rs

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    /// Canonical Agent-owned bridge for a trusted GenUI action. This keeps
171    /// event persistence and MCP invocation under the same Agent instance.
172    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    /// Execute an action selected from a previously admitted composition. The
216    /// cached rendering is never treated as authority: the live composition
217    /// context, source identity, catalog binding, durable state, and normal
218    /// G1/G2 transport fence are all revalidated immediately before dispatch.
219    #[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    /// Execute an explicitly host-local action through the host adapter. It
269    /// never touches the MCP registry or transport; consequential local
270    /// actions still require the exact host-issued confirmation identity.
271    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    /// Run the canonical production G5 path through the Agent's configured
363    /// provider: strict bounded JSON response -> host handle resolution ->
364    /// mandatory authority checks -> durable negotiated renderer admission.
365    /// Any provider/proposal failure uses the deterministic host-owned,
366    /// read-only fallback and records privacy-safe audit metadata.
367    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        // Work from a host-owned snapshot. The optional qualification hook is
377        // invoked after provider resolution but before admission, allowing
378        // adversarial tests to mutate the snapshot at the exact boundary.
379        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        // Capture the live workspace identity before provider generation. The
390        // admission fence compares a fresh read against this host-owned
391        // baseline after all qualification hooks and staged bindings run.
392        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                        // Both fences and the publication seam operate on the
459                        // same real NativeTools instance.  A RefCell keeps
460                        // those borrows sequential: the ordinary/final
461                        // fences only read it, while the post-fence seam gets
462                        // exclusive access before the guarded CAS.
463                        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                                // Hold the exclusive publication guard across
491                                // the commit-time authority re-read and the
492                                // catalog assignment. This closes the exact
493                                // read -> publish gap rejected in V6.
494                                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                                    // Qualification-only seam: mutate the
507                                    // actual workspace/MCP/EventStore after
508                                    // the ordinary final fence and before the
509                                    // guarded authority reread.
510                                    let mut tools = publication_tools_cell.borrow_mut();
511                                    hook(&mut tools, store, catalog);
512                                }
513                                // The authority hook may itself use the
514                                // normal EventStore/MCP mutation APIs, each
515                                // of which takes this global guard. Acquire
516                                // it only after that hook returns, then hold
517                                // it across the final reread and CAS.
518                                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                                    // Qualification-only seam: the callback
538                                    // mutates the real live catalog
539                                    // after the ordinary final fence and
540                                    // before the guarded CAS publication.
541                                    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        // Durable composition events receive only the typed, bounded audit
576        // projection. The internal error (which may retain provider details
577        // for the transient caller) never crosses this persistence boundary.
578        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    /// User-reachable CLI/runtime bridge: after the normal Agent run, expose
627    /// the host-owned run outcome as required data and perform one explicit G5
628    /// composition turn. A provider/proposal failure deterministically renders
629    /// the same host-owned outcome surface through the G5 fallback path.
630    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    /// Install a one-shot host-side mutation hook for qualification tests.
686    /// The hook runs after the model proposal has resolved into a composed
687    /// surface and immediately before durable G1/G2 admission. All normal
688    /// validation remains active, so mutations can only force a safe stale or
689    /// rejected result; they cannot grant authority or bypass the renderer.
690    #[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    /// Install a one-shot qualification hook that mutates the actual runtime
701    /// authorities after the provider response and before the live admission
702    /// fence. This is deliberately test-only; the production path has no
703    /// caller-controlled mutation hook.
704    #[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    /// Install a one-shot qualification hook that can mutate the actual
715    /// runtime and durable state authorities at the admission boundary.
716    #[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    /// Install a one-shot qualification hook that runs after the ordinary
727    /// final admission fence and immediately before guarded catalog
728    /// publication. It is test-only and mutates the actual live authority
729    /// sources so production `compose_genui` CAS behavior can be exercised.
730    #[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    /// Install a one-shot qualification hook that mutates the real runtime
741    /// authorities after `Agent::compose_genui` has completed its ordinary
742    /// final fence and immediately before the guarded publication reread.
743    /// This is the production-path seam used to prove workspace, MCP, and
744    /// durable EventStore races; normal callers never install it.
745    #[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    /// Advance GenUI authority from the trusted Agent/session boundary. The
1016    /// state digest and authorization context are derived from host-owned
1017    /// session and workspace data; callers cannot inject either value.
1018    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    /// End this invocation at a persisted, source-fingerprinted turn boundary.
1059    /// The resulting `Paused` session remains resumable by a later process.
1060    #[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}