From aeef225d6345e5f2b8b149a532871a69975e7054 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 20:58:57 +0000 Subject: [PATCH 01/12] chore(reborn): anchor s2b worktree at flip base Co-Authored-By: Claude Opus 4.8 (1M context) From e05604e9569a209d57e8bfd78aec8f67b3f288c4 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 21:12:54 +0000 Subject: [PATCH 02/12] =?UTF-8?q?refactor(reborn):=20add=20producer-facing?= =?UTF-8?q?=20Resolution=20constructors=20in=20ironclaw=5Fturns=20(=C2=A75?= =?UTF-8?q?.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New run_profile::resolution module with completed/failed/spawned_process/ spawned_child_run (return Resolution), approval_required/auth_required/ resource_blocked/await_dependent_run/external_tool_pending (return GatedResolution), and denied (returns DeniedResolution). Moves the non-lossy redaction verbatim from resolution_mapping (ModelResultPreview 24 KiB word-boundary redaction, ModelFailureDiagnostic, deterministic GateRef::for_auth_gate auth key, resume-token carry, DependentRunResult inline staged result, DenyReason mapping). resolution_mapping:: capability_outcome_to_resolution is retained transitionally as a thin delegator so unmigrated producers keep compiling; RefBindings is now empty (loop refs ride the channel origin). 17 retargeted constructor tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironclaw_turns/src/run_profile/mod.rs | 6 + .../src/run_profile/resolution.rs | 1348 +++++++++++++ .../src/run_profile/resolution_mapping.rs | 1735 +---------------- 3 files changed, 1439 insertions(+), 1650 deletions(-) create mode 100644 crates/ironclaw_turns/src/run_profile/resolution.rs diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index 102870b922..c536b29496 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -23,6 +23,7 @@ mod policy; mod prompt; mod prompt_text; mod refs; +mod resolution; mod resolution_mapping; mod resolver; mod runtime_context; @@ -112,6 +113,11 @@ pub use refs::{ LoopDriverId, ModelProfileId, ResourceBudgetTier, RunClassId, RunProfileFingerprint, RunProfileSourceLayer, RunProfileSourceRef, RunnerPoolId, SchedulingClass, }; +pub use resolution::{ + DeniedResolution, GatedResolution, approval_required, auth_required, await_dependent_run, + completed, denied, external_tool_pending, failed, resource_blocked, spawned_child_run, + spawned_process, +}; pub use resolution_mapping::{MappedResolution, RefBindings, capability_outcome_to_resolution}; pub use resolver::{ InMemoryRunProfileRegistry, InMemoryRunProfileResolver, RunProfileDefinition, diff --git a/crates/ironclaw_turns/src/run_profile/resolution.rs b/crates/ironclaw_turns/src/run_profile/resolution.rs new file mode 100644 index 0000000000..f4d40c87fc --- /dev/null +++ b/crates/ironclaw_turns/src/run_profile/resolution.rs @@ -0,0 +1,1348 @@ +//! Producer-facing constructors that emit a host_api [`Resolution`] directly +//! (arch-simplification §3/§5.3 Stage 2b — the collapse complete). +//! +//! This module replaces the transitional `CapabilityOutcome` → `Resolution` +//! mapping artifact. Instead of building an intermediate ten-variant +//! `CapabilityOutcome` and mapping it, every capability producer calls the +//! constructor for the channel it means: +//! +//! - Non-gate channels return a bare [`Resolution`]: [`completed`], [`failed`], +//! [`spawned_process`], [`spawned_child_run`]. +//! - Gate/suspension channels return a [`GatedResolution`] (the [`Resolution`] +//! plus the durable [`GateRecord`] its opaque ref renders from, §5.2.9), which +//! the loop-host seam persists before returning the resolution: +//! [`approval_required`], [`auth_required`], [`resource_blocked`], +//! [`await_dependent_run`], [`external_tool_pending`]. +//! - A terminal denial returns a [`DeniedResolution`] (the [`Resolution`] plus a +//! sibling [`DenyRecord`]; a denial is terminal and same-turn, so the record +//! is NOT persisted — the model-visible reason/summary ride the channel): [`denied`]. +//! +//! These are free functions in `ironclaw_turns` (NOT methods on +//! `host_api::Resolution`): the non-lossy redaction below consumes loop-facing +//! vocabulary (`CapabilityFailureDetail`, `ModelVisibleToolObservation`, +//! `LoopGateRef`, …) that lives in this crate, and `host_api` sits below it. +//! `turns` is the lowest crate that sees both sides. +//! +//! ## Non-lossy carry (§5.3) +//! +//! `host_api::Resolution` carries **every recoverable field** the old +//! `CapabilityOutcome` variants held, via the vocabulary in +//! [`ironclaw_host_api::result_meta`]: +//! +//! - the failure recovery class ([`CapabilityFailureKind`]) → [`FailureKind`] on +//! [`ToolVerdict::RecoverableFailure`], plus its structured `detail` +//! ([`CapabilityFailureDetail`]) as a redacted [`ModelFailureDiagnostic`] on +//! the same verdict (the model-visible correction hint): `InvalidInput` schema +//! issues carry their [`DispatchInputIssueCode`](ironclaw_host_api::DispatchInputIssueCode) +//! plus redacted [`SafeSummary`] fields, and a free-text `Diagnostic` is +//! redacted to a [`SafeSummary`] (path-shaped text degrades to the placeholder +//! — the raw path never crosses the charter). +//! - `progress`/`terminate_hint`/`output_digest` → +//! [`Outcome::progress`]/[`Outcome::terminate_hint`]/[`OutcomeRefs::output_digest`]. +//! - the `resume_token` inside `approval_resume`/`auth_resume` → the +//! [`ResumeToken`] on the gate [`GateWaypoint`]. Only the token crosses; the +//! raw input/estimate stay host-side (the host reconstitutes them from storage +//! keyed by the token). +//! +//! A spawned-process suspension carries only a [`ProcessRef`] (its summary has no +//! host channel). A completed result's `model_observation` rides the [`Outcome`] +//! result preview; a dependent-run child's rides the inline [`DependentRunResult`] +//! observation on the [`Suspension::DependentRun`] channel. +//! +//! ## Loop refs: minted kernel handle + preserved origin +//! +//! The loop's refs are opaque prefixed strings (`result:*`/`gate:*`/`process:*`); +//! host_api's kernel refs are opaque uuids by design, so they cannot carry the +//! loop's own ref identity. Each constructor mints a fresh kernel handle **and** +//! preserves the originating loop ref on the channel's `origin` (a [`LoopRef`]), +//! so loop/evidence state keyed under the loop ref stays reachable. The only +//! identity that crosses directly is [`TurnRunId`](crate::TurnRunId) → [`RunId`] +//! (both wrap a `Uuid`, preserved via `RunId::from_uuid`). Auth gate records are +//! keyed DETERMINISTICALLY from the `gate:auth-{gate_id}` ref via +//! [`GateRef::for_auth_gate`] so the persist seam and the runner's blocked-exit +//! read derive the same key (byte-stable resume). + +use ironclaw_host_api::{ + Blocked, Denial, DenyReason, DenyRecord, DenyRef, DependentRunResult, FailureKind, GateRecord, + GateRef, GateWaypoint, LoopRef, ModelFailureDiagnostic, ModelInputIssue, ModelInputIssues, + ModelResultPreview, Outcome, OutcomeRefs, OutputDigest, ProcessRef, ProcessWaypoint, + Resolution, ResultPreviewMeta, ResultProgress, ResultRef, ResumeToken, RunId, + RuntimeCredentialAuthRequirement, SafeSummary, Suspension, TerminateHint, ToolVerdict, +}; + +use super::content_digest::ContentDigest; +use super::host::{ + CapabilityApprovalResume, CapabilityAuthResume, CapabilityDeniedReasonKind, CapabilityFailureKind, + CapabilityProgress, CapabilityResumeToken, LoopProcessRef, +}; +use super::model_observation::{ + CapabilityFailureDetail, CapabilityInputIssue, ModelVisibleToolObservation, + ToolObservationDetail, +}; +use crate::{LoopGateRef, LoopResultRef, TurnRunId}; + +/// A [`Resolution`] on a gate/suspension channel paired with the durable +/// [`GateRecord`] its opaque ref renders from (§5.2.9). +/// +/// `Resolution`'s control-plane arms carry only refs; the model-visible content +/// (pending-gate detail) lives in the referenced record. The loop-host seam +/// persists `gate_record` (keyed by the channel's [`GateRef`]) before returning +/// the resolution to the loop. A non-gate resolution routed through this wrapper +/// (`gate_record: None`) is a no-op for the persist seam. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GatedResolution { + pub resolution: Resolution, + pub gate_record: Option, +} + +impl GatedResolution { + /// A resolution with no durable gate record to persist (the `Done`, + /// `Denied`, and `Suspended(Process)` channels). + pub fn bare(resolution: Resolution) -> Self { + Self { + resolution, + gate_record: None, + } + } + + /// A gate/suspension resolution paired with the record its ref renders from. + fn gated(resolution: Resolution, gate_record: GateRecord) -> Self { + Self { + resolution, + gate_record: Some(gate_record), + } + } +} + +/// A terminal [`Resolution::Denied`] paired with the sibling [`DenyRecord`]. +/// +/// A denial is terminal and same-turn, so the record is NOT persisted; the +/// model-visible reason/summary ride the [`Denial`] channel itself (a projection +/// of the record). The record is retained so producers/tests can assert on the +/// redacted denial content at the seam without reading host storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeniedResolution { + pub resolution: Resolution, + pub deny_record: DenyRecord, +} + +// --- Non-gate channel constructors (return a bare `Resolution`) ------------- + +/// The `Done` channel for a capability that ran and succeeded (verdict +/// `Success`). Loop-derived `progress`/`terminate_hint`/`output_digest` cross +/// onto the [`Outcome`]; a fresh [`ResultRef`] is minted and the loop result ref +/// is preserved on `OutcomeRefs.origin`. +pub fn completed( + result_ref: LoopResultRef, + safe_summary: String, + progress: CapabilityProgress, + terminate_hint: bool, + byte_len: u64, + output_digest: Option, + model_observation: Option, +) -> Resolution { + let (preview, preview_meta) = result_preview_parts(model_observation, &result_ref); + Resolution::Done(Outcome { + refs: OutcomeRefs { + result: ResultRef::new(), + byte_len, + preview, + preview_meta, + origin: preserved_origin(result_ref.as_str()), + output_digest: output_digest.map(output_digest_of), + }, + verdict: ToolVerdict::Success, + summary: safe_summary_or_placeholder(safe_summary), + progress: result_progress_of(progress), + terminate_hint: TerminateHint::from_bool(terminate_hint), + }) +} + +/// The `Done` channel for a capability that ran and failed in a model-visible, +/// correctable way (verdict `RecoverableFailure`). The recovery classification +/// AND the redacted structured diagnostic ride the verdict, so the model-visible +/// correction hint crosses without the loop reading host storage. +pub fn failed( + error_kind: CapabilityFailureKind, + safe_summary: String, + detail: Option, +) -> Resolution { + Resolution::Done(Outcome { + refs: OutcomeRefs { + // A recoverable failure stages no durable output beyond its summary; + // the ref is a minted handle the store may leave unpopulated, and + // there is no originating loop result ref to preserve. + result: ResultRef::new(), + byte_len: 0, + preview: None, + preview_meta: ResultPreviewMeta::default(), + origin: None, + output_digest: None, + }, + verdict: match model_failure_diagnostic(detail) { + Some(diagnostic) => { + ToolVerdict::recoverable_failure_with_diagnostic(failure_kind_of(error_kind), diagnostic) + } + None => ToolVerdict::recoverable_failure(failure_kind_of(error_kind)), + }, + summary: safe_summary_or_placeholder(safe_summary), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), + }) +} + +/// The `Suspended(Process)` channel: parked work on a spawned OS process the turn +/// now waits on. A process suspension tracks only a [`ProcessRef`]; the loop +/// process ref is preserved on the waypoint origin (the process's safe summary +/// has no host channel). +pub fn spawned_process(process_ref: LoopProcessRef) -> Resolution { + let waypoint = process_waypoint(ProcessRef::new(), &process_ref); + Resolution::Suspended(Suspension::Process(waypoint)) +} + +/// The `Done`/`ChildSpawned` channel: a NON-suspending child run whose result the +/// executor appends before continuing (the #6137 bug class). Carries the child's +/// [`RunId`] on the verdict (identity preserved via `RunId::from_uuid`); the loop +/// result ref is replaced by a fresh [`ResultRef`] and preserved on the origin. +pub fn spawned_child_run( + child_run_id: TurnRunId, + result_ref: LoopResultRef, + safe_summary: String, + byte_len: u64, + model_observation: Option, +) -> Resolution { + let (preview, preview_meta) = result_preview_parts(model_observation, &result_ref); + Resolution::Done(Outcome { + refs: OutcomeRefs { + result: ResultRef::new(), + byte_len, + preview, + preview_meta, + origin: preserved_origin(result_ref.as_str()), + output_digest: None, + }, + verdict: ToolVerdict::ChildSpawned { + child_run: RunId::from_uuid(child_run_id.as_uuid()), + }, + summary: safe_summary_or_placeholder(safe_summary), + progress: ResultProgress::default(), + terminate_hint: TerminateHint::default(), + }) +} + +// --- Gate/suspension channel constructors (return a `GatedResolution`) ------- + +/// The `Blocked(Approval)` re-entrant gate: needs human approval before it may +/// run. The gate-render summary rides the [`GateRecord`]; the resume token and +/// preserved loop gate ref ride the waypoint (never the model-visible record). +pub fn approval_required( + gate_ref: LoopGateRef, + safe_summary: String, + approval_resume: Option, +) -> GatedResolution { + let waypoint = gate_waypoint(GateRef::new(), &gate_ref, approval_resume_token(approval_resume)); + GatedResolution::gated( + Resolution::Blocked(Blocked::Approval(waypoint)), + GateRecord::Approval { + summary: safe_summary_or_placeholder(safe_summary), + }, + ) +} + +/// The `Blocked(Auth)` re-entrant gate: needs a credential the caller has not +/// supplied. The host-owned `credential_requirements` ride the record (never the +/// model-visible channel); the resume token and preserved loop gate ref ride the +/// waypoint. The record is keyed DETERMINISTICALLY from the `gate:auth-{gate_id}` +/// ref (see [`GateRef::for_auth_gate`]) so the persist seam and the runner's +/// blocked-exit read derive the same key. +pub fn auth_required( + gate_ref: LoopGateRef, + credential_requirements: Vec, + safe_summary: String, + auth_resume: Option, +) -> GatedResolution { + let minted = auth_gate_record_ref(&gate_ref); + let waypoint = gate_waypoint(minted, &gate_ref, auth_resume_token(auth_resume)); + GatedResolution::gated( + Resolution::Blocked(Blocked::Auth(waypoint)), + GateRecord::Auth { + summary: safe_summary_or_placeholder(safe_summary), + credential_requirements, + }, + ) +} + +/// The `Blocked(Resource)` re-entrant gate: needs resource budget currently +/// unavailable. No resume token — a resource gate resumes against then-current +/// budget (§5.3.3). +pub fn resource_blocked(gate_ref: LoopGateRef, safe_summary: String) -> GatedResolution { + let waypoint = gate_waypoint(GateRef::new(), &gate_ref, None); + GatedResolution::gated( + Resolution::Blocked(Blocked::Resource(waypoint)), + GateRecord::Resource { + summary: safe_summary_or_placeholder(safe_summary), + }, + ) +} + +/// The `Suspended(DependentRun)` channel: parked work awaiting a dependent child +/// run. The durable [`GateRecord`] holds the staged result handle + byte length; +/// the channel ALSO carries the staged result inline ([`DependentRunResult`]) so +/// the loop observes the child's output on resume without reading host storage. +/// `model_observation` rides the inline observation caption. +pub fn await_dependent_run( + gate_ref: LoopGateRef, + result_ref: LoopResultRef, + safe_summary: String, + byte_len: u64, + model_observation: Option, +) -> GatedResolution { + let waypoint = gate_waypoint(GateRef::new(), &gate_ref, None); + let minted_result = ResultRef::new(); + let mut staged = + DependentRunResult::new(byte_len, safe_summary_or_placeholder(safe_summary.clone())); + // The dependent-child observation channel is a bounded [`SafeSummary`] + // caption: a child suspension carries the summary caption, not the inline + // first-look content (that is the completed-`Outcome` preview). + if let Some(observation) = observation_summary_caption(model_observation) { + staged = staged.with_observation(observation); + } + if let Some(origin) = preserved_origin(result_ref.as_str()) { + staged = staged.with_origin(origin); + } + GatedResolution::gated( + Resolution::Suspended(Suspension::DependentRun { + waypoint, + result: staged, + }), + GateRecord::DependentRun { + summary: safe_summary_or_placeholder(safe_summary), + result: minted_result, + byte_len, + result_origin: preserved_origin(result_ref.as_str()), + }, + ) +} + +/// The `Suspended(ExternalTool)` channel: parked work on a client-executed +/// external tool the host does not run. +pub fn external_tool_pending(gate_ref: LoopGateRef, safe_summary: String) -> GatedResolution { + let waypoint = gate_waypoint(GateRef::new(), &gate_ref, None); + GatedResolution::gated( + Resolution::Suspended(Suspension::ExternalTool(waypoint)), + GateRecord::ExternalTool { + summary: safe_summary_or_placeholder(safe_summary), + }, + ) +} + +// --- Terminal denial constructor (returns a `DeniedResolution`) -------------- + +/// The terminal `Denied` channel — model-visible, not re-entrant. The +/// model-visible reason + redacted summary ride the [`Denial`] channel (a +/// projection of the sibling [`DenyRecord`]), so the loop can render the denial +/// without reading host storage. +pub fn denied(reason_kind: CapabilityDeniedReasonKind, safe_summary: String) -> DeniedResolution { + let reason = deny_reason_from_kind(&reason_kind); + let summary = safe_summary_or_placeholder(safe_summary); + DeniedResolution { + resolution: Resolution::Denied( + Denial::new(DenyRef::new()) + .with_reason_kind(reason) + .with_summary(summary.clone()), + ), + deny_record: DenyRecord { reason, summary }, + } +} + +// --- Private redaction/mapping helpers (moved verbatim from the mapping) ----- + +/// Redact a loop-facing [`CapabilityFailureDetail`] into the host_api +/// [`ModelFailureDiagnostic`] carried on the verdict. +/// +/// The loop's `InvalidInput` schema issues cross with their structured +/// [`DispatchInputIssueCode`](ironclaw_host_api::DispatchInputIssueCode) and +/// every free-text field re-validated through the [`SafeSummary`] redaction +/// contract (a field that fails is dropped; an issue whose required `path` fails +/// is dropped whole). The loop's lenient free-text `Diagnostic` (which permits +/// paths) is redacted to a [`SafeSummary`]: a path-shaped diagnostic degrades to +/// the placeholder rather than carry a raw host path across the charter. +fn model_failure_diagnostic( + detail: Option, +) -> Option { + match detail? { + CapabilityFailureDetail::InvalidInput { issues } => { + let issues = + ModelInputIssues::truncating(issues.into_iter().filter_map(model_input_issue)); + Some(ModelFailureDiagnostic::InvalidInput { issues }) + } + CapabilityFailureDetail::Diagnostic { text } => Some(ModelFailureDiagnostic::Diagnostic { + // The loop channel allows paths; the host_api boundary does not — a + // path-shaped diagnostic redacts to the placeholder (never raw). + text: SafeSummary::new(text).unwrap_or_else(|_| SafeSummary::placeholder()), + }), + } +} + +/// Redact one loop-facing [`CapabilityInputIssue`] into a host_api +/// [`ModelInputIssue`], routing every free-text field through [`SafeSummary`]. +/// Returns `None` when the required `path` fails the redaction contract (a +/// path-shaped or secret-shaped path — which a safe producer never emits — is +/// dropped rather than carried raw); optional fields that fail are individually +/// dropped. `.ok()` here converts a pure text-to-safe-text validation failure +/// into an absent field, never a swallowed I/O error. +fn model_input_issue(issue: CapabilityInputIssue) -> Option { + let CapabilityInputIssue { + path, + code, + expected, + received, + schema_path, + } = issue; + let mut model = ModelInputIssue::new(SafeSummary::new(path).ok()?, code); + if let Some(expected) = expected.and_then(|value| SafeSummary::new(value).ok()) { + model = model.with_expected(expected); + } + if let Some(received) = received.and_then(|value| SafeSummary::new(value).ok()) { + model = model.with_received(received); + } + if let Some(schema_path) = schema_path.and_then(|value| SafeSummary::new(value).ok()) { + model = model.with_schema_path(schema_path); + } + Some(model) +} + +/// The canonical host [`GateRef`] key for an auth gate's [`GateRecord`], derived +/// deterministically (name-based v5) from the auth gate id encoded in the loop +/// `gate:auth-{gate_id}` ref. Mirrors [`GateRef::for_approval_request`] so the +/// loop-host persist seam and the runner's blocked-exit render-from-record read +/// agree on the key (§5.2.9 / §5.3 Stage 2). A loop ref that is not a +/// `gate:auth-{gate_id}` (which the normal producer never emits) falls back to a +/// fresh handle — the record is still persisted, only not re-derivable. +fn auth_gate_record_ref(loop_gate: &LoopGateRef) -> GateRef { + loop_gate + .as_str() + .strip_prefix("gate:auth-") + .map(GateRef::for_auth_gate) + // silent-ok: pure string reconstruction; a loop ref without the + // `gate:auth-` prefix is never emitted for an auth gate. + .unwrap_or_default() +} + +/// A gate waypoint: the minted kernel handle plus the preserved originating loop +/// gate ref and (for approval/auth) the opaque resume token the loop echoes back. +fn gate_waypoint( + minted: GateRef, + loop_gate: &LoopGateRef, + resume: Option, +) -> GateWaypoint { + let mut waypoint = GateWaypoint::new(minted); + if let Some(origin) = preserved_origin(loop_gate.as_str()) { + waypoint = waypoint.with_origin(origin); + } + if let Some(resume) = resume { + waypoint = waypoint.with_resume(resume); + } + waypoint +} + +/// A process waypoint: the minted kernel handle plus the preserved originating +/// loop process ref. +fn process_waypoint(minted: ProcessRef, loop_process: &LoopProcessRef) -> ProcessWaypoint { + match preserved_origin(loop_process.as_str()) { + Some(origin) => ProcessWaypoint::new(minted).with_origin(origin), + None => ProcessWaypoint::new(minted), + } +} + +/// Preserve a loop ref as a redacted host_api [`LoopRef`] when it satisfies the +/// host redaction contract (bounded, control-free, no path delimiters). A loop +/// ref that fails — which a safe production ref never does — falls back to `None`; +/// `.ok()` here converts a pure text-to-safe-text validation failure into an +/// absent origin, never a swallowed I/O error. +fn preserved_origin(loop_ref: &str) -> Option { + LoopRef::new(loop_ref).ok() +} + +/// The opaque approval resume token, when the producer carried one. +fn approval_resume_token(resume: Option) -> Option { + resume.and_then(|resume| resume_token_of(&resume.resume_token)) +} + +/// The opaque auth resume token, when the producer carried one. +fn auth_resume_token(resume: Option) -> Option { + resume.and_then(|resume| resume_token_of(&resume.resume_token)) +} + +/// Convert a loop-facing [`CapabilityResumeToken`] to a host_api [`ResumeToken`]. +/// Both are bounded/control-free, so a valid loop token always crosses; `.ok()` +/// drops a token that fails the host bound rather than panic (the mapping is +/// total). +fn resume_token_of(token: &CapabilityResumeToken) -> Option { + ResumeToken::new(token.as_str()).ok() +} + +/// Map the loop's [`ContentDigest`] onto host_api's [`OutputDigest`]; both wrap +/// the same truncated Blake3 `u64`. +fn output_digest_of(digest: ContentDigest) -> OutputDigest { + OutputDigest::new(digest.0) +} + +/// Map the loop's [`CapabilityProgress`] onto host_api's [`ResultProgress`]; the +/// variants correspond one-to-one. +fn result_progress_of(progress: CapabilityProgress) -> ResultProgress { + match progress { + CapabilityProgress::Unknown => ResultProgress::Unknown, + CapabilityProgress::MadeProgress => ResultProgress::MadeProgress, + CapabilityProgress::NoChange => ResultProgress::NoChange, + CapabilityProgress::Blocked => ResultProgress::Blocked, + } +} + +/// Map the loop's [`CapabilityFailureKind`] onto host_api's [`FailureKind`] by its +/// stable tag — the two vocabularies share the same closed set plus an open +/// `Unknown`, so every value crosses losslessly. +fn failure_kind_of(kind: CapabilityFailureKind) -> FailureKind { + FailureKind::from_tag(kind.as_str()) +} + +/// The #5838 first-look inline CONTENT preview and its continuation metadata from +/// a loop tool observation, when present. +/// +/// The inline content the model reads without a follow-up `result_read` lives on +/// the `ResultReference` detail's `preview` — NOT the generic `summary` caption +/// (routing content through `SafeSummary` dropped every delimiter-bearing/JSON +/// result and scrubbed `Secretary`, forcing a re-read amnesia loop). It is carried +/// as a [`ModelResultPreview`]: delimiters/newlines retained, credential-redacted +/// at a word boundary, up to 24 KiB. The paired [`ResultPreviewMeta`] carries the +/// TRUNCATED-preview continuation info (`result_read` / large results): the +/// referenced result ref, full byte size, next offset, and JSON-array element +/// count, so the model reads the full result. Detail kinds other than +/// `ResultReference` have no inline content. +/// +/// `own_result_ref` is this outcome's own loop result ref: the referenced ref is +/// carried only when it DIFFERS (a `result_read` presenting another result's ref); +/// otherwise the reconstruction uses the outcome's own ref, keeping the wire clean. +fn result_preview_parts( + observation: Option, + own_result_ref: &LoopResultRef, +) -> (Option, ResultPreviewMeta) { + let empty = (None, ResultPreviewMeta::default()); + let Some(observation) = observation else { + return empty; + }; + // Capture the observation's own model-visible summary before destructuring + // `detail`; it is DISTINCT from the outcome caption and must survive the + // collapse so the reconstructed observation keeps the producer's exact + // truncation/continuation hint (best-effort caption via `.ok()`). + let ModelVisibleToolObservation { + summary, detail, .. + } = observation; + let summary = SafeSummary::new(summary).ok(); + let ToolObservationDetail::ResultReference { + result_ref, + preview: Some(text), + total_bytes, + next_offset, + item_count, + .. + } = detail + else { + return empty; + }; + // `.ok()` intentionally degrades content that fails the credential redaction + // contract to an absent preview (a pure text-to-redacted-content conversion); + // the full output stays reachable through the result ref, and without inline + // content the continuation metadata is useless, so drop both. + let Some(preview) = ModelResultPreview::new(text).ok() else { + return empty; + }; + let referenced_result_ref = if result_ref == own_result_ref.as_str() { + None + } else { + LoopRef::new(result_ref).ok() + }; + ( + Some(preview), + ResultPreviewMeta { + referenced_result_ref, + total_bytes, + next_offset, + item_count, + summary, + }, + ) +} + +/// The observation's generic `summary` as a bounded [`SafeSummary`] caption — the +/// dependent-child observation channel, which carries a caption rather than the +/// inline first-look content the completed-`Outcome` preview does. +/// +/// `.ok()` degrades a caption that fails the caption redaction contract to `None`; +/// a pure text-to-safe-text conversion, and the caption is best-effort. +fn observation_summary_caption( + observation: Option, +) -> Option { + observation.and_then(|observation| SafeSummary::new(observation.summary).ok()) +} + +/// Convert a loop-facing `safe_summary: String` to a host_api [`SafeSummary`]. +/// +/// The redaction rule is the same on both sides (#6236), so a value the producer +/// already redacted normally passes. If it somehow fails validation, fall back to +/// the infallible [`SafeSummary::placeholder`] rather than panic — this mapping is +/// total. +fn safe_summary_or_placeholder(raw: String) -> SafeSummary { + SafeSummary::new(raw).unwrap_or_else(|_| SafeSummary::placeholder()) +} + +/// Map the loop-side denial vocabulary onto host_api's [`DenyReason`]. +/// +/// The loop's [`CapabilityDeniedReasonKind`] is an evolving open set +/// (`EmptySurface` plus free-form `Unknown(..)` strings like `hook_denied`, +/// `model_view_denied`); host_api's [`DenyReason`] is a fixed closed enum whose +/// variants originate on the host authorize path, not the loop. There is no +/// faithful 1:1, so this is a best-effort match: a reason string that already +/// spells a `DenyReason` snake_case tag is honored, and everything else — every +/// loop-originated denial — buckets into the model-visible catch-all +/// [`DenyReason::PolicyDenied`]. +fn deny_reason_from_kind(kind: &CapabilityDeniedReasonKind) -> DenyReason { + use serde::{ + Deserialize, + de::{IntoDeserializer, value::StrDeserializer}, + }; + // Deserialize straight from the &str (no JSON Value/String allocation); + // DenyReason's snake_case serde tags are the match vocabulary. + let deserializer: StrDeserializer<'_, serde::de::value::Error> = + kind.as_str().into_deserializer(); + DenyReason::deserialize(deserializer).unwrap_or(DenyReason::PolicyDenied) +} + +#[cfg(test)] +mod tests { + use super::super::host::CapabilityInputRef; + use super::super::model_observation::ModelVisibleToolObservation; + use super::super::{CapabilityProgress, MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION}; + use super::*; + use ironclaw_host_api::{ + ApprovalRequestId, CorrelationId, DispatchInputIssueCode, ExtensionId, GateRecord, + RuntimeCredentialAccountProviderId, RuntimeCredentialAccountSetup, + }; + + fn result_ref() -> LoopResultRef { + LoopResultRef::new("result:child-1").unwrap() + } + + fn gate_ref() -> LoopGateRef { + LoopGateRef::new("gate:pending-1").unwrap() + } + + fn auth_gate_ref() -> LoopGateRef { + LoopGateRef::new("gate:auth-cred-1").unwrap() + } + + fn credential_requirement() -> RuntimeCredentialAuthRequirement { + RuntimeCredentialAuthRequirement { + provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), + setup: RuntimeCredentialAccountSetup::ManualToken, + requester_extension: ExtensionId::new("github").unwrap(), + provider_scopes: vec!["repo".to_string()], + } + } + + fn completed_ok(summary: &str) -> Resolution { + completed( + result_ref(), + summary.to_string(), + CapabilityProgress::MadeProgress, + true, + 4096, + None, + None, + ) + } + + /// A model-visible `ResultReference` tool observation whose inline + /// `detail.preview` content is `content` (the model-visible CONTENT is on the + /// detail preview, per #5838; the `summary` is a generic caption). + fn observation(content: &str) -> ModelVisibleToolObservation { + use super::super::model_observation::{ + ObservationTrust, ToolObservationDetail, ToolObservationStatus, + }; + ModelVisibleToolObservation { + schema_version: MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, + status: ToolObservationStatus::Success, + summary: "tool completed".to_string(), + detail: ToolObservationDetail::ResultReference { + result_ref: "result:staged".to_string(), + byte_len: 10, + preview: Some(content.to_string()), + total_bytes: None, + next_offset: None, + item_count: None, + }, + artifacts: vec![], + recovery: None, + trust: ObservationTrust::UntrustedToolOutput, + } + } + + /// The §5.3 acceptance table is the definition of done: every producer + /// constructor lands on exactly one `Resolution` channel with the correct + /// suspension semantics and side record. This mirrors host_api's + /// `resolution_covers_the_full_acceptance_table` on the producer side. + #[test] + fn constructors_cover_the_full_acceptance_table() { + // (label, resolution, is_suspension, expected gate_record kind, deny present) + struct Row { + label: &'static str, + resolution: Resolution, + suspends: bool, + gate_record: Option<&'static str>, + deny_record: bool, + } + + let approval = approval_required(gate_ref(), "awaiting approval".to_string(), None); + let auth = auth_required( + auth_gate_ref(), + vec![credential_requirement()], + "awaiting credential".to_string(), + None, + ); + let resource = resource_blocked(gate_ref(), "awaiting budget".to_string()); + let dependent = await_dependent_run( + gate_ref(), + result_ref(), + "awaiting dependent run".to_string(), + 256, + None, + ); + let external = external_tool_pending(gate_ref(), "awaiting external tool".to_string()); + let deny = denied( + CapabilityDeniedReasonKind::EmptySurface, + "denied by policy".to_string(), + ); + + let rows = vec![ + Row { + label: "completed", + resolution: completed_ok("read 3 files"), + suspends: false, + gate_record: None, + deny_record: false, + }, + Row { + label: "failed", + resolution: failed( + CapabilityFailureKind::InvalidInput, + "tool input rejected".to_string(), + None, + ), + suspends: false, + gate_record: None, + deny_record: false, + }, + Row { + label: "denied", + resolution: deny.resolution.clone(), + suspends: false, + gate_record: None, + deny_record: true, + }, + Row { + label: "approval_required", + resolution: approval.resolution.clone(), + suspends: false, + gate_record: approval.gate_record.as_ref().map(GateRecord::kind), + deny_record: false, + }, + Row { + label: "auth_required", + resolution: auth.resolution.clone(), + suspends: false, + gate_record: auth.gate_record.as_ref().map(GateRecord::kind), + deny_record: false, + }, + Row { + label: "resource_blocked", + resolution: resource.resolution.clone(), + suspends: false, + gate_record: resource.gate_record.as_ref().map(GateRecord::kind), + deny_record: false, + }, + Row { + label: "spawned_process", + resolution: spawned_process(LoopProcessRef::new("process:pid-1").unwrap()), + suspends: true, + gate_record: None, + deny_record: false, + }, + Row { + label: "spawned_child_run", + resolution: spawned_child_run( + TurnRunId::new(), + result_ref(), + "spawned child run".to_string(), + 128, + None, + ), + // NON-suspending — the #6137 bug class. + suspends: false, + gate_record: None, + deny_record: false, + }, + Row { + label: "await_dependent_run", + resolution: dependent.resolution.clone(), + suspends: true, + gate_record: dependent.gate_record.as_ref().map(GateRecord::kind), + deny_record: false, + }, + Row { + label: "external_tool_pending", + resolution: external.resolution.clone(), + suspends: true, + gate_record: external.gate_record.as_ref().map(GateRecord::kind), + deny_record: false, + }, + ]; + + // Expected gate-record kinds spelled once, matched against what the gate + // constructors returned above. + let expected_gate_kinds = [ + ("approval_required", Some("approval")), + ("auth_required", Some("auth")), + ("resource_blocked", Some("resource")), + ("await_dependent_run", Some("dependent_run")), + ("external_tool_pending", Some("external_tool")), + ]; + + assert_eq!(rows.len(), 10, "all ten producer channels covered"); + + for row in &rows { + assert_eq!( + row.resolution.is_suspension(), + row.suspends, + "{}: is_suspension", + row.label + ); + if let Some((_, expected)) = expected_gate_kinds.iter().find(|(l, _)| *l == row.label) { + assert_eq!(row.gate_record, *expected, "{}: gate_record kind", row.label); + } + assert!( + !(row.gate_record.is_some() && row.deny_record), + "{}: at most one side record", + row.label + ); + } + } + + /// The suspension split (#6137): Approval/Auth/Resource are re-entrant gates + /// (`Blocked`, NOT a suspension), Process/DependentRun/ExternalTool are parked + /// work (`Suspended`), and a spawned child run completes (`Done`, NOT a + /// suspension). + #[test] + fn suspension_split_matches_host_api_semantics() { + for gated in [ + approval_required(gate_ref(), "a".to_string(), None), + auth_required(auth_gate_ref(), vec![], "a".to_string(), None), + resource_blocked(gate_ref(), "a".to_string()), + ] { + assert!(gated.resolution.is_reentrant_gate()); + assert!( + !gated.resolution.is_suspension(), + "a re-entrant gate must NOT be a host_api suspension" + ); + } + + assert!( + spawned_process(LoopProcessRef::new("process:pid-1").unwrap()).is_suspension(), + "a spawned process is parked work" + ); + assert!( + await_dependent_run(gate_ref(), result_ref(), "a".to_string(), 1, None) + .resolution + .is_suspension(), + "a dependent run is parked work" + ); + assert!( + external_tool_pending(gate_ref(), "a".to_string()) + .resolution + .is_suspension(), + "an external tool is parked work" + ); + assert!( + !spawned_child_run(TurnRunId::new(), result_ref(), "a".to_string(), 1, None) + .is_suspension(), + "a spawned child run completes, it does not suspend" + ); + } + + #[test] + fn completed_carries_success_verdict_and_minted_result_ref() { + match completed_ok("staged output") { + Resolution::Done(outcome) => { + assert_eq!(outcome.verdict, ToolVerdict::Success); + assert!(outcome.verdict.is_success()); + assert_eq!(outcome.refs.byte_len, 4096); + assert_eq!(outcome.summary.as_str(), "staged output"); + assert_eq!(outcome.verdict.child_run(), None); + } + other => panic!("expected Done, got {other:?}"), + } + } + + /// A `completed` result's loop-derived signals (progress, terminate_hint, + /// output_digest) and its originating loop result ref survive onto + /// `Resolution::Done`. + #[test] + fn completed_carries_progress_terminate_hint_digest_and_origin() { + let digest = + ContentDigest::from_json_value(&serde_json::json!({"k": "v"})).expect("digest"); + match completed( + result_ref(), + "did work".to_string(), + CapabilityProgress::MadeProgress, + true, + 4096, + Some(digest), + None, + ) { + Resolution::Done(done) => { + assert_eq!(done.progress, ResultProgress::MadeProgress); + assert!(done.terminate_hint.should_terminate()); + assert_eq!( + done.refs.output_digest.map(OutputDigest::value), + Some(digest.0), + "output_digest must survive" + ); + assert_eq!( + done.refs.origin.as_ref().map(LoopRef::as_str), + Some(result_ref().as_str()), + "the originating loop result ref must be preserved on OutcomeRefs.origin" + ); + } + other => panic!("expected Done, got {other:?}"), + } + } + + /// A `failed` result's recovery classification rides + /// `ToolVerdict::RecoverableFailure`. + #[test] + fn failed_carries_its_error_kind_on_the_verdict() { + for (loop_kind, expected) in [ + (CapabilityFailureKind::Network, FailureKind::Network), + ( + CapabilityFailureKind::InvalidInput, + FailureKind::InvalidInput, + ), + ( + CapabilityFailureKind::unknown("quota_exceeded").unwrap(), + FailureKind::unknown("quota_exceeded").unwrap(), + ), + ] { + match failed(loop_kind, "tool failed".to_string(), None) { + Resolution::Done(done) => { + assert_eq!( + done.verdict, + ToolVerdict::recoverable_failure(expected.clone()), + "the recovery class must ride the verdict" + ); + } + other => panic!("expected Done, got {other:?}"), + } + } + } + + /// A `failed` result's structured `InvalidInput` diagnostic round-trips its + /// schema issues (path, code, expected/received) onto the verdict. + #[test] + fn failed_invalid_input_diagnostic_round_trips_structured_issues() { + let detail = Some(CapabilityFailureDetail::InvalidInput { + issues: vec![CapabilityInputIssue { + path: "schedule.kind".to_string(), + code: DispatchInputIssueCode::TypeMismatch, + expected: Some("integer".to_string()), + received: Some("string".to_string()), + schema_path: Some("properties.schedule".to_string()), + }], + }); + match failed( + CapabilityFailureKind::InvalidInput, + "tool input rejected".to_string(), + detail, + ) { + Resolution::Done(done) => match done.verdict.diagnostic() { + Some(ModelFailureDiagnostic::InvalidInput { issues }) => { + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].code, DispatchInputIssueCode::TypeMismatch); + assert_eq!(issues[0].path.as_str(), "schedule.kind"); + assert_eq!( + issues[0].expected.as_ref().map(SafeSummary::as_str), + Some("integer") + ); + assert_eq!( + issues[0].received.as_ref().map(SafeSummary::as_str), + Some("string") + ); + } + other => panic!("expected InvalidInput diagnostic, got {other:?}"), + }, + other => panic!("expected Done, got {other:?}"), + } + } + + /// A `failed` result's free-text `Diagnostic` rides the verdict as a redacted + /// `SafeSummary`, and a path/secret-shaped diagnostic is redacted (never raw). + #[test] + fn failed_free_text_diagnostic_round_trips_and_redacts() { + match failed( + CapabilityFailureKind::Backend, + "tool failed".to_string(), + Some(CapabilityFailureDetail::Diagnostic { + text: "backend returned an error".to_string(), + }), + ) { + Resolution::Done(done) => match done.verdict.diagnostic() { + Some(ModelFailureDiagnostic::Diagnostic { text }) => { + assert_eq!(text.as_str(), "backend returned an error"); + } + other => panic!("expected Diagnostic, got {other:?}"), + }, + other => panic!("expected Done, got {other:?}"), + } + + // A free-text diagnostic carrying a host path is redacted to the + // placeholder — the raw path never crosses the boundary. + match failed( + CapabilityFailureKind::Backend, + "tool failed".to_string(), + Some(CapabilityFailureDetail::Diagnostic { + text: "failed reading /etc/passwd".to_string(), + }), + ) { + Resolution::Done(done) => match done.verdict.diagnostic() { + Some(ModelFailureDiagnostic::Diagnostic { text }) => { + assert_eq!(text, &SafeSummary::placeholder()); + assert!(!text.as_str().contains("/etc/passwd")); + } + other => panic!("expected Diagnostic, got {other:?}"), + }, + other => panic!("expected Done, got {other:?}"), + } + + // A secret-shaped `received` value is dropped (not carried raw). + match failed( + CapabilityFailureKind::InvalidInput, + "tool input rejected".to_string(), + Some(CapabilityFailureDetail::InvalidInput { + issues: vec![CapabilityInputIssue { + path: "token".to_string(), + code: DispatchInputIssueCode::InvalidValue, + expected: Some("opaque string".to_string()), + received: Some("sk-ant-abc123def456".to_string()), + schema_path: None, + }], + }), + ) { + Resolution::Done(done) => match done.verdict.diagnostic() { + Some(ModelFailureDiagnostic::InvalidInput { issues }) => { + assert_eq!(issues.len(), 1); + assert_eq!(issues[0].path.as_str(), "token"); + assert_eq!( + issues[0].received, None, + "a secret-shaped issue field must be dropped, never carried raw" + ); + assert_eq!( + issues[0].expected.as_ref().map(SafeSummary::as_str), + Some("opaque string") + ); + } + other => panic!("expected InvalidInput, got {other:?}"), + }, + other => panic!("expected Done, got {other:?}"), + } + } + + /// A denial carries its model-visible reason + redacted summary ON THE + /// CHANNEL (mirroring the sibling record), and a path-shaped summary redacts + /// to the placeholder. + #[test] + fn denied_channel_carries_reason_kind_and_redacted_summary() { + let denied_res = denied( + CapabilityDeniedReasonKind::unknown("network_denied").unwrap(), + "blocked egress".to_string(), + ); + match &denied_res.resolution { + Resolution::Denied(denial) => { + assert_eq!(denial.reason_kind, Some(DenyReason::NetworkDenied)); + assert_eq!( + denial.summary.as_ref().map(SafeSummary::as_str), + Some("blocked egress") + ); + assert_eq!(denial.reason_kind, Some(denied_res.deny_record.reason)); + assert_eq!(denial.summary.as_ref(), Some(&denied_res.deny_record.summary)); + } + other => panic!("expected Denied, got {other:?}"), + } + + let denied_res = denied( + CapabilityDeniedReasonKind::EmptySurface, + "denied reading /secret/path".to_string(), + ); + match denied_res.resolution { + Resolution::Denied(denial) => { + assert_eq!(denial.reason_kind, Some(DenyReason::PolicyDenied)); + assert_eq!(denial.summary, Some(SafeSummary::placeholder())); + } + other => panic!("expected Denied, got {other:?}"), + } + } + + /// A loop-originated open-set reason buckets into the model-visible catch-all; + /// a reason string that already spells a `DenyReason` tag is honored. + #[test] + fn deny_record_reason_maps_best_effort_with_policy_fallback() { + for reason_kind in [ + CapabilityDeniedReasonKind::EmptySurface, + CapabilityDeniedReasonKind::unknown("hook_denied").unwrap(), + ] { + assert_eq!( + denied(reason_kind, "denied".to_string()).deny_record.reason, + DenyReason::PolicyDenied + ); + } + assert_eq!( + denied( + CapabilityDeniedReasonKind::unknown("network_denied").unwrap(), + "blocked egress".to_string() + ) + .deny_record + .reason, + DenyReason::NetworkDenied + ); + } + + /// An approval gate carries its resume token and preserved loop gate ref; an + /// auth gate likewise, and its record carries the credential requirements. + #[test] + fn approval_and_auth_gates_carry_resume_token_and_preserved_origin() { + let approval_resume = CapabilityApprovalResume { + approval_request_id: ApprovalRequestId::new(), + resume_token: CapabilityResumeToken::new("approval-resume-1").unwrap(), + correlation_id: CorrelationId::new(), + input_ref: CapabilityInputRef::new("input:x").unwrap(), + }; + let gated = approval_required( + gate_ref(), + "awaiting approval".to_string(), + Some(approval_resume), + ); + match &gated.resolution { + Resolution::Blocked(blocked @ Blocked::Approval(_)) => { + assert_eq!( + blocked.resume_token().map(ResumeToken::as_str), + Some("approval-resume-1") + ); + assert_eq!( + blocked.origin().map(LoopRef::as_str), + Some(gate_ref().as_str()) + ); + } + other => panic!("expected Blocked::Approval, got {other:?}"), + } + + let auth_resume = CapabilityAuthResume { + resume_token: CapabilityResumeToken::new("auth-resume-1").unwrap(), + prior_approval: None, + }; + let gated = auth_required( + auth_gate_ref(), + vec![credential_requirement()], + "awaiting credential".to_string(), + Some(auth_resume), + ); + match &gated.resolution { + Resolution::Blocked(blocked @ Blocked::Auth(_)) => { + assert_eq!( + blocked.resume_token().map(ResumeToken::as_str), + Some("auth-resume-1") + ); + assert_eq!( + blocked.origin().map(LoopRef::as_str), + Some(auth_gate_ref().as_str()) + ); + } + other => panic!("expected Blocked::Auth, got {other:?}"), + } + match gated.gate_record { + Some(GateRecord::Auth { + credential_requirements, + .. + }) => assert_eq!(credential_requirements, vec![credential_requirement()]), + other => panic!("expected GateRecord::Auth, got {other:?}"), + } + } + + /// The auth gate record key is derived DETERMINISTICALLY from the + /// `gate:auth-{gate_id}` ref (byte-stable resume) — the same key + /// `GateRef::for_auth_gate` produces. + #[test] + fn auth_gate_record_key_is_deterministic_from_the_loop_ref() { + let gated = auth_required(auth_gate_ref(), vec![], "auth".to_string(), None); + match &gated.resolution { + Resolution::Blocked(Blocked::Auth(waypoint)) => { + assert_eq!(waypoint.gate, GateRef::for_auth_gate("cred-1")); + } + other => panic!("expected Blocked::Auth, got {other:?}"), + } + } + + /// A spawned-process suspension preserves its loop process ref on the channel. + #[test] + fn spawned_process_preserves_the_loop_process_ref_on_the_channel() { + match spawned_process(LoopProcessRef::new("process:pid-7").unwrap()) { + Resolution::Suspended(suspension @ Suspension::Process(_)) => { + assert_eq!(suspension.origin().map(LoopRef::as_str), Some("process:pid-7")); + } + other => panic!("expected Suspended(Process), got {other:?}"), + } + } + + #[test] + fn child_run_identity_is_preserved_on_the_verdict() { + let child_run_id = TurnRunId::new(); + match spawned_child_run(child_run_id, result_ref(), "spawned".to_string(), 64, None) { + Resolution::Done(outcome) => { + assert_eq!( + outcome.verdict.child_run().map(|run| run.as_uuid()), + Some(child_run_id.as_uuid()) + ); + assert_eq!(outcome.refs.byte_len, 64); + } + other => panic!("expected Done, got {other:?}"), + } + } + + /// A dependent run carries its staged result + byte length on the durable + /// record AND inline on the channel, with the loop origin preserved on both. + #[test] + fn dependent_run_record_and_channel_carry_staged_result() { + let gated = await_dependent_run( + gate_ref(), + result_ref(), + "awaiting dependent".to_string(), + 2048, + None, + ); + match &gated.gate_record { + Some(GateRecord::DependentRun { + byte_len, + summary, + result_origin, + .. + }) => { + assert_eq!(*byte_len, 2048); + assert_eq!(summary.as_str(), "awaiting dependent"); + assert_eq!( + result_origin.as_ref().map(LoopRef::as_str), + Some(result_ref().as_str()) + ); + } + other => panic!("expected GateRecord::DependentRun, got {other:?}"), + } + match &gated.resolution { + Resolution::Suspended(suspension @ Suspension::DependentRun { .. }) => { + let staged = suspension.dependent_result().expect("inline staged result"); + assert_eq!(staged.byte_len, 2048); + assert_eq!(staged.summary.as_str(), "awaiting dependent"); + assert_eq!( + staged.origin.as_ref().map(LoopRef::as_str), + Some(result_ref().as_str()) + ); + } + other => panic!("expected Suspended(DependentRun), got {other:?}"), + } + } + + /// `await_dependent_run`'s `model_observation` rides the inline observation + /// caption and is redacted (a secret/path-shaped observation degrades). + #[test] + fn dependent_run_carries_model_observation_inline_and_redacts() { + let with_summary = |summary: &str| { + let mut o = observation("child content"); + o.summary = summary.to_string(); + o + }; + + let gated = await_dependent_run( + gate_ref(), + result_ref(), + "child produced 4 rows".to_string(), + 512, + Some(with_summary("child preview: 4 rows")), + ); + let staged = match &gated.resolution { + Resolution::Suspended(s @ Suspension::DependentRun { .. }) => { + s.dependent_result().expect("staged").clone() + } + other => panic!("expected DependentRun, got {other:?}"), + }; + assert_eq!( + staged.observation.as_ref().map(SafeSummary::as_str), + Some("child preview: 4 rows") + ); + assert_eq!(staged.summary.as_str(), "child produced 4 rows"); + + let gated = await_dependent_run( + gate_ref(), + result_ref(), + "leaked path /etc/passwd".to_string(), + 512, + Some(with_summary("api key: sk-ant-leak")), + ); + let staged = match &gated.resolution { + Resolution::Suspended(s @ Suspension::DependentRun { .. }) => { + s.dependent_result().expect("staged").clone() + } + other => panic!("expected DependentRun, got {other:?}"), + }; + assert_eq!(staged.observation, None); + assert_eq!(staged.summary, SafeSummary::placeholder()); + } + + #[test] + fn an_unsafe_summary_falls_back_to_the_placeholder_never_panics() { + let gated = resource_blocked(gate_ref(), "leaked path /etc/passwd".to_string()); + match gated.gate_record { + Some(GateRecord::Resource { summary }) => { + assert_eq!(summary, SafeSummary::placeholder()); + } + other => panic!("expected GateRecord::Resource, got {other:?}"), + } + } + + #[test] + fn completed_observation_preview_carries_delimiter_content_and_drops_credentials() { + let refs_preview = |content: &str| match completed( + result_ref(), + "ok".to_string(), + CapabilityProgress::Unknown, + false, + 10, + None, + Some(observation(content)), + ) { + Resolution::Done(outcome) => { + outcome.refs.preview.as_ref().map(|p| p.as_str().to_string()) + } + other => panic!("expected Done, got {other:?}"), + }; + + // Structured content with delimiters + "Secretary" retained verbatim. + let content = "{\"office\": \"Secretary of the Treasury\", \"rows\": [1, 2, 3]}"; + assert_eq!(refs_preview(content).as_deref(), Some(content)); + // A genuine credential in the content drops the inline preview to None. + assert_eq!(refs_preview("token sk-ant-abc123def456").as_deref(), None); + } +} diff --git a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs b/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs index ce06d3e489..ee562dab2d 100644 --- a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs +++ b/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs @@ -1,1701 +1,136 @@ -//! `CapabilityOutcome` → `Resolution` mapping (arch-simplification §3/§5.3). +//! Transitional `CapabilityOutcome` → `Resolution` adapter (arch-simplification +//! §5.3 Stage 2b — being deleted). //! -//! The loop-facing [`CapabilityOutcome`] is the overloaded ten-variant enum that -//! carries every non-happy path today (§1.2). The target model folds those ten -//! variants into the five host_api result channels — [`Resolution::Done`], -//! [`Resolution::Denied`], [`Resolution::Blocked`], [`Resolution::Suspended`], and -//! the `Err` arm [`ironclaw_host_api::HostFailure`] — plus the side records -//! ([`GateRecord`]/[`DenyRecord`]) that hold the content the channel's opaque refs -//! point at (§5.2.9's "render from record" contract). -//! -//! This module is the **pure mapping artifact**: it converts one owned -//! `CapabilityOutcome` into a [`MappedResolution`]. It is landed additively (§9) — -//! nothing produces `Resolution` from a `CapabilityOutcome` in production yet; the -//! later producer/consumer migration wires this in. `CapabilityOutcome` is -//! unchanged and every existing path keeps its current behavior. -//! -//! ## Non-lossy carry (§5.3 Stage 1) -//! -//! `host_api::Resolution` now carries **every recoverable field** the old -//! `CapabilityOutcome` variants held, via the vocabulary in -//! [`ironclaw_host_api::result_meta`]: -//! -//! - `CapabilityFailure::error_kind` ([`CapabilityFailureKind`]) → the -//! [`FailureKind`] on [`ToolVerdict::RecoverableFailure`] — the recovery class -//! that drives retry-vs-terminal now crosses (was "G1-dropped"). Its structured -//! `detail` ([`CapabilityFailureDetail`]) now crosses too, as a redacted -//! [`ModelFailureDiagnostic`] on the same variant (the model-visible correction -//! hint — PR-B): the `InvalidInput` schema issues carry their -//! [`DispatchInputIssueCode`](ironclaw_host_api::DispatchInputIssueCode) plus -//! redacted [`SafeSummary`] fields, and a free-text `Diagnostic` is redacted to -//! a [`SafeSummary`] (path-shaped text degrades to the placeholder — the raw -//! path never crosses the charter). No backend cause stays behind for want of a -//! home. -//! - `CapabilityResultMessage::{progress, terminate_hint, output_digest}` → -//! [`Outcome::progress`]/[`Outcome::terminate_hint`]/[`OutcomeRefs::output_digest`] -//! (were the "G4-dropped" loop-derived signals). -//! - The `resume_token` inside `approval_resume`/`auth_resume` → the -//! [`ResumeToken`] on the gate [`GateWaypoint`], so the loop can echo it back to -//! resume the gate. Only the *token* crosses; the raw input/estimate replay it -//! was bundled with stays host-side (charter: no raw input in vocabulary — the -//! host reconstitutes it from storage keyed by the token). -//! -//! `SpawnedProcess`'s `safe_summary` still has no host channel (a process -//! suspension carries a [`ProcessRef`], not a summary). `SpawnedChildRun`'s -//! `model_observation` rides the [`Outcome`] result preview; `AwaitDependentRun`'s -//! rides the inline [`DependentRunResult`] observation on the -//! [`Suspension::DependentRun`] channel (was dropped entirely — §5.3 Stage 1b), -//! so the loop observes the child's `byte_len`, redacted summary, and observation -//! on resume without reading the host-persisted `GateRecord::DependentRun`. -//! -//! ## Loop refs: minted kernel handle + preserved origin -//! -//! The loop's refs ([`LoopResultRef`], [`LoopGateRef`], [`LoopProcessRef`]) are -//! opaque prefixed strings (`result:*`/`gate:*`/`process:*`); host_api's kernel -//! refs ([`ResultRef`]/[`GateRef`]/[`ProcessRef`]) are opaque uuids by design, so -//! they cannot carry the loop's own ref identity. The mapping mints a fresh kernel -//! handle **and** preserves the originating loop ref on the channel's `origin` -//! (a [`LoopRef`]) — so loop/evidence state keyed under the loop ref (e.g. output -//! the result writer staged) stays reachable through the migration window, not only -//! via the [`RefBindings`] side-table (which is retained). The only identity that -//! crosses directly is [`TurnRunId`](crate::TurnRunId) → [`RunId`]: both wrap a -//! `Uuid`, preserved via `RunId::from_uuid`. +//! The non-lossy redaction now lives in the producer-facing constructors in +//! [`super::resolution`]; this module is a thin delegator kept ONLY so producers +//! not yet migrated to the constructors keep compiling through the collapse. It +//! mints no refs and carries no side-table: [`RefBindings`] is always empty (the +//! flip routes loop-ref recovery via the channel's preserved `origin`), and the +//! sibling records are re-collected from the constructor results. Once every +//! producer emits a `Resolution` directly, this file and `MappedResolution`/ +//! `RefBindings` are deleted. -// arch-exempt: large_file, transitional CapabilityOutcome→Resolution mapping artifact deleted at the atomic flip; the Stage-1b growth is non-lossy tests, plan #6175 -use ironclaw_host_api::{ - Blocked, Denial, DenyReason, DenyRecord, DenyRef, DependentRunResult, FailureKind, GateRecord, - GateRef, GateWaypoint, LoopRef, ModelFailureDiagnostic, ModelInputIssue, ModelInputIssues, - ModelResultPreview, Outcome, OutcomeRefs, OutputDigest, ProcessRef, ProcessWaypoint, - Resolution, ResultPreviewMeta, ResultProgress, ResultRef, ResumeToken, RunId, SafeSummary, - Suspension, TerminateHint, ToolVerdict, -}; +use ironclaw_host_api::{DenyRecord, GateRecord, Resolution}; -use super::content_digest::ContentDigest; -use super::host::{ - CapabilityApprovalResume, CapabilityAuthResume, CapabilityDenied, CapabilityDeniedReasonKind, - CapabilityFailure, CapabilityFailureKind, CapabilityOutcome, CapabilityProgress, - CapabilityResultMessage, CapabilityResumeToken, LoopProcessRef, ProcessHandleSummary, -}; -use super::model_observation::{ - CapabilityFailureDetail, CapabilityInputIssue, ModelVisibleToolObservation, - ToolObservationDetail, -}; -use crate::{LoopGateRef, LoopResultRef}; +use super::host::CapabilityOutcome; +use super::resolution; /// A [`Resolution`] plus the side records its opaque refs render from (§5.2.9). /// -/// `Resolution`'s control-plane arms carry only refs; the model-visible content -/// (pending-gate detail, denial reason) lives in the referenced record. A gate -/// channel yields a `gate_record`; a denial yields a `deny_record`; the -/// `Done`/`Suspended(Process)` channels carry their content inline and yield -/// neither. +/// Retained transitionally for callers of [`capability_outcome_to_resolution`]. +/// `bindings` is always empty; loop-ref recovery rides the channel `origin`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MappedResolution { pub resolution: Resolution, pub gate_record: Option, pub deny_record: Option, - /// Source→target associations for every freshly-minted host ref, so the - /// later wiring slice can persist the loop-ref↔uuid-ref correspondence at - /// the writer/store boundary instead of losing it in this pure function. pub bindings: RefBindings, } -/// The loop-side refs each freshly-minted host_api uuid ref replaces. -/// -/// The pure mapping cannot reconstruct a uuid from an opaque loop string, so it -/// mints fresh host refs — but minting without a binding would strand already- -/// stored loop state (the result writer stored output under the *loop* ref). -/// Each populated pair here says "the minted host ref on the right stands for -/// the loop ref on the left"; the consumer persists that association. A `None` -/// means the variant carried no loop-side ref for that slot (e.g. `Failed` -/// mints a `ResultRef` handle with no loop source, `Denied` has no loop deny -/// ref). +/// Retained transitionally; always empty (the flip preserves loop refs on the +/// channel `origin`, not this side-table). Deleted with `resolution_mapping`. #[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct RefBindings { - /// Loop result ref → the minted [`ResultRef`] (on `OutcomeRefs.result` or - /// `GateRecord::DependentRun.result`). - pub result: Option<(LoopResultRef, ResultRef)>, - /// Loop gate ref → the minted [`GateRef`] (on the `Blocked`/`Suspended` - /// channel and its `GateRecord`). - pub gate: Option<(LoopGateRef, GateRef)>, - /// Loop process ref → the minted [`ProcessRef`] (on - /// `Suspension::Process`). - pub process: Option<(LoopProcessRef, ProcessRef)>, -} - -impl MappedResolution { - /// A resolution that carries no side record (the `Done` and - /// `Suspended(Process)` channels). - fn bare(resolution: Resolution) -> Self { - Self { - resolution, - gate_record: None, - deny_record: None, - bindings: RefBindings::default(), - } - } - - /// A gate/suspension channel paired with the [`GateRecord`] its ref renders - /// from. - fn with_gate(resolution: Resolution, gate_record: GateRecord) -> Self { - Self { - resolution, - gate_record: Some(gate_record), - deny_record: None, - bindings: RefBindings::default(), - } - } - - /// A denial paired with the [`DenyRecord`] its ref renders from. - fn with_deny(resolution: Resolution, deny_record: DenyRecord) -> Self { - Self { - resolution, - gate_record: None, - deny_record: Some(deny_record), - bindings: RefBindings::default(), - } - } - - fn bind_result(mut self, loop_ref: LoopResultRef, minted: ResultRef) -> Self { - self.bindings.result = Some((loop_ref, minted)); - self - } - - fn bind_gate(mut self, loop_ref: LoopGateRef, minted: GateRef) -> Self { - self.bindings.gate = Some((loop_ref, minted)); - self - } - - fn bind_process(mut self, loop_ref: LoopProcessRef, minted: ProcessRef) -> Self { - self.bindings.process = Some((loop_ref, minted)); - self - } -} +pub struct RefBindings; /// Map one loop-facing [`CapabilityOutcome`] onto its host_api [`Resolution`] -/// channel plus any side record (§5.3 acceptance table). -/// -/// Pure and total: consumes the outcome, mints fresh uuid refs for host-side -/// handles, and never panics (see [`safe_summary_or_placeholder`] for the -/// summary-validation fallback). +/// channel plus any side record, delegating to the producer-facing constructors +/// in [`super::resolution`]. pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedResolution { + let bare = |resolution: Resolution| MappedResolution { + resolution, + gate_record: None, + deny_record: None, + bindings: RefBindings, + }; match outcome { - // Ran and succeeded. Loop-derived progress/terminate_hint/output_digest - // now cross onto the Outcome; a fresh ResultRef handle is minted, the - // loop result_ref is preserved on OutcomeRefs.origin AND bound so the - // stored output stays reachable. - CapabilityOutcome::Completed(message) => { - let (outcome, loop_result) = completed_outcome(message); - let minted = outcome.refs.result; - MappedResolution::bare(Resolution::Done(outcome)).bind_result(loop_result, minted) - } - // Ran and failed in a model-visible, correctable way. The recovery class - // (error_kind) AND the redacted structured diagnostic (the model-visible - // correction hint) ride the verdict. - CapabilityOutcome::Failed(failure) => { - MappedResolution::bare(Resolution::Done(failed_outcome(failure))) + CapabilityOutcome::Completed(message) => bare(resolution::completed( + message.result_ref, + message.safe_summary, + message.progress, + message.terminate_hint, + message.byte_len, + message.output_digest, + message.model_observation, + )), + CapabilityOutcome::Failed(failure) => bare(resolution::failed( + failure.error_kind, + failure.safe_summary, + failure.detail, + )), + CapabilityOutcome::SpawnedProcess(process) => { + bare(resolution::spawned_process(process.process_ref)) } - // Terminal policy denial — model-visible, not re-entrant. The model-visible - // reason + summary now ride the Denial channel too (a projection of the - // sibling DenyRecord), so the loop can render the denial without reading - // the host-persisted record (PR-B). + CapabilityOutcome::SpawnedChildRun { + child_run_id, + result_ref, + safe_summary, + byte_len, + model_observation, + } => bare(resolution::spawned_child_run( + child_run_id, + result_ref, + safe_summary, + byte_len, + model_observation, + )), CapabilityOutcome::Denied(denied) => { - let CapabilityDenied { - reason_kind, - safe_summary, - } = denied; - let reason = deny_reason_from_kind(&reason_kind); - let summary = safe_summary_or_placeholder(safe_summary); - MappedResolution::with_deny( - Resolution::Denied( - Denial::new(DenyRef::new()) - .with_reason_kind(reason) - .with_summary(summary.clone()), - ), - DenyRecord { reason, summary }, - ) + let denied = resolution::denied(denied.reason_kind, denied.safe_summary); + MappedResolution { + resolution: denied.resolution, + gate_record: None, + deny_record: Some(denied.deny_record), + bindings: RefBindings, + } } - // Re-entrant gate: needs human approval before it may run. The gate-render - // content (summary) rides the GateRecord; the resume token and the - // preserved loop gate ref ride the waypoint (never the model-visible - // record — §5.2.9). CapabilityOutcome::ApprovalRequired { gate_ref, safe_summary, approval_resume, - } => { - let minted = GateRef::new(); - let waypoint = gate_waypoint(minted, &gate_ref, approval_resume_token(approval_resume)); - MappedResolution::with_gate( - Resolution::Blocked(Blocked::Approval(waypoint)), - GateRecord::Approval { - summary: safe_summary_or_placeholder(safe_summary), - }, - ) - .bind_gate(gate_ref, minted) - } - // Re-entrant gate: needs a credential the caller has not supplied. The - // host-owned credential requirements ride the record (G3); the resume - // token and preserved loop gate ref ride the waypoint. + } => gated(resolution::approval_required( + gate_ref, + safe_summary, + approval_resume, + )), CapabilityOutcome::AuthRequired { gate_ref, credential_requirements, safe_summary, auth_resume, - } => { - // Mint the host GateRecord key DETERMINISTICALLY from the auth gate id - // encoded in the loop `gate:auth-{gate_id}` ref (§5.2.9 / §5.3 Stage 2 - // auth render-from-record), so the loop-host persist seam and the - // runner's blocked-exit read derive the same key. Approval already has - // this via `GateRef::for_approval_request`; this is its auth analogue. - // `credential_requirements` ride the record here (never the model-visible - // channel), and the runner re-reads them from the record to rebuild the - // blocked-exit `TurnRunRecord.credential_requirements` after the flip. - let minted = auth_gate_record_ref(&gate_ref); - let waypoint = gate_waypoint(minted, &gate_ref, auth_resume_token(auth_resume)); - MappedResolution::with_gate( - Resolution::Blocked(Blocked::Auth(waypoint)), - GateRecord::Auth { - summary: safe_summary_or_placeholder(safe_summary), - credential_requirements, - }, - ) - .bind_gate(gate_ref, minted) - } - // Re-entrant gate: needs resource budget currently unavailable. No resume - // token — a resource gate resumes against then-current budget (§5.3.3). + } => gated(resolution::auth_required( + gate_ref, + credential_requirements, + safe_summary, + auth_resume, + )), CapabilityOutcome::ResourceBlocked { gate_ref, safe_summary, - } => { - let minted = GateRef::new(); - let waypoint = gate_waypoint(minted, &gate_ref, None); - MappedResolution::with_gate( - Resolution::Blocked(Blocked::Resource(waypoint)), - GateRecord::Resource { - summary: safe_summary_or_placeholder(safe_summary), - }, - ) - .bind_gate(gate_ref, minted) - } - // Parked work: a spawned OS process the turn now waits on. Process - // suspensions track a ProcessRef, not a gate record; the loop process ref - // is preserved on the waypoint origin (the loop summary still has no host - // channel). - CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { process_ref, .. }) => { - let minted = ProcessRef::new(); - let waypoint = process_waypoint(minted, &process_ref); - MappedResolution::bare(Resolution::Suspended(Suspension::Process(waypoint))) - .bind_process(process_ref, minted) - } - // NON-suspending (the #6137 bug class): the executor appends the child - // result and continues. Maps to Done/ChildSpawned, carrying the child's - // RunId on the verdict. child_run_id (a TurnRunId) preserves identity via - // RunId::from_uuid; the string result_ref is replaced by a fresh ResultRef. - CapabilityOutcome::SpawnedChildRun { - child_run_id, + } => gated(resolution::resource_blocked(gate_ref, safe_summary)), + CapabilityOutcome::AwaitDependentRun { + gate_ref, result_ref, safe_summary, byte_len, model_observation, - } => { - let minted = ResultRef::new(); - let (preview, preview_meta) = result_preview_parts(model_observation, &result_ref); - MappedResolution::bare(Resolution::Done(Outcome { - refs: OutcomeRefs { - result: minted, - byte_len, - preview, - preview_meta, - origin: preserved_origin(result_ref.as_str()), - output_digest: None, - }, - verdict: ToolVerdict::ChildSpawned { - child_run: RunId::from_uuid(child_run_id.as_uuid()), - }, - summary: safe_summary_or_placeholder(safe_summary), - progress: ResultProgress::default(), - terminate_hint: TerminateHint::default(), - })) - .bind_result(result_ref, minted) - } - // Parked work: awaits a dependent child run. Gate-shaped, so the durable - // GateRecord holds the staged result handle + byte length (G2). The - // channel ALSO carries the staged result inline (DependentRunResult) so - // the loop observes the child's output on resume without reading host - // storage — the same dual pattern as PR-B: the record is the durable - // copy, the inline payload the loop-visible one. model_observation now - // rides the inline observation preview (was dropped entirely). Both the - // gate ref and the staged result ref are freshly minted and bound. - CapabilityOutcome::AwaitDependentRun { + } => gated(resolution::await_dependent_run( gate_ref, result_ref, safe_summary, byte_len, model_observation, - } => { - let minted_gate = GateRef::new(); - let minted_result = ResultRef::new(); - let waypoint = gate_waypoint(minted_gate, &gate_ref, None); - let mut staged = DependentRunResult::new( - byte_len, - safe_summary_or_placeholder(safe_summary.clone()), - ); - // The dependent-child observation channel stays a bounded [`SafeSummary`] - // caption (Stage 1b): a child suspension carries the summary caption, not - // the inline first-look content (that is the completed-`Outcome` preview). - if let Some(observation) = observation_summary_caption(model_observation) { - staged = staged.with_observation(observation); - } - if let Some(origin) = preserved_origin(result_ref.as_str()) { - staged = staged.with_origin(origin); - } - MappedResolution::with_gate( - Resolution::Suspended(Suspension::DependentRun { - waypoint, - result: staged, - }), - GateRecord::DependentRun { - summary: safe_summary_or_placeholder(safe_summary), - result: minted_result, - byte_len, - result_origin: preserved_origin(result_ref.as_str()), - }, - ) - .bind_gate(gate_ref, minted_gate) - .bind_result(result_ref, minted_result) - } - // Parked work: a client-executed external tool the host does not run. + )), CapabilityOutcome::ExternalToolPending { gate_ref, safe_summary, - } => { - let minted = GateRef::new(); - let waypoint = gate_waypoint(minted, &gate_ref, None); - MappedResolution::with_gate( - Resolution::Suspended(Suspension::ExternalTool(waypoint)), - GateRecord::ExternalTool { - summary: safe_summary_or_placeholder(safe_summary), - }, - ) - .bind_gate(gate_ref, minted) - } - } -} - -/// Build the `Done` payload for a `Completed` outcome (verdict `Success`), -/// returning the loop result ref alongside so the caller can bind it to the -/// minted [`ResultRef`]. -fn completed_outcome(message: CapabilityResultMessage) -> (Outcome, LoopResultRef) { - let CapabilityResultMessage { - result_ref, - safe_summary, - byte_len, - model_observation, - progress, - terminate_hint, - output_digest, - } = message; - let (preview, preview_meta) = result_preview_parts(model_observation, &result_ref); - let outcome = Outcome { - refs: OutcomeRefs { - result: ResultRef::new(), - byte_len, - preview, - preview_meta, - origin: preserved_origin(result_ref.as_str()), - output_digest: output_digest.map(output_digest_of), - }, - verdict: ToolVerdict::Success, - summary: safe_summary_or_placeholder(safe_summary), - progress: result_progress_of(progress), - terminate_hint: TerminateHint::from_bool(terminate_hint), - }; - (outcome, result_ref) -} - -/// Build the `Done` payload for a `Failed` outcome (verdict `RecoverableFailure`), -/// carrying the recovery classification AND the redacted structured diagnostic on -/// the verdict, so the model-visible correction hint crosses without the loop -/// reading host storage (PR-B). -fn failed_outcome(failure: CapabilityFailure) -> Outcome { - let CapabilityFailure { - error_kind, - safe_summary, - detail, - } = failure; - Outcome { - refs: OutcomeRefs { - // A recoverable failure stages no durable output beyond its summary; - // the ref is a minted handle the later store may leave unpopulated, - // and there is no originating loop result ref to preserve. - result: ResultRef::new(), - byte_len: 0, - preview: None, - preview_meta: ResultPreviewMeta::default(), - origin: None, - output_digest: None, - }, - verdict: match model_failure_diagnostic(detail) { - Some(diagnostic) => ToolVerdict::recoverable_failure_with_diagnostic( - failure_kind_of(error_kind), - diagnostic, - ), - None => ToolVerdict::recoverable_failure(failure_kind_of(error_kind)), - }, - summary: safe_summary_or_placeholder(safe_summary), - progress: ResultProgress::default(), - terminate_hint: TerminateHint::default(), - } -} - -/// Redact a loop-facing [`CapabilityFailureDetail`] into the host_api -/// [`ModelFailureDiagnostic`] carried on the verdict. -/// -/// The loop's `InvalidInput` schema issues cross with their structured -/// [`DispatchInputIssueCode`](ironclaw_host_api::DispatchInputIssueCode) and -/// every free-text field re-validated through the [`SafeSummary`] redaction -/// contract (a field that fails is dropped; an issue whose required `path` fails -/// is dropped whole), bounded to [`MAX_MODEL_INPUT_ISSUES`]. The loop's lenient -/// free-text `Diagnostic` (which permits paths) is redacted to a [`SafeSummary`]: -/// a path-shaped diagnostic degrades to the placeholder rather than carry a raw -/// host path across the charter. -fn model_failure_diagnostic( - detail: Option, -) -> Option { - match detail? { - CapabilityFailureDetail::InvalidInput { issues } => { - let issues = - ModelInputIssues::truncating(issues.into_iter().filter_map(model_input_issue)); - Some(ModelFailureDiagnostic::InvalidInput { issues }) - } - CapabilityFailureDetail::Diagnostic { text } => Some(ModelFailureDiagnostic::Diagnostic { - // The loop channel allows paths; the host_api boundary does not — a - // path-shaped diagnostic redacts to the placeholder (never raw). - text: SafeSummary::new(text).unwrap_or_else(|_| SafeSummary::placeholder()), - }), - } -} - -/// Redact one loop-facing [`CapabilityInputIssue`] into a host_api -/// [`ModelInputIssue`], routing every free-text field through [`SafeSummary`]. -/// Returns `None` when the required `path` fails the redaction contract (a -/// path-shaped or secret-shaped path — which a safe producer never emits — is -/// dropped rather than carried raw); optional fields that fail are individually -/// dropped. `.ok()` here converts a pure text-to-safe-text validation failure -/// into an absent field, never a swallowed I/O error. -fn model_input_issue(issue: CapabilityInputIssue) -> Option { - let CapabilityInputIssue { - path, - code, - expected, - received, - schema_path, - } = issue; - let mut model = ModelInputIssue::new(SafeSummary::new(path).ok()?, code); - if let Some(expected) = expected.and_then(|value| SafeSummary::new(value).ok()) { - model = model.with_expected(expected); - } - if let Some(received) = received.and_then(|value| SafeSummary::new(value).ok()) { - model = model.with_received(received); - } - if let Some(schema_path) = schema_path.and_then(|value| SafeSummary::new(value).ok()) { - model = model.with_schema_path(schema_path); - } - Some(model) -} - -/// The canonical host [`GateRef`] key for an auth gate's [`GateRecord`], derived -/// deterministically (name-based v5) from the auth gate id encoded in the loop -/// `gate:auth-{gate_id}` ref. Mirrors [`GateRef::for_approval_request`] so the -/// loop-host persist seam and the runner's blocked-exit render-from-record read -/// agree on the key (§5.2.9 / §5.3 Stage 2). A loop ref that is not a -/// `gate:auth-{gate_id}` (which the normal producer never emits) falls back to a -/// fresh handle — the record is still persisted, only not re-derivable, which is -/// no worse than the pre-flip random key. -fn auth_gate_record_ref(loop_gate: &LoopGateRef) -> GateRef { - loop_gate - .as_str() - .strip_prefix("gate:auth-") - .map(GateRef::for_auth_gate) - // silent-ok: pure string reconstruction; a loop ref without the - // `gate:auth-` prefix is never emitted for an auth gate. - .unwrap_or_default() -} - -/// A gate waypoint: the minted kernel handle plus the preserved originating loop -/// gate ref and (for approval/auth) the opaque resume token the loop echoes back. -fn gate_waypoint( - minted: GateRef, - loop_gate: &LoopGateRef, - resume: Option, -) -> GateWaypoint { - let mut waypoint = GateWaypoint::new(minted); - if let Some(origin) = preserved_origin(loop_gate.as_str()) { - waypoint = waypoint.with_origin(origin); - } - if let Some(resume) = resume { - waypoint = waypoint.with_resume(resume); - } - waypoint -} - -/// A process waypoint: the minted kernel handle plus the preserved originating -/// loop process ref. -fn process_waypoint(minted: ProcessRef, loop_process: &LoopProcessRef) -> ProcessWaypoint { - match preserved_origin(loop_process.as_str()) { - Some(origin) => ProcessWaypoint::new(minted).with_origin(origin), - None => ProcessWaypoint::new(minted), - } -} - -/// Preserve a loop ref as a redacted host_api [`LoopRef`] when it satisfies the -/// host redaction contract (bounded, control-free, no path delimiters). A loop -/// ref that fails — which a safe production ref never does — falls back to `None` -/// and stays reachable only through [`RefBindings`]; `.ok()` here converts a pure -/// text-to-safe-text validation failure into an absent origin, never a swallowed -/// I/O error. -fn preserved_origin(loop_ref: &str) -> Option { - LoopRef::new(loop_ref).ok() -} - -/// The opaque approval resume token, when the outcome carried one. -fn approval_resume_token(resume: Option) -> Option { - resume.and_then(|resume| resume_token_of(&resume.resume_token)) -} - -/// The opaque auth resume token, when the outcome carried one. -fn auth_resume_token(resume: Option) -> Option { - resume.and_then(|resume| resume_token_of(&resume.resume_token)) -} - -/// Convert a loop-facing [`CapabilityResumeToken`] to a host_api [`ResumeToken`]. -/// Both are bounded/control-free, so a valid loop token always crosses; `.ok()` -/// drops a token that fails the host bound rather than panic (the mapping is -/// total) — such a token, never produced by the loop's own validator, then -/// resumes through the retained binding. -fn resume_token_of(token: &CapabilityResumeToken) -> Option { - ResumeToken::new(token.as_str()).ok() -} - -/// Map the loop's [`ContentDigest`] onto host_api's [`OutputDigest`]; both wrap -/// the same truncated Blake3 `u64`. -fn output_digest_of(digest: ContentDigest) -> OutputDigest { - OutputDigest::new(digest.0) -} - -/// Map the loop's [`CapabilityProgress`] onto host_api's [`ResultProgress`]; the -/// variants correspond one-to-one. -fn result_progress_of(progress: CapabilityProgress) -> ResultProgress { - match progress { - CapabilityProgress::Unknown => ResultProgress::Unknown, - CapabilityProgress::MadeProgress => ResultProgress::MadeProgress, - CapabilityProgress::NoChange => ResultProgress::NoChange, - CapabilityProgress::Blocked => ResultProgress::Blocked, + } => gated(resolution::external_tool_pending(gate_ref, safe_summary)), } } -/// Map the loop's [`CapabilityFailureKind`] onto host_api's [`FailureKind`] by its -/// stable tag — the two vocabularies share the same closed set plus an open -/// `Unknown`, so every value crosses losslessly. -fn failure_kind_of(kind: CapabilityFailureKind) -> FailureKind { - FailureKind::from_tag(kind.as_str()) -} - -/// The #5838 first-look inline CONTENT preview and its continuation metadata from -/// a loop tool observation, when present. -/// -/// The inline content the model reads without a follow-up `result_read` lives on -/// the `ResultReference` detail's `preview` — NOT the generic `summary` caption -/// (routing content through `SafeSummary` dropped every delimiter-bearing/JSON -/// result and scrubbed `Secretary`, forcing a re-read amnesia loop). It is carried -/// as a [`ModelResultPreview`]: delimiters/newlines retained, credential-redacted -/// at a word boundary, up to 24 KiB. The paired [`ResultPreviewMeta`] carries the -/// TRUNCATED-preview continuation info (`result_read` / large results): the -/// referenced result ref, full byte size, next offset, and JSON-array element -/// count, so the model reads the full result. Detail kinds other than -/// `ResultReference` have no inline content. -/// -/// `own_result_ref` is this outcome's own loop result ref: the referenced ref is -/// carried only when it DIFFERS (a `result_read` presenting another result's ref); -/// otherwise the reconstruction uses the outcome's own ref, keeping the wire clean. -fn result_preview_parts( - observation: Option, - own_result_ref: &LoopResultRef, -) -> (Option, ResultPreviewMeta) { - let empty = (None, ResultPreviewMeta::default()); - let Some(observation) = observation else { - return empty; - }; - // Capture the observation's own model-visible summary before destructuring - // `detail`; it is DISTINCT from the outcome caption and must survive the - // collapse so the reconstructed observation keeps the producer's exact - // truncation/continuation hint (best-effort caption via `.ok()`). - let ModelVisibleToolObservation { - summary, detail, .. - } = observation; - let summary = SafeSummary::new(summary).ok(); - let ToolObservationDetail::ResultReference { - result_ref, - preview: Some(text), - total_bytes, - next_offset, - item_count, - .. - } = detail - else { - return empty; - }; - // `.ok()` intentionally degrades content that fails the credential redaction - // contract to an absent preview (a pure text-to-redacted-content conversion); - // the full output stays reachable through the result ref, and without inline - // content the continuation metadata is useless, so drop both. - let Some(preview) = ModelResultPreview::new(text).ok() else { - return empty; - }; - let referenced_result_ref = if result_ref == own_result_ref.as_str() { - None - } else { - LoopRef::new(result_ref).ok() - }; - ( - Some(preview), - ResultPreviewMeta { - referenced_result_ref, - total_bytes, - next_offset, - item_count, - summary, - }, - ) -} - -/// The observation's generic `summary` as a bounded [`SafeSummary`] caption — the -/// dependent-child observation channel (Stage 1b), which carries a caption rather -/// than the inline first-look content the completed-`Outcome` preview does. -/// -/// `.ok()` degrades a caption that fails the caption redaction contract to `None`; -/// a pure text-to-safe-text conversion, and the caption is best-effort. -fn observation_summary_caption( - observation: Option, -) -> Option { - observation.and_then(|observation| SafeSummary::new(observation.summary).ok()) -} - -/// Convert a loop-facing `safe_summary: String` to a host_api [`SafeSummary`]. -/// -/// The redaction rule is the same on both sides (#6236), so a value the loop -/// already redacted normally passes. If it somehow fails validation, fall back to -/// the infallible [`SafeSummary::placeholder`] rather than panic — this mapping is -/// total. -fn safe_summary_or_placeholder(raw: String) -> SafeSummary { - SafeSummary::new(raw).unwrap_or_else(|_| SafeSummary::placeholder()) -} - -/// Map the loop-side denial vocabulary onto host_api's [`DenyReason`]. -/// -/// The loop's [`CapabilityDeniedReasonKind`] is an evolving open set -/// (`EmptySurface` plus free-form `Unknown(..)` strings like `hook_denied`, -/// `model_view_denied`); host_api's [`DenyReason`] is a fixed closed enum whose -/// variants originate on the host authorize path, not the loop. There is no -/// faithful 1:1, so this is a best-effort match: a reason string that already -/// spells a `DenyReason` snake_case tag is honored, and everything else — every -/// loop-originated denial — buckets into the model-visible catch-all -/// [`DenyReason::PolicyDenied`]. -fn deny_reason_from_kind(kind: &CapabilityDeniedReasonKind) -> DenyReason { - use serde::{ - Deserialize, - de::{IntoDeserializer, value::StrDeserializer}, - }; - // Deserialize straight from the &str (no JSON Value/String allocation); - // DenyReason's snake_case serde tags are the match vocabulary. - let deserializer: StrDeserializer<'_, serde::de::value::Error> = - kind.as_str().into_deserializer(); - DenyReason::deserialize(deserializer).unwrap_or(DenyReason::PolicyDenied) -} - -#[cfg(test)] -mod tests { - use super::super::host::CapabilityInputRef; - use super::super::model_observation::ModelVisibleToolObservation; - use super::super::{ - CapabilityFailureDetail, CapabilityFailureKind, CapabilityInputIssue, CapabilityProgress, - LoopProcessRef, MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, - }; - use super::*; - use crate::{LoopGateRef, LoopResultRef, TurnRunId}; - use ironclaw_host_api::{ - ApprovalRequestId, CorrelationId, ExtensionId, RuntimeCredentialAccountProviderId, - RuntimeCredentialAccountSetup, RuntimeCredentialAuthRequirement, - }; - - fn result_ref() -> LoopResultRef { - LoopResultRef::new("result:child-1").unwrap() - } - - fn gate_ref() -> LoopGateRef { - LoopGateRef::new("gate:pending-1").unwrap() - } - - fn credential_requirement() -> RuntimeCredentialAuthRequirement { - RuntimeCredentialAuthRequirement { - provider: RuntimeCredentialAccountProviderId::new("github").unwrap(), - setup: RuntimeCredentialAccountSetup::ManualToken, - requester_extension: ExtensionId::new("github").unwrap(), - provider_scopes: vec!["repo".to_string()], - } - } - - fn completed(summary: &str) -> CapabilityOutcome { - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref(), - safe_summary: summary.to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 4096, - output_digest: None, - model_observation: None, - }) - } - - /// A model-visible `ResultReference` tool observation whose inline - /// `detail.preview` content is `content` — the field `observation_preview` - /// reduces to the [`ModelResultPreview`]. (The `summary` is a generic caption; - /// the model-visible CONTENT is on the detail preview, per #5838.) - fn observation(content: &str) -> ModelVisibleToolObservation { - use super::super::model_observation::{ - ObservationTrust, ToolObservationDetail, ToolObservationStatus, - }; - ModelVisibleToolObservation { - schema_version: MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, - status: ToolObservationStatus::Success, - summary: "tool completed".to_string(), - detail: ToolObservationDetail::ResultReference { - result_ref: "result:staged".to_string(), - byte_len: 10, - preview: Some(content.to_string()), - total_bytes: None, - next_offset: None, - item_count: None, - }, - artifacts: vec![], - recovery: None, - trust: ObservationTrust::UntrustedToolOutput, - } - } - - /// The §5.3 acceptance table is the definition of done. Every one of the ten - /// `CapabilityOutcome` variants maps to exactly one `Resolution` channel with - /// the correct suspension semantics and side record. This mirrors host_api's - /// `resolution_covers_the_full_acceptance_table` on the producer side. - #[test] - fn maps_the_full_acceptance_table() { - // (variant label, outcome, expected channel kind, is_suspension, - // expected gate_record kind, expected deny_record present) - struct Row { - label: &'static str, - outcome: CapabilityOutcome, - channel: &'static str, - suspends: bool, - gate_record: Option<&'static str>, - deny_record: bool, - // (result, gate, process) binding presence - bindings: (bool, bool, bool), - } - - let rows = vec![ - Row { - label: "Completed", - outcome: completed("read 3 files"), - channel: "done", - suspends: false, - gate_record: None, - deny_record: false, - bindings: (true, false, false), - }, - Row { - label: "Failed", - outcome: CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "tool input rejected".to_string(), - detail: None, - }), - channel: "done", - suspends: false, - gate_record: None, - deny_record: false, - bindings: (false, false, false), - }, - Row { - label: "Denied", - outcome: CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "denied by policy".to_string(), - }), - channel: "denied", - suspends: false, - gate_record: None, - deny_record: true, - bindings: (false, false, false), - }, - Row { - label: "ApprovalRequired", - outcome: CapabilityOutcome::ApprovalRequired { - gate_ref: gate_ref(), - safe_summary: "awaiting approval".to_string(), - approval_resume: None, - }, - channel: "blocked", - suspends: false, - gate_record: Some("approval"), - deny_record: false, - bindings: (false, true, false), - }, - Row { - label: "AuthRequired", - outcome: CapabilityOutcome::AuthRequired { - gate_ref: gate_ref(), - credential_requirements: vec![credential_requirement()], - safe_summary: "awaiting credential".to_string(), - auth_resume: None, - }, - channel: "blocked", - suspends: false, - gate_record: Some("auth"), - deny_record: false, - bindings: (false, true, false), - }, - Row { - label: "ResourceBlocked", - outcome: CapabilityOutcome::ResourceBlocked { - gate_ref: gate_ref(), - safe_summary: "awaiting budget".to_string(), - }, - channel: "blocked", - suspends: false, - gate_record: Some("resource"), - deny_record: false, - bindings: (false, true, false), - }, - Row { - label: "SpawnedProcess", - outcome: CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { - process_ref: LoopProcessRef::new("process:pid-1").unwrap(), - safe_summary: "spawned build".to_string(), - }), - channel: "suspended", - suspends: true, - gate_record: None, - deny_record: false, - bindings: (false, false, true), - }, - Row { - label: "SpawnedChildRun", - outcome: CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref: result_ref(), - safe_summary: "spawned child run".to_string(), - byte_len: 128, - model_observation: None, - }, - // NON-suspending — the #6137 bug class. - channel: "done", - suspends: false, - gate_record: None, - deny_record: false, - bindings: (true, false, false), - }, - Row { - label: "AwaitDependentRun", - outcome: CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "awaiting dependent run".to_string(), - byte_len: 256, - model_observation: None, - }, - channel: "suspended", - suspends: true, - gate_record: Some("dependent_run"), - deny_record: false, - bindings: (true, true, false), - }, - Row { - label: "ExternalToolPending", - outcome: CapabilityOutcome::ExternalToolPending { - gate_ref: gate_ref(), - safe_summary: "awaiting external tool".to_string(), - }, - channel: "suspended", - suspends: true, - gate_record: Some("external_tool"), - deny_record: false, - bindings: (false, true, false), - }, - ]; - - assert_eq!(rows.len(), 10, "all ten CapabilityOutcome variants covered"); - - for row in rows { - let mapped = capability_outcome_to_resolution(row.outcome); - - assert_eq!( - mapped.resolution.kind(), - row.channel, - "{}: channel", - row.label - ); - // The critical #6137 assertion: suspension semantics come from the - // host_api Resolution, NOT the loop enum's is_suspension(). - assert_eq!( - mapped.resolution.is_suspension(), - row.suspends, - "{}: is_suspension", - row.label - ); - assert_eq!( - mapped.gate_record.as_ref().map(GateRecord::kind), - row.gate_record, - "{}: gate_record kind", - row.label - ); - assert_eq!( - mapped.deny_record.is_some(), - row.deny_record, - "{}: deny_record present", - row.label - ); - assert_eq!( - ( - mapped.bindings.result.is_some(), - mapped.bindings.gate.is_some(), - mapped.bindings.process.is_some(), - ), - row.bindings, - "{}: ref bindings presence", - row.label - ); - // A gate/deny record exists iff the channel renders from one; the two - // record slots are mutually exclusive. - assert!( - !(mapped.gate_record.is_some() && mapped.deny_record.is_some()), - "{}: at most one side record", - row.label - ); - } - } - - /// The suspension split is the bug class #6137 pins: Approval/Auth/Resource - /// are re-entrant gates (`Blocked`, NOT a suspension), Process/DependentRun/ - /// ExternalTool are parked work (`Suspended`), and SpawnedChildRun completes - /// (`Done`, NOT a suspension). This is deliberately DIFFERENT from the loop - /// enum's own `is_suspension()`, which lumps the re-entrant gates in with - /// parked work. - #[test] - fn suspension_split_matches_host_api_semantics() { - let blocked_not_suspended = [ - CapabilityOutcome::ApprovalRequired { - gate_ref: gate_ref(), - safe_summary: "a".to_string(), - approval_resume: None, - }, - CapabilityOutcome::AuthRequired { - gate_ref: gate_ref(), - credential_requirements: vec![], - safe_summary: "a".to_string(), - auth_resume: None, - }, - CapabilityOutcome::ResourceBlocked { - gate_ref: gate_ref(), - safe_summary: "a".to_string(), - }, - ]; - for outcome in blocked_not_suspended { - let mapped = capability_outcome_to_resolution(outcome); - assert!(mapped.resolution.is_reentrant_gate()); - assert!( - !mapped.resolution.is_suspension(), - "a re-entrant gate must NOT be a host_api suspension" - ); - } - - let suspended = [ - CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { - process_ref: LoopProcessRef::new("process:pid-1").unwrap(), - safe_summary: "a".to_string(), - }), - CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "a".to_string(), - byte_len: 1, - model_observation: None, - }, - CapabilityOutcome::ExternalToolPending { - gate_ref: gate_ref(), - safe_summary: "a".to_string(), - }, - ]; - for outcome in suspended { - assert!( - capability_outcome_to_resolution(outcome) - .resolution - .is_suspension(), - "parked work must be a host_api suspension" - ); - } - - // The non-suspending child spawn — the one that has bitten before. - let child = CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref: result_ref(), - safe_summary: "a".to_string(), - byte_len: 1, - model_observation: None, - }; - assert!( - !capability_outcome_to_resolution(child) - .resolution - .is_suspension() - ); - } - - #[test] - fn completed_carries_success_verdict_and_minted_result_ref() { - let mapped = capability_outcome_to_resolution(completed("staged output")); - match mapped.resolution { - Resolution::Done(outcome) => { - assert_eq!(outcome.verdict, ToolVerdict::Success); - assert!(outcome.verdict.is_success()); - assert_eq!(outcome.refs.byte_len, 4096); - assert_eq!(outcome.summary.as_str(), "staged output"); - assert_eq!(outcome.verdict.child_run(), None); - } - other => panic!("expected Done, got {other:?}"), - } - } - - /// Stage-1 non-lossy: a `Completed` outcome's loop-derived G4 signals - /// (progress, terminate_hint, output_digest) and its originating loop result - /// ref now survive the mapping into `Resolution::Done` instead of being - /// dropped. - #[test] - fn completed_carries_progress_terminate_hint_digest_and_origin() { - let digest = - ContentDigest::from_json_value(&serde_json::json!({"k": "v"})).expect("digest"); - let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref(), - safe_summary: "did work".to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 4096, - output_digest: Some(digest), - model_observation: None, - }); - let mapped = capability_outcome_to_resolution(outcome); - match mapped.resolution { - Resolution::Done(done) => { - assert_eq!(done.progress, ResultProgress::MadeProgress); - assert!(done.terminate_hint.should_terminate()); - assert_eq!( - done.refs.output_digest.map(OutputDigest::value), - Some(digest.0), - "output_digest must survive the mapping (was G4-dropped)" - ); - assert_eq!( - done.refs.origin.as_ref().map(LoopRef::as_str), - Some(result_ref().as_str()), - "the originating loop result ref must be preserved on OutcomeRefs.origin" - ); - } - other => panic!("expected Done, got {other:?}"), - } - } - - /// Stage-1 non-lossy: a `Failed` outcome's recovery classification - /// (error_kind, the "G1" field) now rides `ToolVerdict::RecoverableFailure` - /// instead of being dropped. - #[test] - fn failed_carries_its_error_kind_on_the_verdict() { - for (loop_kind, expected) in [ - (CapabilityFailureKind::Network, FailureKind::Network), - ( - CapabilityFailureKind::InvalidInput, - FailureKind::InvalidInput, - ), - ( - CapabilityFailureKind::unknown("quota_exceeded").unwrap(), - FailureKind::unknown("quota_exceeded").unwrap(), - ), - ] { - let mapped = - capability_outcome_to_resolution(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: loop_kind, - safe_summary: "tool failed".to_string(), - detail: None, - })); - match mapped.resolution { - Resolution::Done(done) => { - assert_eq!( - done.verdict, - ToolVerdict::recoverable_failure(expected.clone()), - "the recovery class must ride the verdict (was G1-dropped)" - ); - } - other => panic!("expected Done, got {other:?}"), - } - } - } - - /// PR-B: a `Failed` outcome's structured `InvalidInput` diagnostic - /// round-trips its schema issues (path, code, expected/received) through the - /// mapping onto `ToolVerdict::RecoverableFailure.diagnostic`, so the model can - /// still correct a bad tool call after the loop reads `Resolution` instead of - /// `CapabilityOutcome`. - #[test] - fn failed_invalid_input_diagnostic_round_trips_structured_issues() { - use ironclaw_host_api::{DispatchInputIssueCode, ModelFailureDiagnostic}; - - let outcome = CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "tool input rejected".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { - issues: vec![CapabilityInputIssue { - path: "schedule.kind".to_string(), - code: DispatchInputIssueCode::TypeMismatch, - expected: Some("integer".to_string()), - received: Some("string".to_string()), - schema_path: Some("properties.schedule".to_string()), - }], - }), - }); - let mapped = capability_outcome_to_resolution(outcome); - match mapped.resolution { - Resolution::Done(done) => match done.verdict.diagnostic() { - Some(ModelFailureDiagnostic::InvalidInput { issues }) => { - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].code, DispatchInputIssueCode::TypeMismatch); - assert_eq!(issues[0].path.as_str(), "schedule.kind"); - assert_eq!( - issues[0].expected.as_ref().map(SafeSummary::as_str), - Some("integer") - ); - assert_eq!( - issues[0].received.as_ref().map(SafeSummary::as_str), - Some("string") - ); - } - other => panic!("expected InvalidInput diagnostic, got {other:?}"), - }, - other => panic!("expected Done, got {other:?}"), - } - } - - /// PR-B: a `Failed` outcome's free-text `Diagnostic` detail rides the verdict - /// as a redacted `SafeSummary`. - #[test] - fn failed_free_text_diagnostic_round_trips() { - use ironclaw_host_api::ModelFailureDiagnostic; - - let outcome = CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::Backend, - safe_summary: "tool failed".to_string(), - detail: Some(CapabilityFailureDetail::Diagnostic { - text: "backend returned an error".to_string(), - }), - }); - match capability_outcome_to_resolution(outcome).resolution { - Resolution::Done(done) => match done.verdict.diagnostic() { - Some(ModelFailureDiagnostic::Diagnostic { text }) => { - assert_eq!(text.as_str(), "backend returned an error"); - } - other => panic!("expected Diagnostic, got {other:?}"), - }, - other => panic!("expected Done, got {other:?}"), - } - } - - /// PR-B redaction proof: a free-text diagnostic carrying a host path (which - /// the loop's lenient diagnostic channel allows) is REDACTED at the host_api - /// boundary — the raw path never appears in the `Resolution` output; it - /// degrades to the redaction-safe placeholder. Likewise a secret-shaped - /// InvalidInput field is dropped rather than carried raw. - #[test] - fn failed_diagnostic_redacts_path_and_secret_shaped_content() { - use ironclaw_host_api::{DispatchInputIssueCode, ModelFailureDiagnostic}; - - // Free-text with a raw path: redacted to the placeholder, path gone. - let outcome = CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::Backend, - safe_summary: "tool failed".to_string(), - detail: Some(CapabilityFailureDetail::Diagnostic { - text: "failed reading /etc/passwd".to_string(), - }), - }); - match capability_outcome_to_resolution(outcome).resolution { - Resolution::Done(done) => match done.verdict.diagnostic() { - Some(ModelFailureDiagnostic::Diagnostic { text }) => { - assert_eq!(text, &SafeSummary::placeholder()); - assert!( - !text.as_str().contains("/etc/passwd"), - "the raw host path must not cross the host_api boundary" - ); - } - other => panic!("expected Diagnostic, got {other:?}"), - }, - other => panic!("expected Done, got {other:?}"), - } - - // A secret-shaped `received` value is dropped (not carried raw); the - // path-shaped issue with a secret-shaped field is filtered field-wise. - let outcome = CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "tool input rejected".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { - issues: vec![CapabilityInputIssue { - path: "token".to_string(), - code: DispatchInputIssueCode::InvalidValue, - expected: Some("opaque string".to_string()), - received: Some("sk-ant-abc123def456".to_string()), - schema_path: None, - }], - }), - }); - match capability_outcome_to_resolution(outcome).resolution { - Resolution::Done(done) => match done.verdict.diagnostic() { - Some(ModelFailureDiagnostic::InvalidInput { issues }) => { - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].path.as_str(), "token"); - assert_eq!( - issues[0].received, None, - "a secret-shaped issue field must be dropped, never carried raw" - ); - assert_eq!( - issues[0].expected.as_ref().map(SafeSummary::as_str), - Some("opaque string") - ); - } - other => panic!("expected InvalidInput, got {other:?}"), - }, - other => panic!("expected Done, got {other:?}"), - } - } - - /// PR-B: a `Denied` outcome carries its model-visible `reason_kind` + redacted - /// `summary` ON THE CHANNEL (not only in the host-persisted `DenyRecord`), so - /// the loop can render the denial without reading host storage. A path-shaped - /// summary is redacted to the placeholder on the channel too. - #[test] - fn denied_channel_carries_reason_kind_and_redacted_summary() { - let mapped = - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown("network_denied").unwrap(), - safe_summary: "blocked egress".to_string(), - })); - match &mapped.resolution { - Resolution::Denied(denial) => { - assert_eq!(denial.reason_kind, Some(DenyReason::NetworkDenied)); - assert_eq!( - denial.summary.as_ref().map(SafeSummary::as_str), - Some("blocked egress") - ); - // The channel content mirrors the persisted record. - let record = mapped.deny_record.as_ref().expect("deny record"); - assert_eq!(denial.reason_kind, Some(record.reason)); - assert_eq!(denial.summary.as_ref(), Some(&record.summary)); - } - other => panic!("expected Denied, got {other:?}"), - } - - // Redaction proof: a path-shaped denial summary degrades to placeholder - // on the channel, never carrying the raw path. - let mapped = - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "denied reading /secret/path".to_string(), - })); - match mapped.resolution { - Resolution::Denied(denial) => { - assert_eq!(denial.reason_kind, Some(DenyReason::PolicyDenied)); - assert_eq!(denial.summary, Some(SafeSummary::placeholder())); - } - other => panic!("expected Denied, got {other:?}"), - } - } - - /// Stage-1 non-lossy: an approval gate carries its resume token and preserved - /// loop gate ref (was G1-dropped), and an auth gate likewise. - #[test] - fn approval_and_auth_gates_carry_resume_token_and_preserved_origin() { - let approval_resume = CapabilityApprovalResume { - approval_request_id: ApprovalRequestId::new(), - resume_token: CapabilityResumeToken::new("approval-resume-1").unwrap(), - correlation_id: CorrelationId::new(), - input_ref: CapabilityInputRef::new("input:x").unwrap(), - }; - let mapped = capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { - gate_ref: gate_ref(), - safe_summary: "awaiting approval".to_string(), - approval_resume: Some(approval_resume), - }); - match &mapped.resolution { - Resolution::Blocked(blocked @ Blocked::Approval(_)) => { - assert_eq!( - blocked.resume_token().map(ResumeToken::as_str), - Some("approval-resume-1"), - "the approval resume token must cross" - ); - assert_eq!( - blocked.origin().map(LoopRef::as_str), - Some(gate_ref().as_str()), - "the originating loop gate ref must be preserved" - ); - } - other => panic!("expected Blocked::Approval, got {other:?}"), - } - - let auth_resume = CapabilityAuthResume { - resume_token: CapabilityResumeToken::new("auth-resume-1").unwrap(), - prior_approval: None, - }; - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AuthRequired { - gate_ref: gate_ref(), - credential_requirements: vec![], - safe_summary: "awaiting credential".to_string(), - auth_resume: Some(auth_resume), - }); - match &mapped.resolution { - Resolution::Blocked(blocked @ Blocked::Auth(_)) => { - assert_eq!( - blocked.resume_token().map(ResumeToken::as_str), - Some("auth-resume-1") - ); - assert_eq!( - blocked.origin().map(LoopRef::as_str), - Some(gate_ref().as_str()) - ); - } - other => panic!("expected Blocked::Auth, got {other:?}"), - } - } - - /// Stage-1 non-lossy: a spawned-process suspension preserves its loop process - /// ref on the channel (not only in the binding side-table). - #[test] - fn spawned_process_preserves_the_loop_process_ref_on_the_channel() { - let mapped = capability_outcome_to_resolution(CapabilityOutcome::SpawnedProcess( - ProcessHandleSummary { - process_ref: LoopProcessRef::new("process:pid-7").unwrap(), - safe_summary: "spawned".to_string(), - }, - )); - match &mapped.resolution { - Resolution::Suspended(suspension @ Suspension::Process(_)) => { - assert_eq!( - suspension.origin().map(LoopRef::as_str), - Some("process:pid-7") - ); - } - other => panic!("expected Suspended(Process), got {other:?}"), - } - } - - #[test] - fn child_run_identity_is_preserved_on_the_verdict() { - let child_run_id = TurnRunId::new(); - let mapped = capability_outcome_to_resolution(CapabilityOutcome::SpawnedChildRun { - child_run_id, - result_ref: result_ref(), - safe_summary: "spawned".to_string(), - byte_len: 64, - model_observation: None, - }); - match mapped.resolution { - Resolution::Done(outcome) => { - // TurnRunId → RunId preserves the underlying uuid identity. - assert_eq!( - outcome.verdict.child_run().map(|run| run.as_uuid()), - Some(child_run_id.as_uuid()) - ); - assert_eq!(outcome.refs.byte_len, 64); - } - other => panic!("expected Done, got {other:?}"), - } - } - - #[test] - fn auth_gate_record_carries_credential_requirements() { - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AuthRequired { - gate_ref: gate_ref(), - credential_requirements: vec![credential_requirement()], - safe_summary: "awaiting credential".to_string(), - auth_resume: None, - }); - assert!( - matches!(mapped.resolution, Resolution::Blocked(Blocked::Auth(_))), - "expected Blocked::Auth, got {:?}", - mapped.resolution - ); - match mapped.gate_record { - Some(GateRecord::Auth { - credential_requirements, - .. - }) => { - assert_eq!(credential_requirements, vec![credential_requirement()]); - } - other => panic!("expected GateRecord::Auth, got {other:?}"), - } - } - - #[test] - fn dependent_run_record_carries_staged_result_and_byte_len() { - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "awaiting dependent".to_string(), - byte_len: 2048, - model_observation: None, - }); - match &mapped.gate_record { - Some(GateRecord::DependentRun { - byte_len, - summary, - result_origin, - .. - }) => { - assert_eq!(*byte_len, 2048); - assert_eq!(summary.as_str(), "awaiting dependent"); - // Stage-1 non-lossy: the staged result's originating loop ref - // is preserved ON THE DURABLE RECORD — the minted ResultRef is - // a fresh uuid, and the transient RefBindings side-table is not - // persisted, so without this the child output the loop staged - // under its own ref would be unreachable from the record a - // later resume turn renders from. - assert_eq!( - result_origin.as_ref().map(LoopRef::as_str), - Some(result_ref().as_str()), - "the staged result's loop origin must ride the durable record" - ); - } - other => panic!("expected GateRecord::DependentRun, got {other:?}"), - } - // Stage-1b: the SAME staged content also rides the channel inline, so the - // loop observes it on resume without reading the durable record above. - match &mapped.resolution { - Resolution::Suspended(suspension @ Suspension::DependentRun { .. }) => { - let staged = suspension.dependent_result().expect("inline staged result"); - assert_eq!(staged.byte_len, 2048); - assert_eq!(staged.summary.as_str(), "awaiting dependent"); - assert_eq!( - staged.origin.as_ref().map(LoopRef::as_str), - Some(result_ref().as_str()), - "the staged result's loop origin must also ride the inline channel" - ); - } - other => panic!("expected Suspended(DependentRun), got {other:?}"), - } - } - - /// Stage-1b non-lossy: `AwaitDependentRun`'s `model_observation` now rides the - /// inline [`DependentRunResult`] observation preview instead of being dropped - /// entirely — and it is redacted (a secret/path-shaped observation degrades to - /// absent, and a leaked summary degrades to the placeholder), so only plain - /// redacted host_api vocabulary crosses. - #[test] - fn dependent_run_carries_model_observation_inline_and_redacts() { - // The inline staged result reached through the channel accessor. - fn staged_of(mapped: &MappedResolution) -> DependentRunResult { - match &mapped.resolution { - Resolution::Suspended(suspension @ Suspension::DependentRun { .. }) => suspension - .dependent_result() - .expect("inline staged result") - .clone(), - other => panic!("expected Suspended(DependentRun), got {other:?}"), - } - } - - // The dependent-child observation channel is the summary CAPTION (Stage - // 1b), so this test drives the observation `summary` (not its detail - // preview, which is the completed-`Outcome` content channel). - let observation_with_summary = |summary: &str| { - let mut o = observation("child content"); - o.summary = summary.to_string(); - o - }; - - // Safe observation + summary → both cross verbatim onto the inline payload. - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "child produced 4 rows".to_string(), - byte_len: 512, - model_observation: Some(observation_with_summary("child preview: 4 rows")), - }); - let staged = staged_of(&mapped); - assert_eq!( - staged.observation.as_ref().map(SafeSummary::as_str), - Some("child preview: 4 rows"), - "model_observation must ride the inline observation (was dropped entirely)" - ); - assert_eq!(staged.summary.as_str(), "child produced 4 rows"); - - // Hostile observation + summary → the observation drops to None and the - // summary degrades to the placeholder; no raw secret/path crosses. - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "leaked path /etc/passwd".to_string(), - byte_len: 512, - model_observation: Some(observation_with_summary("api key: sk-ant-leak")), - }); - let staged = staged_of(&mapped); - assert_eq!( - staged.observation, None, - "a secret-shaped observation must be dropped, never carried raw" - ); - assert_eq!( - staged.summary, - SafeSummary::placeholder(), - "a path-shaped summary must degrade to the placeholder" - ); - } - - #[test] - fn deny_record_reason_maps_best_effort_with_policy_fallback() { - // A loop-originated open-set reason (EmptySurface / hook_denied) has no - // faithful DenyReason, so it buckets into the model-visible catch-all. - for reason_kind in [ - CapabilityDeniedReasonKind::EmptySurface, - CapabilityDeniedReasonKind::unknown("hook_denied").unwrap(), - ] { - let mapped = - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind, - safe_summary: "denied".to_string(), - })); - match mapped.deny_record { - Some(DenyRecord { reason, .. }) => { - assert_eq!(reason, DenyReason::PolicyDenied); - } - other => panic!("expected a DenyRecord, got {other:?}"), - } - } - - // A reason string that already spells a DenyReason tag is honored. - let mapped = - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown("network_denied").unwrap(), - safe_summary: "blocked egress".to_string(), - })); - match mapped.deny_record { - Some(DenyRecord { reason, .. }) => assert_eq!(reason, DenyReason::NetworkDenied), - other => panic!("expected a DenyRecord, got {other:?}"), - } - } - - /// The bindings are not merely present — each pairs the loop-side source - /// ref with EXACTLY the uuid ref minted into the resolution/record, so a - /// consumer persisting the association can make already-stored loop state - /// reachable through the host ref. - #[test] - fn bindings_pair_loop_refs_with_the_minted_host_refs() { - // Dependent run: both a gate binding and a staged-result binding. - let mapped = capability_outcome_to_resolution(CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref(), - result_ref: result_ref(), - safe_summary: "awaiting dependent".to_string(), - byte_len: 9, - model_observation: None, - }); - let (loop_gate, minted_gate) = mapped.bindings.gate.clone().expect("gate binding"); - let (loop_result, minted_result) = mapped.bindings.result.clone().expect("result binding"); - assert_eq!(loop_gate, gate_ref()); - assert_eq!(loop_result, result_ref()); - match (&mapped.resolution, &mapped.gate_record) { - ( - Resolution::Suspended(Suspension::DependentRun { waypoint, .. }), - Some(GateRecord::DependentRun { result, .. }), - ) => { - assert_eq!(waypoint.gate, minted_gate, "gate binding matches channel"); - assert_eq!(*result, minted_result, "result binding matches record"); - } - other => panic!("expected DependentRun channel + record, got {other:?}"), - } - - // Completed: the result binding matches OutcomeRefs.result. - let mapped = capability_outcome_to_resolution(completed("ok")); - let (loop_result, minted_result) = mapped.bindings.result.clone().expect("result binding"); - assert_eq!(loop_result, result_ref()); - match &mapped.resolution { - Resolution::Done(outcome) => assert_eq!(outcome.refs.result, minted_result), - other => panic!("expected Done, got {other:?}"), - } - - // Spawned process: the process binding matches the suspension ref. - let mapped = capability_outcome_to_resolution(CapabilityOutcome::SpawnedProcess( - ProcessHandleSummary { - process_ref: LoopProcessRef::new("process:pid-1").unwrap(), - safe_summary: "spawned".to_string(), - }, - )); - let (loop_process, minted_process) = mapped.bindings.process.clone().expect("binding"); - assert_eq!(loop_process, LoopProcessRef::new("process:pid-1").unwrap()); - match &mapped.resolution { - Resolution::Suspended(Suspension::Process(channel_process)) => { - assert_eq!(channel_process.process, minted_process); - } - other => panic!("expected Suspended(Process), got {other:?}"), - } - } - - #[test] - fn an_unsafe_loop_summary_falls_back_to_the_placeholder_never_panics() { - // A summary that violates the redaction contract (a raw path delimiter) - // must map to the safe placeholder rather than panic. - let mapped = capability_outcome_to_resolution(CapabilityOutcome::ResourceBlocked { - gate_ref: gate_ref(), - safe_summary: "leaked path /etc/passwd".to_string(), - }); - match mapped.gate_record { - Some(GateRecord::Resource { summary }) => { - assert_eq!(summary, SafeSummary::placeholder()); - } - other => panic!("expected GateRecord::Resource, got {other:?}"), - } - } - - #[test] - fn observation_preview_carries_delimiter_content_and_drops_credentials() { - // The #5838 first-look preview is CONTENT on the ResultReference detail: - // delimiters/JSON and the ordinary word "Secretary" must survive (the - // #6129 substring-`secret` bug is fixed), while a genuine credential is - // dropped to `None` (the model reads the full output via the ref). - let refs_preview = |content: &str| { - let mapped = capability_outcome_to_resolution(CapabilityOutcome::Completed( - CapabilityResultMessage { - result_ref: result_ref(), - safe_summary: "ok".to_string(), - progress: CapabilityProgress::Unknown, - terminate_hint: false, - byte_len: 10, - output_digest: None, - model_observation: Some(observation(content)), - }, - )); - match mapped.resolution { - Resolution::Done(outcome) => outcome - .refs - .preview - .as_ref() - .map(|p| p.as_str().to_string()), - other => panic!("expected Done, got {other:?}"), - } - }; - - // Structured content with delimiters + "Secretary" is retained verbatim. - let content = "{\"office\": \"Secretary of the Treasury\", \"rows\": [1, 2, 3]}"; - assert_eq!(refs_preview(content).as_deref(), Some(content)); - // A genuine credential in the content drops the inline preview to None. - assert_eq!(refs_preview("token sk-ant-abc123def456").as_deref(), None); +fn gated(gated: resolution::GatedResolution) -> MappedResolution { + MappedResolution { + resolution: gated.resolution, + gate_record: gated.gate_record, + deny_record: None, + bindings: RefBindings, } } From 08c33d4f40969604ada63b61de62c029088732f7 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 21:34:20 +0000 Subject: [PATCH 03/12] =?UTF-8?q?refactor(reborn):=20loop=5Fhost=20produce?= =?UTF-8?q?rs=20emit=20Resolution=20directly=20(=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit capability_port.rs: invoke_capability_dispatch now returns GatedResolution; the seam persists the durable GateRecord from .gate_record (GateRef::for_auth_gate key + replay-payload persistence intact) and returns .resolution — deleting the capability_outcome_to_resolution re-map. runtime_outcome_to_loop and the synthetic/inline dispatch arms emit Resolution/GatedResolution via the new constructors; the runtime failure classifier returns a private LoopFailureClass (Failed | Denied) so the per-tool display preview still stages from the raw fields. lib.rs empty-surface denial, subagent_spawn_port (spawn gates + batch coalescing keyed on the DependentRun channel origin), and capability_surface_filter denials all emit Resolution directly. 378 loop_host tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironclaw_loop_host/src/capability_port.rs | 498 +++++++++--------- .../src/capability_surface_filter.rs | 27 +- crates/ironclaw_loop_host/src/lib.rs | 14 +- .../src/subagent_spawn_port.rs | 84 +-- .../src/subagent_spawn_port/tests.rs | 25 +- crates/ironclaw_turns/src/run_profile/mod.rs | 8 +- 6 files changed, 346 insertions(+), 310 deletions(-) diff --git a/crates/ironclaw_loop_host/src/capability_port.rs b/crates/ironclaw_loop_host/src/capability_port.rs index f8361d49e3..0ed38d08bb 100644 --- a/crates/ironclaw_loop_host/src/capability_port.rs +++ b/crates/ironclaw_loop_host/src/capability_port.rs @@ -24,16 +24,16 @@ use ironclaw_turns::{ CapabilityActivityId, LoopGateRef, LoopResultRef, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityApprovalResume, CapabilityAuthResume, - CapabilityBatchInvocation, CapabilityDenied, CapabilityDeniedReasonKind, - CapabilityDescriptorView, CapabilityFailure, CapabilityFailureDetail, - CapabilityFailureKind, CapabilityInputIssue, CapabilityInputRef, CapabilityInvocation, - CapabilityOutcome, CapabilityResultMessage, CapabilityResumeToken, ConcurrencyHint, - ContentDigest, LoopCapabilityPort, LoopHostMilestone, LoopHostMilestoneKind, - LoopHostMilestoneSink, LoopProcessRef, LoopRunContext, LoopSafeSummary, MappedResolution, - ModelVisibleToolObservation, ProcessHandleSummary, ProviderToolCall, + CapabilityBatchInvocation, CapabilityDeniedReasonKind, CapabilityDescriptorView, + CapabilityFailureDetail, CapabilityFailureKind, CapabilityInputIssue, CapabilityInputRef, + CapabilityInvocation, CapabilityResumeToken, ConcurrencyHint, ContentDigest, + LoopCapabilityPort, LoopHostMilestone, LoopHostMilestoneKind, LoopHostMilestoneSink, + LoopProcessRef, LoopRunContext, LoopSafeSummary, ModelVisibleToolObservation, + ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolCallReplay, ProviderToolDefinition, RegisterProviderToolCallRequest, VisibleCapabilityRequest, VisibleCapabilitySurface, - capability_outcome_to_resolution, sanitize_model_visible_text, + resolution::{self, GatedResolution}, + sanitize_model_visible_text, }, }; use serde_json::Value; @@ -313,8 +313,11 @@ const GENERIC_CAPABILITY_FAILURE_SUMMARIES: [&str; 2] = [ /// /// Returns `None` when neither is available, so the projection keeps its /// existing `tool failed: ` fallback. -fn capability_failure_display_summary(failure: &CapabilityFailure) -> Option { - if let Some(CapabilityFailureDetail::InvalidInput { issues }) = failure.detail.as_ref() +fn failure_display_summary( + safe_summary: &str, + detail: &Option, +) -> Option { + if let Some(CapabilityFailureDetail::InvalidInput { issues }) = detail.as_ref() && !issues.is_empty() { let rendered = issues @@ -339,7 +342,7 @@ fn capability_failure_display_summary(failure: &CapabilityFailure) -> Option, + result: Result, milestone: LoopHostMilestoneKind, }, LoopCompleted { invocation_id: InvocationId, - result: Result, + result: Result, }, } @@ -919,10 +922,10 @@ enum DispatchReservation { }, TerminalMilestonePending { invocation_id: InvocationId, - result: Result, + result: Result, milestone: LoopHostMilestoneKind, }, - LoopCompleted(Result), + LoopCompleted(Result), } /// RAII guard for an `InFlight` dispatch reservation: if the holder drops @@ -1290,7 +1293,7 @@ impl HostRuntimeLoopCapabilityPort { &self, key: &IdempotencyKey, invocation_id: InvocationId, - result: Result, + result: Result, milestone: LoopHostMilestoneKind, ) -> Result<(), AgentLoopHostError> { let notify = lock_mut(&self.dispatch_records, "capability dispatch record store")?.record( @@ -1311,7 +1314,7 @@ impl HostRuntimeLoopCapabilityPort { &self, key: &IdempotencyKey, invocation_id: InvocationId, - result: Result, + result: Result, ) -> Result<(), AgentLoopHostError> { let notify = lock_mut(&self.dispatch_records, "capability dispatch record store")?.record( key, @@ -1432,7 +1435,7 @@ impl HostRuntimeLoopCapabilityPort { &self, key: &IdempotencyKey, completion: RuntimeOutcomeCompletion<'_>, - ) -> Result { + ) -> Result { let result = runtime_outcome_to_loop( &self.run_context, self.result_writer.as_ref(), @@ -1474,9 +1477,9 @@ impl HostRuntimeLoopCapabilityPort { &self, key: &IdempotencyKey, invocation_id: InvocationId, - result: Result, + result: Result, terminal_milestone: Option, - ) -> Result { + ) -> Result { if let Some(milestone) = terminal_milestone && let Err(error) = self.emit_capability_milestone(milestone.clone()).await { @@ -1521,7 +1524,7 @@ impl HostRuntimeLoopCapabilityPort { request: CapabilityInvocation, capability: SyntheticSurfaceCapabilitySnapshot, snapshot: SurfaceSnapshot, - ) -> Result { + ) -> Result { let input = self .input_resolver .resolve_capability_input(&self.run_context, &request.input_ref) @@ -1544,11 +1547,11 @@ impl HostRuntimeLoopCapabilityPort { // model-visible so the driver can retry instead of terminalizing the host. // INVARIANT: synthetic capabilities must not use InvalidInvocation for // internal or host-fatal conditions. - return Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: error.safe_summary, - detail: None, - })); + return Ok(GatedResolution::bare(resolution::failed( + CapabilityFailureKind::InvalidInput, + error.safe_summary, + None, + ))); } Err(error) => return Err(error), }; @@ -1564,15 +1567,15 @@ impl HostRuntimeLoopCapabilityPort { durable_persistence: DurablePersistence::Persist, }) .await?; - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write_result.result_ref, - safe_summary: "capability info returned".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write_result.byte_len, - output_digest: write_result.output_digest, - model_observation: write_result.model_observation, - })) + Ok(GatedResolution::bare(resolution::completed( + write_result.result_ref, + "capability info returned".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + write_result.byte_len, + write_result.output_digest, + write_result.model_observation, + ))) } fn prepare_provider_tool_call( @@ -1798,18 +1801,15 @@ impl LoopCapabilityPort for HostRuntimeLoopCapabilityPort { // (rather than up front) keeps dispatch's own resume identity/activity // validation the FIRST error a malformed resume surfaces — a missing/stale // resume payload must not pre-empt an `InvalidInvocation` activity mismatch. - let outcome = self.invoke_capability_dispatch(request.clone()).await?; - // Map ONCE, then persist and return the SAME `MappedResolution`. The - // approval/resource/dependent/external gates each mint a fresh random - // `GateRef` per mapping (only auth is deterministic via - // `for_auth_gate`), so mapping a second time to build the return value - // would hand the executor a gate ref that differs from the one the - // record was just persisted under — the resume could never load it. - // `persist_gate_record_for_mapped` returns the resolution to actually - // hand back: on a replay it is the FIRST invocation's cached resolution - // (whose gate ref the record is under), not this call's fresh mint. - let mapped = capability_outcome_to_resolution(outcome); - self.persist_gate_record_for_mapped(&request, mapped).await + // Stage 2b: dispatch produces the `GatedResolution` (resolution + the + // durable gate record its channel renders from) directly — mapped once, + // by construction, so the return value carries the SAME gate ref the + // record is persisted under. `persist_gate_record_for_mapped` persists + // that record and returns the resolution to actually hand back; on a + // concurrent duplicate it is the OWNER's resolution (whose gate ref the + // record is under), returned only AFTER its durable save completes (#6287). + let gated = self.invoke_capability_dispatch(request.clone()).await?; + self.persist_gate_record_for_mapped(&request, gated).await } async fn invoke_capability_batch( @@ -1865,14 +1865,14 @@ impl HostRuntimeLoopCapabilityPort { async fn persist_gate_record_for_mapped( &self, request: &CapabilityInvocation, - mapped: MappedResolution, + gated: GatedResolution, ) -> Result { - let Some(record) = mapped.gate_record.as_ref() else { + let Some(record) = gated.gate_record.as_ref() else { // Done / Denied / Suspended(Process): nothing durable to persist, no // idempotency key needed, and no gate ref that must stay loadable. - return Ok(mapped.resolution); + return Ok(gated.resolution); }; - let Some(gate_ref) = gate_ref_for_resolution(&mapped.resolution) else { + let Some(gate_ref) = gate_ref_for_resolution(&gated.resolution) else { // A gate record without a gate-ref-bearing channel is a mapping // invariant violation, not a recoverable model-visible error. return Err(AgentLoopHostError::new( @@ -1949,10 +1949,10 @@ impl HostRuntimeLoopCapabilityPort { // ref the record is under), wake them, and commit the guard so its // drop is a no-op. Ok(()) => { - self.publish_gate_resolution(&idempotency_key, &mapped.resolution)?; + self.publish_gate_resolution(&idempotency_key, &gated.resolution)?; owner_notify.notify_waiters(); reservation_guard.commit(); - Ok(mapped.resolution) + Ok(gated.resolution) } // A deterministic gate-record key (the auth gate's `for_auth_gate`, // and the approval gate's `for_approval_request` on the authorize @@ -1968,10 +1968,10 @@ impl HostRuntimeLoopCapabilityPort { %gate_ref, "gate record already persisted for this deterministic key; keeping existing record" ); - self.publish_gate_resolution(&idempotency_key, &mapped.resolution)?; + self.publish_gate_resolution(&idempotency_key, &gated.resolution)?; owner_notify.notify_waiters(); reservation_guard.commit(); - Ok(mapped.resolution) + Ok(gated.resolution) } // Transient fault: do NOT commit. The guard's drop clears the // reservation and wakes the waiters so one re-owns and retries the @@ -2152,7 +2152,7 @@ impl HostRuntimeLoopCapabilityPort { async fn invoke_capability_dispatch( &self, request: CapabilityInvocation, - ) -> Result { + ) -> Result { let requested_invocation_id = InvocationId::from_uuid(request.activity_id.as_uuid()); // Normalize resume mode and validate token/activity identity before // dispatch reservation. Cached replay branches can return without @@ -2246,10 +2246,13 @@ impl HostRuntimeLoopCapabilityPort { )?; let snapshot = self.snapshot_for(&request.surface_version)?; let Some(capability) = snapshot.capabilities.get(&request.capability_id).cloned() else { - return Ok(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: capability_denied_reason_kind("outside_visible_surface")?, - safe_summary: "capability was not visible on the cited surface".to_string(), - })); + return Ok(GatedResolution::bare( + resolution::denied( + capability_denied_reason_kind("outside_visible_surface")?, + "capability was not visible on the cited surface".to_string(), + ) + .resolution, + )); }; let idempotency_key = invocation_idempotency_key(&self.run_context, &request, effective_input_ref)?; @@ -2346,10 +2349,13 @@ impl HostRuntimeLoopCapabilityPort { .get(&capability.provider) .cloned() else { - return Ok(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: capability_denied_reason_kind("missing_provider_trust")?, - safe_summary: "capability provider trust is unavailable".to_string(), - })); + return Ok(GatedResolution::bare( + resolution::denied( + capability_denied_reason_kind("missing_provider_trust")?, + "capability provider trust is unavailable".to_string(), + ) + .resolution, + )); }; let (input, estimate) = match resume_payload { // Host-side resume replay: reconstitute {input, estimate} from the @@ -2396,11 +2402,11 @@ impl HostRuntimeLoopCapabilityPort { && is_provider_tool_call_input_ref(effective_input_ref) => { let host_error = *error.error; - let result = Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: host_error.safe_summary, - detail: error.detail, - })); + let result = Ok(GatedResolution::bare(resolution::failed( + CapabilityFailureKind::InvalidInput, + host_error.safe_summary, + error.detail, + ))); guard.commit(); self.record_loop_completed( &idempotency_key, @@ -2421,11 +2427,11 @@ impl HostRuntimeLoopCapabilityPort { // arguments instead of ending the run. `host_runtime_input_for_capability` // only returns `InvalidInvocation` for the sandbox-plan parse/validation // case; its host-internal serialization failure keeps its `Internal` Err. - let result = Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: error.safe_summary, - detail: None, - })); + let result = Ok(GatedResolution::bare(resolution::failed( + CapabilityFailureKind::InvalidInput, + error.safe_summary, + None, + ))); guard.commit(); self.record_loop_completed( &idempotency_key, @@ -2929,7 +2935,7 @@ pub fn concurrency_hint_from_effects(effects: &[EffectKind]) -> ConcurrencyHint fn should_retry_result_write( outcome: &RuntimeCapabilityOutcome, - result: &Result, + result: &Result, ) -> bool { matches!(outcome, RuntimeCapabilityOutcome::Completed(_)) && matches!( @@ -3230,7 +3236,7 @@ async fn runtime_outcome_to_loop( run_context: &LoopRunContext, result_writer: &(dyn LoopCapabilityResultWriter + Send + Sync), conversion: RuntimeOutcomeConversion<'_>, -) -> Result { +) -> Result { ensure_runtime_outcome_matches(conversion.requested_capability_id, &conversion.outcome)?; Ok(match conversion.outcome { RuntimeCapabilityOutcome::Completed(completed) => { @@ -3245,66 +3251,68 @@ async fn runtime_outcome_to_loop( durable_persistence: DurablePersistence::Persist, }) .await?; - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write_result.result_ref, - safe_summary: "capability completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write_result.byte_len, - output_digest: write_result.output_digest, - model_observation: write_result.model_observation, - }) + GatedResolution::bare(resolution::completed( + write_result.result_ref, + "capability completed".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + write_result.byte_len, + write_result.output_digest, + write_result.model_observation, + )) } RuntimeCapabilityOutcome::ApprovalRequired(gate) => { - // Raw input/estimate no longer ride the loop-facing outcome; the host - // persists them in the replay-payload store at the fresh gate raise - // (see `persist_replay_payload_for_fresh_gate`) and reconstitutes them - // on resume (arch-simplification §5.3 Stage 2a-i). - CapabilityOutcome::ApprovalRequired { - gate_ref: loop_gate_ref("approval", gate.approval_request_id.to_string())?, - safe_summary: blocked_summary(gate.reason).to_string(), - approval_resume: Some(ironclaw_turns::run_profile::CapabilityApprovalResume { + // Raw input/estimate no longer ride the loop-facing resolution; the + // host persists them in the replay-payload store at the fresh gate + // raise (see `persist_replay_payload_for_fresh_gate`) and reconstitutes + // them on resume (arch-simplification §5.3 Stage 2a-i). + resolution::approval_required( + loop_gate_ref("approval", gate.approval_request_id.to_string())?, + blocked_summary(gate.reason).to_string(), + Some(ironclaw_turns::run_profile::CapabilityApprovalResume { approval_request_id: gate.approval_request_id, resume_token: resume_token_from_invocation_id(conversion.invocation_id)?, correlation_id: conversion.correlation_id, input_ref: conversion.input_ref.clone(), }), - } + ) } - RuntimeCapabilityOutcome::AuthRequired(gate) => CapabilityOutcome::AuthRequired { - gate_ref: loop_gate_ref("auth", gate.gate_id.to_string())?, - credential_requirements: gate.credential_requirements, - safe_summary: blocked_summary(gate.reason).to_string(), - auth_resume: Some(ironclaw_turns::run_profile::CapabilityAuthResume { + RuntimeCapabilityOutcome::AuthRequired(gate) => resolution::auth_required( + loop_gate_ref("auth", gate.gate_id.to_string())?, + gate.credential_requirements, + blocked_summary(gate.reason).to_string(), + Some(ironclaw_turns::run_profile::CapabilityAuthResume { resume_token: resume_token_from_invocation_id(conversion.invocation_id)?, prior_approval: None, }), - }, - RuntimeCapabilityOutcome::ResourceBlocked(gate) => CapabilityOutcome::ResourceBlocked { - gate_ref: loop_gate_ref("resource", gate.gate_id.to_string())?, - safe_summary: blocked_summary(gate.reason).to_string(), - }, + ), + RuntimeCapabilityOutcome::ResourceBlocked(gate) => resolution::resource_blocked( + loop_gate_ref("resource", gate.gate_id.to_string())?, + blocked_summary(gate.reason).to_string(), + ), RuntimeCapabilityOutcome::SpawnedProcess(process) => { - CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { - process_ref: LoopProcessRef::new(format!("process:{}", process.process_id)) - .map_err(|_| { - AgentLoopHostError::new( - AgentLoopHostErrorKind::Internal, - "process ref could not be represented", - ) - })?, - safe_summary: "capability spawned background work".to_string(), - }) + GatedResolution::bare(resolution::spawned_process( + LoopProcessRef::new(format!("process:{}", process.process_id)).map_err(|_| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + "process ref could not be represented", + ) + })?, + )) } RuntimeCapabilityOutcome::Failed(failure) => { let capability_id = failure.capability_id.clone(); - let outcome = runtime_failure_to_loop(failure)?; - // Surface actionable failure detail (e.g. invalid-input field - // issues) to the per-tool UI preview by staging a display-preview - // record. Without this the projection falls back to the bare error - // kind. The model-visible observation is unaffected. - if let CapabilityOutcome::Failed(ref cap_failure) = outcome - && let Some(summary) = capability_failure_display_summary(cap_failure) + let class = runtime_failure_to_loop(failure)?; + // Surface actionable failure detail (e.g. invalid-input field issues) + // to the per-tool UI preview by staging a display-preview record. + // Without this the projection falls back to the bare error kind. The + // model-visible observation is unaffected. + if let LoopFailureClass::Failed { + safe_summary, + detail, + .. + } = &class + && let Some(summary) = failure_display_summary(safe_summary, detail) { result_writer .stage_capability_failure_preview( @@ -3315,21 +3323,53 @@ async fn runtime_outcome_to_loop( ) .await; } - outcome + GatedResolution::bare(class.into_resolution()) } RuntimeCapabilityOutcome::Unknown(unknown) => { - CapabilityOutcome::Failed(CapabilityFailure { - error_kind: capability_failure_kind(unknown.kind)?, - safe_summary: runtime_safe_summary( + GatedResolution::bare(resolution::failed( + capability_failure_kind(unknown.kind)?, + runtime_safe_summary( unknown.message, "capability invocation returned an unknown outcome", ), - detail: None, - }) + None, + )) } }) } +/// A runtime failure classified onto its loop channel — either a model-visible +/// recoverable failure or a terminal denial. Private to the seam: the failure +/// path needs the raw fields both to build the `Resolution` (via the producer +/// constructors) and to stage the per-tool display preview. +enum LoopFailureClass { + Failed { + error_kind: CapabilityFailureKind, + safe_summary: String, + detail: Option, + }, + Denied { + reason_kind: CapabilityDeniedReasonKind, + safe_summary: String, + }, +} + +impl LoopFailureClass { + fn into_resolution(self) -> Resolution { + match self { + LoopFailureClass::Failed { + error_kind, + safe_summary, + detail, + } => resolution::failed(error_kind, safe_summary, detail), + LoopFailureClass::Denied { + reason_kind, + safe_summary, + } => resolution::denied(reason_kind, safe_summary).resolution, + } + } +} + fn runtime_terminal_milestone( activity_id: CapabilityActivityId, provider: ExtensionId, @@ -3379,7 +3419,7 @@ fn runtime_terminal_milestone( fn runtime_failure_to_loop( failure: RuntimeCapabilityFailure, -) -> Result { +) -> Result { match failure.disposition() { CapabilityFailureDisposition::ModelVisibleToolError => { runtime_model_visible_failure_to_loop(failure) @@ -3389,14 +3429,14 @@ fn runtime_failure_to_loop( Some(structured) => Some(structured), None => runtime_failure_diagnostic_detail(&failure), }; - Ok(CapabilityOutcome::Failed(CapabilityFailure { + Ok(LoopFailureClass::Failed { error_kind: runtime_failure_kind_to_loop(failure.kind)?, safe_summary: runtime_failure_safe_summary( &failure, "capability invocation failed", ), detail, - })) + }) } } } @@ -3441,15 +3481,15 @@ fn model_visible_diagnostic_text(raw: &str) -> Option { fn runtime_model_visible_failure_to_loop( failure: RuntimeCapabilityFailure, -) -> Result { +) -> Result { if matches!( failure.kind, RuntimeFailureKind::Authorization | RuntimeFailureKind::PolicyDenied ) { - return Ok(CapabilityOutcome::Denied(CapabilityDenied { + return Ok(LoopFailureClass::Denied { reason_kind: denied_reason_kind_for(failure.kind)?, safe_summary: runtime_failure_safe_summary(&failure, "capability authorization denied"), - })); + }); } let error_kind = model_visible_runtime_failure_kind_to_loop(failure.kind)?; @@ -3458,11 +3498,11 @@ fn runtime_model_visible_failure_to_loop( Some(structured) => Some(structured), None => runtime_failure_diagnostic_detail(&failure), }; - Ok(CapabilityOutcome::Failed(CapabilityFailure { + Ok(LoopFailureClass::Failed { error_kind, safe_summary, detail, - })) + }) } fn runtime_failure_detail_to_loop( @@ -3988,9 +4028,9 @@ mod tests { .expect("convert invalid input without runtime detail"); assert!(matches!( invalid_input, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::InvalidInput - && failure.safe_summary == RuntimeDispatchErrorKind::InputEncode.human_summary() + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::InvalidInput + && safe_summary == RuntimeDispatchErrorKind::InputEncode.human_summary() )); let unsafe_invalid_input = runtime_failure_to_loop(RuntimeCapabilityFailure::new( @@ -4001,9 +4041,9 @@ mod tests { .expect("convert unsafe invalid input runtime summary"); assert!(matches!( unsafe_invalid_input, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::InvalidInput - && failure.safe_summary == RuntimeDispatchErrorKind::InputEncode.human_summary() + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::InvalidInput + && safe_summary == RuntimeDispatchErrorKind::InputEncode.human_summary() )); let issue = @@ -4025,10 +4065,10 @@ mod tests { .expect("convert invalid input with runtime detail"); assert!(matches!( detailed_invalid_input, - CapabilityOutcome::Failed(CapabilityFailure { + LoopFailureClass::Failed { detail: Some(CapabilityFailureDetail::InvalidInput { issues }), .. - }) if issues.len() == 2 + } if issues.len() == 2 && issues[0].path == "schedule.kind" && issues[0].code == DispatchInputIssueCode::MissingRequired && issues[1].path == "schedule.timezone" @@ -4043,9 +4083,9 @@ mod tests { .expect("convert policy denial"); assert!(matches!( denied, - CapabilityOutcome::Denied(denied) - if denied.reason_kind.as_str() == "policy_denied" - && denied.safe_summary == "policy denied request" + LoopFailureClass::Denied { reason_kind, safe_summary } + if reason_kind.as_str() == "policy_denied" + && safe_summary == "policy denied request" )); // Regression: RuntimeFailureKind::Authorization.as_str() is the literal @@ -4064,9 +4104,9 @@ mod tests { .expect("convert authorization denial without borking the run"); assert!(matches!( auth_denied, - CapabilityOutcome::Denied(denied) - if denied.reason_kind.as_str() == "auth_denied" - && denied.safe_summary == "capability requires authentication" + LoopFailureClass::Denied { reason_kind, safe_summary } + if reason_kind.as_str() == "auth_denied" + && safe_summary == "capability requires authentication" )); let operation_failed = runtime_failure_to_loop(RuntimeCapabilityFailure::new( @@ -4080,9 +4120,9 @@ mod tests { .expect("convert operation failure"); assert!(matches!( operation_failed, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::OperationFailed - && failure.safe_summary == "apply_patch failed for path workspace main.rs: old_string matched 0 times" + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::OperationFailed + && safe_summary == "apply_patch failed for path workspace main.rs: old_string matched 0 times" )); let missing_runtime = runtime_failure_to_loop(RuntimeCapabilityFailure::new( @@ -4093,9 +4133,9 @@ mod tests { .expect("convert missing runtime"); assert!(matches!( missing_runtime, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::MissingRuntime - && failure.safe_summary == "tool runtime is missing" + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::MissingRuntime + && safe_summary == "tool runtime is missing" )); } @@ -4114,13 +4154,18 @@ mod tests { )) .expect("convert host runtime failure"); - let CapabilityOutcome::Failed(failure) = outcome else { + let LoopFailureClass::Failed { + safe_summary, + detail, + .. + } = outcome + else { panic!("expected a model-visible Failed outcome"); }; // The summary stays generic (the path tripped the strict validator) ... - assert_eq!(failure.safe_summary, "capability invocation failed"); + assert_eq!(safe_summary, "capability invocation failed"); // ... but the raw path-bearing cause now rides the diagnostic detail. - let Some(CapabilityFailureDetail::Diagnostic { text }) = failure.detail else { + let Some(CapabilityFailureDetail::Diagnostic { text }) = detail else { panic!("expected a diagnostic detail carrying the raw cause"); }; assert_eq!(text, path, "the path string must reach the model intact"); @@ -4137,10 +4182,10 @@ mod tests { )) .expect("convert host runtime failure"); - let CapabilityOutcome::Failed(failure) = outcome else { + let LoopFailureClass::Failed { detail, .. } = outcome else { panic!("expected a model-visible Failed outcome"); }; - let Some(CapabilityFailureDetail::Diagnostic { text }) = failure.detail else { + let Some(CapabilityFailureDetail::Diagnostic { text }) = detail else { panic!("expected a diagnostic detail"); }; assert!( @@ -4173,10 +4218,10 @@ mod tests { let outcome = runtime_failure_to_loop(failure).expect("convert host runtime failure"); - let CapabilityOutcome::Failed(failure) = outcome else { + let LoopFailureClass::Failed { detail, .. } = outcome else { panic!("expected a model-visible Failed outcome"); }; - let Some(CapabilityFailureDetail::Diagnostic { text }) = failure.detail else { + let Some(CapabilityFailureDetail::Diagnostic { text }) = detail else { panic!("expected a diagnostic detail carrying the raw cause"); }; assert!( @@ -4208,10 +4253,10 @@ mod tests { let outcome = runtime_failure_to_loop(failure).expect("convert host runtime failure"); - let CapabilityOutcome::Failed(failure) = outcome else { + let LoopFailureClass::Failed { detail, .. } = outcome else { panic!("expected a model-visible Failed outcome"); }; - assert_eq!(failure.detail, None, "empty diagnostics must be dropped"); + assert_eq!(detail, None, "empty diagnostics must be dropped"); } #[test] @@ -4225,38 +4270,34 @@ mod tests { .expect("convert retryable failure"); assert!(matches!( retry, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::Transient - && failure.safe_summary == "temporary outage" + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::Transient + && safe_summary == "temporary outage" )); } #[test] fn capability_failure_display_summary_renders_invalid_input_issues() { - let failure = CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "tool input failed validation".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { - issues: vec![ - CapabilityInputIssue { - path: "schedule.kind".to_string(), - code: DispatchInputIssueCode::MissingRequired, - expected: Some("cron or once".to_string()), - received: Some("super-secret-raw-value".to_string()), - schema_path: None, - }, - CapabilityInputIssue { - path: "schedule.timezone".to_string(), - code: DispatchInputIssueCode::InvalidValue, - expected: None, - received: None, - schema_path: None, - }, - ], - }), - }; - let summary = - capability_failure_display_summary(&failure).expect("invalid input renders a summary"); + let detail = Some(CapabilityFailureDetail::InvalidInput { + issues: vec![ + CapabilityInputIssue { + path: "schedule.kind".to_string(), + code: DispatchInputIssueCode::MissingRequired, + expected: Some("cron or once".to_string()), + received: Some("super-secret-raw-value".to_string()), + schema_path: None, + }, + CapabilityInputIssue { + path: "schedule.timezone".to_string(), + code: DispatchInputIssueCode::InvalidValue, + expected: None, + received: None, + schema_path: None, + }, + ], + }); + let summary = failure_display_summary("tool input failed validation", &detail) + .expect("invalid input renders a summary"); assert!(summary.starts_with("Invalid input:")); assert!(summary.contains("schedule.kind — missing required field (expected cron or once)")); assert!(summary.contains("schedule.timezone — invalid value")); @@ -4268,57 +4309,45 @@ mod tests { fn capability_failure_display_summary_uses_safe_summary_without_issues() { // The `json` builtin reports invalid_input with a descriptive message // but no structured issues; that message must reach the preview. - let failure = CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "invalid JSON: expected value at line 1 column 1".to_string(), - detail: None, - }; assert_eq!( - capability_failure_display_summary(&failure).as_deref(), + failure_display_summary("invalid JSON: expected value at line 1 column 1", &None) + .as_deref(), Some("invalid JSON: expected value at line 1 column 1") ); } #[test] fn capability_failure_display_summary_skips_unsafe_input_issue_fields() { - let failure = CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "input schema validation failed".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { - issues: vec![CapabilityInputIssue { - path: "payload".to_string(), - code: DispatchInputIssueCode::InvalidValue, - expected: Some("safe".to_string()), - received: None, - schema_path: None, - }], - }), - }; + let detail = Some(CapabilityFailureDetail::InvalidInput { + issues: vec![CapabilityInputIssue { + path: "payload".to_string(), + code: DispatchInputIssueCode::InvalidValue, + expected: Some("safe".to_string()), + received: None, + schema_path: None, + }], + }); assert_eq!( - capability_failure_display_summary(&failure).as_deref(), + failure_display_summary("input schema validation failed", &detail).as_deref(), Some("input schema validation failed") ); } #[test] fn capability_failure_display_summary_skips_sensitive_input_issue_fields() { - let failure = CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "input schema validation failed".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { - issues: vec![CapabilityInputIssue { - path: "secret_api_key".to_string(), - code: DispatchInputIssueCode::TypeMismatch, - expected: Some("password string".to_string()), - received: None, - schema_path: None, - }], - }), - }; + let detail = Some(CapabilityFailureDetail::InvalidInput { + issues: vec![CapabilityInputIssue { + path: "secret_api_key".to_string(), + code: DispatchInputIssueCode::TypeMismatch, + expected: Some("password string".to_string()), + received: None, + schema_path: None, + }], + }); assert_eq!( - capability_failure_display_summary(&failure).as_deref(), + failure_display_summary("input schema validation failed", &detail).as_deref(), Some("input schema validation failed") ); } @@ -4338,12 +4367,7 @@ mod tests { #[test] fn capability_failure_display_summary_is_none_for_generic_placeholder() { - let failure = CapabilityFailure { - error_kind: CapabilityFailureKind::Backend, - safe_summary: "capability invocation failed".to_string(), - detail: None, - }; - assert!(capability_failure_display_summary(&failure).is_none()); + assert!(failure_display_summary("capability invocation failed", &None).is_none()); } #[test] @@ -4357,9 +4381,9 @@ mod tests { .expect("convert invalid output"); assert!(matches!( invalid_output, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::InvalidOutput - && failure.safe_summary == "runtime returned malformed output" + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::InvalidOutput + && safe_summary == "runtime returned malformed output" )); let cancelled = runtime_failure_to_loop(RuntimeCapabilityFailure::new( @@ -4370,9 +4394,9 @@ mod tests { .expect("convert cancelled failure"); assert!(matches!( cancelled, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::Cancelled - && failure.safe_summary == "capability cancelled" + LoopFailureClass::Failed { error_kind, safe_summary, .. } + if error_kind == CapabilityFailureKind::Cancelled + && safe_summary == "capability cancelled" )); } diff --git a/crates/ironclaw_loop_host/src/capability_surface_filter.rs b/crates/ironclaw_loop_host/src/capability_surface_filter.rs index 42688f4bd7..b175bec360 100644 --- a/crates/ironclaw_loop_host/src/capability_surface_filter.rs +++ b/crates/ironclaw_loop_host/src/capability_surface_filter.rs @@ -8,10 +8,10 @@ use ironclaw_host_api::{CapabilityId, Resolution, ResolutionBatch}; use ironclaw_turns::CapabilityActivityId; use ironclaw_turns::run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, CapabilityCallCandidate, - CapabilityDenied, CapabilityDeniedReasonKind, CapabilityInvocation, CapabilityOutcome, - CapabilitySurfaceProfileId, LoopCapabilityPort, LoopRunContext, ProviderToolCall, - ProviderToolCallCapabilityIds, ProviderToolDefinition, RegisterProviderToolCallRequest, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, + CapabilityDeniedReasonKind, CapabilityInvocation, CapabilitySurfaceProfileId, + LoopCapabilityPort, LoopRunContext, ProviderToolCall, ProviderToolCallCapabilityIds, + ProviderToolDefinition, RegisterProviderToolCallRequest, VisibleCapabilityRequest, + VisibleCapabilitySurface, resolution, }; use crate::{CapabilityAllowSet, LoopCapabilityPortDecorator, capability_info}; @@ -671,10 +671,10 @@ fn provider_capability_permitted( } fn model_view_denied_outcome() -> Resolution { - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: model_view_denied_kind(), - safe_summary: "capability outside the model-visible view".to_string(), - })) + resolution::denied( + model_view_denied_kind(), + "capability outside the model-visible view".to_string(), + ) .resolution } @@ -693,10 +693,10 @@ fn model_view_denied_kind() -> CapabilityDeniedReasonKind { } fn surface_profile_denied_outcome() -> Resolution { - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: surface_profile_denied_kind(), - safe_summary: "capability not in run-profile surface".to_string(), - })) + resolution::denied( + surface_profile_denied_kind(), + "capability not in run-profile surface".to_string(), + ) .resolution } @@ -723,7 +723,8 @@ mod tests { }; use ironclaw_turns::run_profile::{ CancellationPolicy, CapabilityBatchOutcome, CapabilityDescriptorView, CapabilityInputRef, - CapabilityResultMessage, CapabilitySurfaceVersion, CheckpointPolicy, CheckpointSchemaId, + CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, CheckpointPolicy, + CheckpointSchemaId, capability_outcome_to_resolution, ConcurrencyClass, ConcurrencyHint, ContextProfileId, LoopDriverId, ModelProfileId, PersonalContextPolicy, RedactedRunProfileProvenance, ResolvedRunProfile, ResourceBudgetPolicy, ResourceBudgetTier, RuntimeProfileConstraints, SchedulingClass, diff --git a/crates/ironclaw_loop_host/src/lib.rs b/crates/ironclaw_loop_host/src/lib.rs index d96bbf5a66..e1b55068c8 100644 --- a/crates/ironclaw_loop_host/src/lib.rs +++ b/crates/ironclaw_loop_host/src/lib.rs @@ -145,15 +145,15 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AgentLoopHostErrorReasonKind, AppendCapabilityResultRef, AssistantReply, BeginAssistantDraft, CapabilityBatchInvocation, - CapabilityDenied, CapabilityDeniedReasonKind, CapabilityInvocation, CapabilityOutcome, - CapabilitySurfaceVersion, FinalizeAssistantMessage, InstructionMaterializationStore, + CapabilityDeniedReasonKind, CapabilityInvocation, CapabilitySurfaceVersion, + FinalizeAssistantMessage, InstructionMaterializationStore, LoopCapabilityPort, LoopContextBundle, LoopContextCompactionKind, LoopContextCompactionMetadata, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopDriverNoteKind, LoopHostMilestoneEmitter, LoopHostMilestoneSink, LoopInputCursor, LoopModelMessage, LoopModelPort, LoopModelRequest, LoopModelResponse, LoopModelUsage, LoopPromptBundleAuthority, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, ParentLoopOutput, PromptMode, UpdateAssistantDraft, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilityRequest, VisibleCapabilitySurface, resolution, sanitize_model_visible_text, sort_instruction_snippets_for_prompt, }, }; @@ -931,10 +931,10 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for EmptyLoopCapabilityPort .invocations .into_iter() .map(|_| { - capability_outcome_to_resolution(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "no capabilities are available to this loop".to_string(), - })) + resolution::denied( + CapabilityDeniedReasonKind::EmptySurface, + "no capabilities are available to this loop".to_string(), + ) .resolution }) .collect(); diff --git a/crates/ironclaw_loop_host/src/subagent_spawn_port.rs b/crates/ironclaw_loop_host/src/subagent_spawn_port.rs index 693331e766..a4dced0919 100644 --- a/crates/ironclaw_loop_host/src/subagent_spawn_port.rs +++ b/crates/ironclaw_loop_host/src/subagent_spawn_port.rs @@ -11,8 +11,8 @@ use std::{ use async_trait::async_trait; use chrono::Utc; use ironclaw_host_api::{ - CapabilityId, InvocationId, ProviderToolName, Resolution, ResolutionBatch, RuntimeKind, - ThreadId, + CapabilityId, InvocationId, LoopRef, ProviderToolName, Resolution, ResolutionBatch, RuntimeKind, + Suspension, ThreadId, }; use ironclaw_threads::{ AcceptInboundMessageRequest, EnsureThreadRequest, MessageContent, SessionThreadService, @@ -25,13 +25,12 @@ use ironclaw_turns::{ TurnError, TurnErrorCategory, TurnRunId, TurnScope, TurnSpawnTreePort, TurnSpawnTreeStateStore, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, - CapabilityCallCandidate, CapabilityDenied, CapabilityDeniedReasonKind, - CapabilityDescriptorView, CapabilityFailure, CapabilityFailureKind, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, ConcurrencyHint, LoopCapabilityPort, - LoopRunContext, LoopSafeSummary, ProviderToolCall, ProviderToolCallCapabilityIds, - ProviderToolCallReplay, ProviderToolDefinition, RegisterProviderToolCallRequest, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, - sanitize_model_visible_text, + CapabilityCallCandidate, CapabilityDeniedReasonKind, CapabilityDescriptorView, + CapabilityFailureKind, CapabilityInputRef, CapabilityInvocation, ConcurrencyHint, + LoopCapabilityPort, LoopRunContext, LoopSafeSummary, ProviderToolCall, + ProviderToolCallCapabilityIds, ProviderToolCallReplay, ProviderToolDefinition, + RegisterProviderToolCallRequest, VisibleCapabilityRequest, VisibleCapabilitySurface, + resolution, sanitize_model_visible_text, }, }; use serde::{Deserialize, Serialize}; @@ -699,7 +698,7 @@ impl SubagentSpawnCapabilityPort { invocation: &CapabilityInvocation, args: SpawnSubagentArgs, gate_override: Option, - ) -> Result { + ) -> Result { let mut compensation = SpawnCompensationState::default(); self.handle_spawn_with_gate_recording(invocation, args, gate_override, &mut compensation) .await @@ -711,7 +710,7 @@ impl SubagentSpawnCapabilityPort { args: SpawnSubagentArgs, gate_override: Option, compensation: &mut SpawnCompensationState, - ) -> Result { + ) -> Result { let Some(spawn_slot) = self.reserve_spawn_slot() else { return Ok(spawn_rejected("fanout_cap_exceeded")); }; @@ -803,7 +802,7 @@ impl SubagentSpawnCapabilityPort { async fn authorize_spawn( &self, invocation: &CapabilityInvocation, - ) -> Result, AgentLoopHostError> { + ) -> Result, AgentLoopHostError> { let mut spawn_authorizations = self.spawn_authorizations.lock().map_err(|_| { AgentLoopHostError::new( AgentLoopHostErrorKind::Unavailable, @@ -863,7 +862,7 @@ impl SubagentSpawnCapabilityPort { actor: TurnActor, invocation: &CapabilityInvocation, compensation: &mut SpawnCompensationState, - ) -> Result { + ) -> Result { let SpawnContext { definition, child_scope, @@ -955,7 +954,7 @@ impl SubagentSpawnCapabilityPort { // Lazy-recovery admission gate (§5.3): refuse to open a new edge onto // a scope whose boot/lazy recovery is still in flight. Transient and // retryable, like the `spawn_rejected(...)` outcomes above — surface - // it as `CapabilityOutcome::Failed`, not `Err(AgentLoopHostError)`, + // it as a model-visible recoverable failure, not `Err(AgentLoopHostError)`, // which maps to a run-ending `HostUnavailable` (external review, // PR #5819). if let Err(error) = self @@ -964,11 +963,11 @@ impl SubagentSpawnCapabilityPort { .check_scope_recovered(&child_turn_scope) .await { - return Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::Transient, - safe_summary: format!("subagent spawn scope recovery in progress: {error}"), - detail: None, - })); + return Ok(resolution::failed( + CapabilityFailureKind::Transient, + format!("subagent spawn scope recovery in progress: {error}"), + None, + )); } self.deps .goal_store @@ -1066,13 +1065,14 @@ impl SubagentSpawnCapabilityPort { } let loop_gate_ref = LoopGateRef::new(gate_ref.as_str()).map_err(invalid_static_ref)?; - Ok(CapabilityOutcome::AwaitDependentRun { - gate_ref: loop_gate_ref, + Ok(resolution::await_dependent_run( + loop_gate_ref, result_ref, - safe_summary: safe_summary("subagent spawned; waiting for completion"), - byte_len: write_result.byte_len, - model_observation: write_result.model_observation, - }) + safe_summary("subagent spawned; waiting for completion"), + write_result.byte_len, + write_result.model_observation, + ) + .resolution) } async fn rollback_batch_compensation(&self, compensations: &mut Vec) { @@ -1171,11 +1171,10 @@ impl LoopCapabilityPort for SubagentSpawnCapabilityPort { .spawn_input_codec .decode(&self.run_context, &request.input_ref) .await?; - if let Some(outcome) = self.authorize_spawn(&request).await? { - return Ok(capability_outcome_to_resolution(outcome).resolution); + if let Some(resolution) = self.authorize_spawn(&request).await? { + return Ok(resolution); } - let outcome = self.handle_spawn_with_gate(&request, args, None).await?; - return Ok(capability_outcome_to_resolution(outcome).resolution); + return self.handle_spawn_with_gate(&request, args, None).await; } self.inner.invoke_capability(request).await } @@ -1258,17 +1257,21 @@ impl LoopCapabilityPort for SubagentSpawnCapabilityPort { return Err(error); } }; + // The spawn helpers now emit the host_api `Resolution` directly; a + // coalesced batch-await-dependent is the `Suspended(DependentRun)` + // whose preserved loop-gate origin is the shared batch gate. + let resolution = outcome; let batch_await_dependent = matches!( - &outcome, - CapabilityOutcome::AwaitDependentRun { gate_ref, .. } - if batch_blocking_gate - .as_ref() - .is_some_and(|batch_gate| batch_gate == gate_ref) + &resolution, + Resolution::Suspended(Suspension::DependentRun { waypoint, .. }) + if batch_blocking_gate.as_ref().is_some_and(|batch_gate| { + waypoint.origin.as_ref().map(LoopRef::as_str) + == Some(batch_gate.as_str()) + }) ); // `parks()`, not `is_suspension()` (H1): the batch stops on any // parking resolution (gate or suspension), except a coalesced // batch-await-dependent which continues to accrue siblings. - let resolution = capability_outcome_to_resolution(outcome).resolution; let parks = resolution.parks(); resolutions.push(resolution); if parks && request.stop_on_first_suspension && !batch_await_dependent { @@ -1405,12 +1408,13 @@ impl crate::AwaitEdgeWriter for InMemoryAwaitEdgeWriter { } } -fn spawn_rejected(reason: &'static str) -> CapabilityOutcome { - CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown(reason) +fn spawn_rejected(reason: &'static str) -> Resolution { + resolution::denied( + CapabilityDeniedReasonKind::unknown(reason) .unwrap_or(CapabilityDeniedReasonKind::EmptySurface), - safe_summary: format!("subagent spawn rejected: {reason}"), - }) + format!("subagent spawn rejected: {reason}"), + ) + .resolution } fn background_subagents_disabled() -> AgentLoopHostError { diff --git a/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs b/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs index 07160b516f..e8f2182ee1 100644 --- a/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs +++ b/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs @@ -17,9 +17,9 @@ use ironclaw_turns::{ SubmitTurnRequest, TurnId, TurnRunProfile, TurnRunRecord, TurnRunState, TurnStateStore, TurnStatus, run_profile::{ - CapabilityResultMessage, CapabilitySurfaceVersion, ModelVisibleToolObservation, - ObservationTrust, RegisterProviderToolCallRequest, ToolObservationDetail, - ToolObservationStatus, + CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, + ModelVisibleToolObservation, ObservationTrust, RegisterProviderToolCallRequest, + ToolObservationDetail, ToolObservationStatus, capability_outcome_to_resolution, }, }; use serde_json::json; @@ -3095,13 +3095,24 @@ async fn json_spawn_input_codec_propagates_resolver_error() { } #[test] -fn spawn_rejected_preserves_spawn_specific_reason_kind() { - let CapabilityOutcome::Denied(denied) = spawn_rejected("depth_cap_exceeded") else { +fn spawn_rejected_preserves_spawn_specific_reason_in_summary() { + // The §5.3 collapse maps the open-set loop reason ("depth_cap_exceeded", + // which is not a host_api `DenyReason` tag) to the model-visible catch-all + // `PolicyDenied`; the spawn-specific reason rides the redacted summary. + let ironclaw_host_api::Resolution::Denied(denial) = spawn_rejected("depth_cap_exceeded") else { panic!("spawn_rejected should deny"); }; - assert_eq!(denied.reason_kind.as_str(), "depth_cap_exceeded"); - assert!(denied.safe_summary.contains("depth_cap_exceeded")); + assert_eq!( + denial.reason_kind, + Some(ironclaw_host_api::DenyReason::PolicyDenied) + ); + assert!( + denial + .summary + .as_ref() + .is_some_and(|summary| summary.as_str().contains("depth_cap_exceeded")) + ); } #[tokio::test] diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index c536b29496..34d8e2269d 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -23,7 +23,7 @@ mod policy; mod prompt; mod prompt_text; mod refs; -mod resolution; +pub mod resolution; mod resolution_mapping; mod resolver; mod runtime_context; @@ -113,11 +113,7 @@ pub use refs::{ LoopDriverId, ModelProfileId, ResourceBudgetTier, RunClassId, RunProfileFingerprint, RunProfileSourceLayer, RunProfileSourceRef, RunnerPoolId, SchedulingClass, }; -pub use resolution::{ - DeniedResolution, GatedResolution, approval_required, auth_required, await_dependent_run, - completed, denied, external_tool_pending, failed, resource_blocked, spawned_child_run, - spawned_process, -}; +pub use resolution::{DeniedResolution, GatedResolution}; pub use resolution_mapping::{MappedResolution, RefBindings, capability_outcome_to_resolution}; pub use resolver::{ InMemoryRunProfileRegistry, InMemoryRunProfileResolver, RunProfileDefinition, From 7574742a0f12b7eef360dfc31b616ae800c6b1ba Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 21:38:55 +0000 Subject: [PATCH 04/12] =?UTF-8?q?refactor(reborn):=20hooks=20middleware=20?= =?UTF-8?q?emits=20Resolution=20directly=20(=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HookedLoopCapabilityPort::decision_to_outcome and fail_closed_gate_ref_unavailable now return host_api Resolution (deny/approval/auth) via the producer constructors instead of building CapabilityOutcome and re-mapping; single and batch invoke paths consume the Resolution directly. Test double emits resolution::completed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/middleware/capability_port.rs | 104 +++++++++--------- 1 file changed, 53 insertions(+), 51 deletions(-) diff --git a/crates/ironclaw_hooks/src/middleware/capability_port.rs b/crates/ironclaw_hooks/src/middleware/capability_port.rs index 1ad48f17cd..dd564917a2 100644 --- a/crates/ironclaw_hooks/src/middleware/capability_port.rs +++ b/crates/ironclaw_hooks/src/middleware/capability_port.rs @@ -28,11 +28,10 @@ use std::sync::Arc; use async_trait::async_trait; use ironclaw_host_api::{Resolution, ResolutionBatch, TenantId}; use ironclaw_turns::run_profile::{ - AgentLoopHostError, CapabilityBatchInvocation, CapabilityCallCandidate, CapabilityDenied, - CapabilityDeniedReasonKind, CapabilityInvocation, CapabilityOutcome, LoopCapabilityPort, - ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolDefinition, - RegisterProviderToolCallRequest, VisibleCapabilityRequest, VisibleCapabilitySurface, - capability_outcome_to_resolution, + AgentLoopHostError, CapabilityBatchInvocation, CapabilityCallCandidate, + CapabilityDeniedReasonKind, CapabilityInvocation, LoopCapabilityPort, ProviderToolCall, + ProviderToolCallCapabilityIds, ProviderToolDefinition, RegisterProviderToolCallRequest, + VisibleCapabilityRequest, VisibleCapabilitySurface, resolution, }; use crate::dispatch::{BeforeCapabilityDispatchOutcome, HookDispatcher}; @@ -276,9 +275,9 @@ impl LoopCapabilityPort for HookedLoopCapabilityPort { .await; let outcome = self.run_dispatch(&request, provider.clone()).await; let result = match self.decision_to_outcome(&outcome).await { - // Hook produced a restrictive decision: map its `CapabilityOutcome` - // onto the host_api `Resolution` channel the loop now speaks. - Some(translated) => Ok(capability_outcome_to_resolution(translated).resolution), + // Hook produced a restrictive decision — already a host_api + // `Resolution` on the channel the loop speaks. + Some(resolution) => Ok(resolution), // Hooks allowed: forward to the inner port, which already returns a // `Resolution` (§5.3 flip) — pure pass-through, no variant inspection. None => self.inner.invoke_capability(request).await, @@ -354,13 +353,11 @@ impl LoopCapabilityPort for HookedLoopCapabilityPort { .await; let dispatch = self.run_dispatch(&invocation, provider.clone()).await; match self.decision_to_outcome(&dispatch).await { - Some(translated) => { - // Map the hook's restrictive `CapabilityOutcome` onto the - // host_api `Resolution` the loop now speaks, then gate the - // batch-stop decision on `Resolution::parks()` — the correct - // superset of the old `CapabilityOutcome::is_suspension` - // (H1: a re-entrant gate parks the batch too). - let resolution = capability_outcome_to_resolution(translated).resolution; + Some(resolution) => { + // The hook's restrictive decision is already a host_api + // `Resolution`; gate the batch-stop decision on + // `Resolution::parks()` (H1: a re-entrant gate parks the + // batch too). let parks = resolution.parks(); slots.push(Slot::Resolved { resolution: Box::new(resolution), @@ -518,38 +515,45 @@ impl HookedLoopCapabilityPort { async fn decision_to_outcome( &self, dispatched: &BeforeCapabilityDispatchOutcome, - ) -> Option { + ) -> Option { match dispatched.decision.inner() { GateDecisionInner::Allow => None, - GateDecisionInner::Deny { reason } => { - Some(CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown("hook_denied") + GateDecisionInner::Deny { reason } => Some( + resolution::denied( + CapabilityDeniedReasonKind::unknown("hook_denied") .expect("hook_denied is a valid loop-safe identifier"), // safety: literal ASCII identifier, validated by LoopGateRef constructor contract - safe_summary: reason.as_str().to_string(), - })) - } + reason.as_str().to_string(), + ) + .resolution, + ), GateDecisionInner::PauseApproval { reason } => { match self .gate_ref_factory .mint_approval_ref(reason.as_str()) .await { - Ok(gate_ref) => Some(CapabilityOutcome::ApprovalRequired { - gate_ref, - safe_summary: reason.as_str().to_string(), - approval_resume: None, - }), + Ok(gate_ref) => Some( + resolution::approval_required( + gate_ref, + reason.as_str().to_string(), + None, + ) + .resolution, + ), Err(_) => Some(fail_closed_gate_ref_unavailable(reason.as_str())), } } GateDecisionInner::PauseAuth { reason } => { match self.gate_ref_factory.mint_auth_ref(reason.as_str()).await { - Ok(gate_ref) => Some(CapabilityOutcome::AuthRequired { - gate_ref, - credential_requirements: Vec::new(), - safe_summary: reason.as_str().to_string(), - auth_resume: None, - }), + Ok(gate_ref) => Some( + resolution::auth_required( + gate_ref, + Vec::new(), + reason.as_str().to_string(), + None, + ) + .resolution, + ), Err(_) => Some(fail_closed_gate_ref_unavailable(reason.as_str())), } } @@ -561,12 +565,13 @@ impl HookedLoopCapabilityPort { /// pause-class decision. The safe summary intentionally carries only the /// hook's already-sanitized reason — the underlying host error is dropped to /// avoid leaking internal gate-router state into model-visible output. -fn fail_closed_gate_ref_unavailable(sanitized_reason: &str) -> CapabilityOutcome { - CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown("hook_gate_ref_unavailable") +fn fail_closed_gate_ref_unavailable(sanitized_reason: &str) -> Resolution { + resolution::denied( + CapabilityDeniedReasonKind::unknown("hook_gate_ref_unavailable") .expect("hook_gate_ref_unavailable is a valid loop-safe identifier"), // safety: literal ASCII identifier, validated by LoopGateRef constructor contract - safe_summary: sanitized_reason.to_string(), - }) + sanitized_reason.to_string(), + ) + .resolution } /// Counts the JSON-serialized byte length of `value` without allocating @@ -655,8 +660,7 @@ mod tests { use ironclaw_host_api::{CapabilityId, DenyReason, RuntimeKind}; use ironclaw_turns::LoopResultRef; use ironclaw_turns::run_profile::{ - CapabilityDescriptorView, CapabilityInputRef, CapabilityResultMessage, - CapabilitySurfaceVersion, + CapabilityDescriptorView, CapabilityInputRef, CapabilitySurfaceVersion, }; use std::sync::Mutex; @@ -718,17 +722,15 @@ mod tests { .lock() .expect("not poisoned") .push(request.capability_id.clone()); - let completed = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(format!("result:{}", request.capability_id)) - .expect("ok"), - safe_summary: format!("ran {}", request.capability_id), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }); - Ok(capability_outcome_to_resolution(completed).resolution) + Ok(resolution::completed( + LoopResultRef::new(format!("result:{}", request.capability_id)).expect("ok"), + format!("ran {}", request.capability_id), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + )) } async fn invoke_capability_batch( From d184159e8bcfe56d13fc87e5eb8abe0bd88d86ff Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 21:59:31 +0000 Subject: [PATCH 05/12] =?UTF-8?q?refactor(reborn):=20local=5Fdev=20synthet?= =?UTF-8?q?ic=20capabilities=20emit=20Resolution=20directly=20(=C2=A75.3?= =?UTF-8?q?=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SyntheticCapabilityHandler::invoke now returns host_api Resolution; the synthetic port and external-tool port delegate it straight through (no capability_outcome_to_resolution re-map). outbound_delivery (approval gate + denial/failure paths, internal ApprovedResumeDecision + approval_lease_outcome now carry Resolution), skill_activation, project_create, result_read (parse error boxed for result_large_err), and external_tool_capability all build Resolution via the producer constructors. Tests assert Resolution::Done recoverable-failure verdicts and Resolution::Denied. 230 local_dev tests green; crate clippy -D warnings clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../local_dev/external_tool_capability.rs | 45 ++-- .../runtime/local_dev/outbound_delivery.rs | 226 +++++++++--------- .../src/runtime/local_dev/project_create.rs | 61 ++--- .../src/runtime/local_dev/result_read.rs | 77 +++--- .../src/runtime/local_dev/skill_activation.rs | 75 +++--- .../runtime/local_dev/synthetic_capability.rs | 43 ++-- 6 files changed, 257 insertions(+), 270 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/external_tool_capability.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/external_tool_capability.rs index 5fae506363..34bac03640 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/external_tool_capability.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/external_tool_capability.rs @@ -33,11 +33,10 @@ use ironclaw_loop_host::{ }; use ironclaw_turns::run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, CapabilityCallCandidate, - CapabilityInvocation, CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, - CapabilitySurfaceVersion, ConcurrencyHint, LoopCapabilityPort, LoopRunContext, - ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolCallReplay, - ProviderToolDefinition, RegisterProviderToolCallRequest, VisibleCapabilityRequest, - VisibleCapabilitySurface, capability_outcome_to_resolution, + CapabilityInvocation, CapabilityProgress, CapabilitySurfaceVersion, ConcurrencyHint, + LoopCapabilityPort, LoopRunContext, ProviderToolCall, ProviderToolCallCapabilityIds, + ProviderToolCallReplay, ProviderToolDefinition, RegisterProviderToolCallRequest, + VisibleCapabilityRequest, VisibleCapabilitySurface, resolution, }; use ironclaw_turns::{ExternalToolCatalog, PendingExternalCall}; use ironclaw_turns::{LoopGateRef, TurnRunId}; @@ -183,7 +182,7 @@ impl ExternalToolCapabilityPort { async fn complete_or_park( &self, request: CapabilityInvocation, - ) -> Result { + ) -> Result { if request.surface_version != self.surface_version()? { return Err(AgentLoopHostError::new( AgentLoopHostErrorKind::StaleSurface, @@ -223,21 +222,22 @@ impl ExternalToolCapabilityPort { .complete_call_for_input_ref(self.run_id, &input_ref) .await .map_err(catalog_error)?; - return Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write.result_ref, - safe_summary: "external tool output".to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write.byte_len, - output_digest: write.output_digest, - model_observation: write.model_observation, - })); + return Ok(resolution::completed( + write.result_ref, + "external tool output".to_string(), + CapabilityProgress::MadeProgress, + false, + write.byte_len, + write.output_digest, + write.model_observation, + )); } // No output yet → park and return control to the API client. - Ok(CapabilityOutcome::ExternalToolPending { - gate_ref: external_tool_gate_ref(&call_id)?, - safe_summary: "awaiting client tool output".to_string(), - }) + Ok(resolution::external_tool_pending( + external_tool_gate_ref(&call_id)?, + "awaiting client tool output".to_string(), + ) + .resolution) } } @@ -420,11 +420,8 @@ impl LoopCapabilityPort for ExternalToolCapabilityPort { if !self.owns_capability(&request.capability_id) { return self.inner.invoke_capability(request).await; } - // `complete_or_park` still constructs `CapabilityOutcome` (unchanged - // internal helper); collapse it to the host `Resolution` at this - // `LoopCapabilityPort` boundary. - let outcome = self.complete_or_park(request).await?; - Ok(capability_outcome_to_resolution(outcome).resolution) + // `complete_or_park` emits the host `Resolution` directly (§5.3 Stage 2b). + self.complete_or_park(request).await } async fn invoke_capability_batch( diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/outbound_delivery.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/outbound_delivery.rs index 459a24c093..495609989d 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/outbound_delivery.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/outbound_delivery.rs @@ -5,8 +5,8 @@ use ironclaw_approvals::ToolPermissionOverride; use ironclaw_authorization::{CapabilityLeaseError, CapabilityLeaseStatus, CapabilityLeaseStore}; use ironclaw_host_api::{ Action, ApprovalRequest, ApprovalRequestId, CapabilityGrantId, CapabilityId, CorrelationId, - GateRecord, GateRef, InvocationFingerprint, InvocationId, Principal, ResourceEstimate, - ResourceScope, SafeSummary, UserId, + GateRecord, GateRef, InvocationFingerprint, InvocationId, Principal, Resolution, + ResourceEstimate, ResourceScope, SafeSummary, UserId, }; use ironclaw_loop_host::{CapabilityResultWrite, DurablePersistence}; use ironclaw_product_workflow::{ @@ -17,10 +17,9 @@ use ironclaw_run_state::{ApprovalRequestStore, ApprovalStatus, RunStateError}; use ironclaw_turns::{ LoopGateRef, run_profile::{ - AgentLoopHostError, AgentLoopHostErrorKind, CapabilityApprovalResume, CapabilityDenied, - CapabilityDeniedReasonKind, CapabilityFailure, CapabilityFailureKind, CapabilityInputRef, - CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, CapabilityResumeToken, - ConcurrencyHint, LoopRunContext, + AgentLoopHostError, AgentLoopHostErrorKind, CapabilityApprovalResume, + CapabilityDeniedReasonKind, CapabilityFailureKind, CapabilityInputRef, CapabilityProgress, + CapabilityResumeToken, ConcurrencyHint, LoopRunContext, resolution, }, }; @@ -108,7 +107,7 @@ impl SyntheticCapabilityHandler for OutboundDeliveryTargetsListHandler { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { let input = parse_outbound_delivery_targets_list_input(&invocation.input).map_err(input_error)?; let caller = caller_for_run(&invocation, &self.fallback_user_id); @@ -182,7 +181,7 @@ struct ApprovedDispatchLease { /// run (see .claude/rules/agent-loop-capabilities.md, Invariant 1). enum ApprovedResumeDecision { Approved(ApprovedDispatchLease), - Denied(CapabilityDenied), + Denied(Resolution), } enum OutboundDeliveryApprovalSettingsDecision { @@ -205,7 +204,7 @@ impl SyntheticCapabilityHandler for OutboundDeliveryTargetSetHandler { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { if invocation.request.auth_resume.is_some() { return Err(AgentLoopHostError::new( AgentLoopHostErrorKind::InvalidInvocation, @@ -230,7 +229,7 @@ impl SyntheticCapabilityHandler for OutboundDeliveryTargetSetHandler { { ApprovedResumeDecision::Approved(lease) => Some(lease), ApprovedResumeDecision::Denied(denied) => { - return Ok(CapabilityOutcome::Denied(denied)); + return Ok(denied); } } } @@ -242,11 +241,11 @@ impl SyntheticCapabilityHandler for OutboundDeliveryTargetSetHandler { .await; } OutboundDeliveryApprovalSettingsDecision::Deny => { - return Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::PolicyDenied, - safe_summary: "outbound delivery target setter is disabled by tool approval settings".to_string(), - detail: None, - })); + return Ok(resolution::failed( + CapabilityFailureKind::PolicyDenied, + "outbound delivery target setter is disabled by tool approval settings".to_string(), + None, + )); } }, } @@ -285,7 +284,7 @@ impl SyntheticCapabilityHandler for OutboundDeliveryTargetSetHandler { { Ok(_) => {} Err(error) => match approval_lease_outcome("consume_approval_lease", error) { - Ok(denied) => return Ok(CapabilityOutcome::Denied(denied)), + Ok(denied) => return Ok(denied), Err(host_error) => return Err(host_error), }, } @@ -344,7 +343,7 @@ impl OutboundDeliveryTargetSetHandler { invocation: &SyntheticCapabilityInvocation, input: &serde_json::Value, target_id: &RebornOutboundDeliveryTargetId, - ) -> Result { + ) -> Result { let capability_id = outbound_delivery_target_set_capability_id()?; let approval_request_id = ApprovalRequestId::new(); let correlation_id = CorrelationId::new(); @@ -431,16 +430,17 @@ impl OutboundDeliveryTargetSetHandler { .await .map_err(|error| approval_store_error("save_gate_record", error))?; - Ok(CapabilityOutcome::ApprovalRequired { - gate_ref: approval_gate_ref(approval_request_id)?, - safe_summary: APPROVAL_GATE_SUMMARY.to_string(), - approval_resume: Some(CapabilityApprovalResume { + Ok(resolution::approval_required( + approval_gate_ref(approval_request_id)?, + APPROVAL_GATE_SUMMARY.to_string(), + Some(CapabilityApprovalResume { approval_request_id, resume_token: resume_token_from_invocation_id(invocation_id)?, correlation_id, input_ref: invocation.request.input_ref.clone(), }), - }) + ) + .resolution) } async fn verify_approved_resume( @@ -581,7 +581,7 @@ async fn write_completed_result( invocation: SyntheticCapabilityInvocation, output: serde_json::Value, safe_summary: String, -) -> Result { +) -> Result { let write_result = invocation .result_writer .write_capability_result(CapabilityResultWrite { @@ -594,15 +594,15 @@ async fn write_completed_result( durable_persistence: DurablePersistence::Persist, }) .await?; - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write_result.result_ref, + Ok(resolution::completed( + write_result.result_ref, safe_summary, - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write_result.byte_len, - output_digest: write_result.output_digest, - model_observation: write_result.model_observation, - })) + CapabilityProgress::MadeProgress, + false, + write_result.byte_len, + write_result.output_digest, + write_result.model_observation, + )) } /// The input a synthetic invocation dispatches from. The decorator already @@ -780,35 +780,33 @@ fn input_error(error: OutboundDeliveryCapabilityInputError) -> AgentLoopHostErro /// recoverable arm into a terminal `HostUnavailable` (Invariant 2). fn outbound_delivery_outcome( error: RebornServicesError, -) -> Result { +) -> Result { match error.code { RebornServicesErrorCode::InvalidRequest | RebornServicesErrorCode::NotFound => { - Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "invalid outbound delivery request".to_string(), - detail: None, - })) + Ok(resolution::failed( + CapabilityFailureKind::InvalidInput, + "invalid outbound delivery request".to_string(), + None, + )) } RebornServicesErrorCode::Unauthenticated | RebornServicesErrorCode::Forbidden => { - Ok(CapabilityOutcome::Denied(approval_denied( - "not permitted to change the outbound delivery target", - )?)) + approval_denied("not permitted to change the outbound delivery target") } - RebornServicesErrorCode::Conflict => Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::OperationFailed, - safe_summary: "outbound delivery target operation conflicted".to_string(), - detail: None, - })), - RebornServicesErrorCode::RateLimited => Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::Resource, - safe_summary: "outbound delivery target operation rate limited".to_string(), - detail: None, - })), - RebornServicesErrorCode::Unavailable => Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::Unavailable, - safe_summary: "outbound delivery service temporarily unavailable".to_string(), - detail: None, - })), + RebornServicesErrorCode::Conflict => Ok(resolution::failed( + CapabilityFailureKind::OperationFailed, + "outbound delivery target operation conflicted".to_string(), + None, + )), + RebornServicesErrorCode::RateLimited => Ok(resolution::failed( + CapabilityFailureKind::Resource, + "outbound delivery target operation rate limited".to_string(), + None, + )), + RebornServicesErrorCode::Unavailable => Ok(resolution::failed( + CapabilityFailureKind::Unavailable, + "outbound delivery service temporarily unavailable".to_string(), + None, + )), RebornServicesErrorCode::Internal => Err(AgentLoopHostError::new( AgentLoopHostErrorKind::Internal, "outbound delivery target operation failed", @@ -816,20 +814,18 @@ fn outbound_delivery_outcome( } } -/// Build a model-visible `CapabilityDenied` with a fixed, host-authored summary. +/// Build a model-visible denial `Resolution` with a fixed, host-authored summary. /// The reason kind is a charset-safe identifier, so it never trips /// safe-summary/identifier validation. -fn approval_denied(safe_summary: &str) -> Result { - Ok(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::unknown("outbound_delivery_approval_required") - .map_err(|reason| { - AgentLoopHostError::new( - AgentLoopHostErrorKind::Internal, - format!("outbound delivery denial reason kind is invalid: {reason}"), - ) - })?, - safe_summary: safe_summary.to_string(), - }) +fn approval_denied(safe_summary: &str) -> Result { + let reason_kind = CapabilityDeniedReasonKind::unknown("outbound_delivery_approval_required") + .map_err(|reason| { + AgentLoopHostError::new( + AgentLoopHostErrorKind::Internal, + format!("outbound delivery denial reason kind is invalid: {reason}"), + ) + })?; + Ok(resolution::denied(reason_kind, safe_summary.to_string()).resolution) } fn approval_store_error(operation: &'static str, error: RunStateError) -> AgentLoopHostError { @@ -848,12 +844,12 @@ fn approval_store_error(operation: &'static str, error: RunStateError) -> AgentL /// Lease-state arms (unknown / expired / exhausted / unclaimed-fingerprint / /// fingerprint-mismatch / inactive) describe a lost or stale approval lease, /// which the model can recover from by re-requesting approval — so they return -/// `Ok(CapabilityDenied)`. Genuine infra faults (lease persistence, version +/// a denial `Resolution`. Genuine infra faults (lease persistence, version /// mismatch, CAS exhaustion) stay terminal `Err(AgentLoopHostError)`. fn approval_lease_outcome( operation: &'static str, error: CapabilityLeaseError, -) -> Result { +) -> Result { match error { CapabilityLeaseError::UnknownLease { .. } | CapabilityLeaseError::ExpiredLease { .. } @@ -901,32 +897,50 @@ mod tests { } } + /// The §5.3 collapse maps a recoverable service failure onto + /// `Resolution::Done` with a `RecoverableFailure` verdict; the redacted + /// summary rides the outcome (already a host_api `SafeSummary`). + fn assert_recoverable_failure( + resolution: &Resolution, + expected: ironclaw_host_api::FailureKind, + ) { + match resolution { + Resolution::Done(outcome) => assert_eq!( + outcome.verdict, + ironclaw_host_api::ToolVerdict::recoverable_failure(expected) + ), + other => panic!("expected Resolution::Done recoverable failure, got {other:?}"), + } + } + + /// The model-visible summary carried on a recoverable failure / denial. + fn recoverable_summary(resolution: &Resolution) -> String { + match resolution { + Resolution::Done(outcome) => outcome.summary.as_str().to_string(), + Resolution::Denied(denial) => denial + .summary + .as_ref() + .map(|summary| summary.as_str().to_string()) + .unwrap_or_default(), + other => panic!("expected a recoverable outcome, got {other:?}"), + } + } + #[test] fn invalid_request_is_a_recoverable_tool_failure_not_terminal() { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::InvalidRequest)) .expect("invalid request must be a model-visible failure, not terminal"); - - match outcome { - CapabilityOutcome::Failed(failure) => { - assert_eq!(failure.error_kind, CapabilityFailureKind::InvalidInput); - LoopSafeSummary::new(failure.safe_summary) - .expect("safe summary must satisfy the loop validator"); - } - other => panic!("expected CapabilityOutcome::Failed, got {other:?}"), - } + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::InvalidInput); + LoopSafeSummary::new(recoverable_summary(&outcome)) + .expect("safe summary must satisfy the loop validator"); } #[test] fn not_found_is_a_recoverable_tool_failure_not_terminal() { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::NotFound)) .expect("not found must be a model-visible failure, not terminal"); - - assert!(matches!( - outcome, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::InvalidInput - )); + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::InvalidInput); } #[test] @@ -934,34 +948,23 @@ mod tests { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::Unauthenticated)) .expect("unauthenticated must be a model-visible denial, not terminal"); - - match outcome { - CapabilityOutcome::Denied(denied) => { - LoopSafeSummary::new(denied.safe_summary) - .expect("safe summary must satisfy the loop validator"); - } - other => panic!("expected CapabilityOutcome::Denied, got {other:?}"), - } + assert!(matches!(outcome, Resolution::Denied(_))); + LoopSafeSummary::new(recoverable_summary(&outcome)) + .expect("safe summary must satisfy the loop validator"); } #[test] fn forbidden_is_a_recoverable_denial_not_terminal() { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::Forbidden)) .expect("forbidden must be a model-visible denial, not terminal"); - - assert!(matches!(outcome, CapabilityOutcome::Denied(_))); + assert!(matches!(outcome, Resolution::Denied(_))); } #[test] fn conflict_is_a_recoverable_tool_failure_not_terminal() { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::Conflict)) .expect("conflict must be a model-visible failure, not terminal"); - - assert!(matches!( - outcome, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::OperationFailed - )); + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::OperationFailed); } #[test] @@ -969,12 +972,7 @@ mod tests { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::RateLimited)) .expect("rate limited must be a model-visible failure, not terminal"); - - assert!(matches!( - outcome, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::Resource - )); + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::Resource); } #[test] @@ -982,12 +980,7 @@ mod tests { let outcome = outbound_delivery_outcome(service_error(RebornServicesErrorCode::Unavailable)) .expect("transient unavailability must not kill the run"); - - assert!(matches!( - outcome, - CapabilityOutcome::Failed(failure) - if failure.error_kind == CapabilityFailureKind::Unavailable - )); + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::Unavailable); } #[test] @@ -1015,11 +1008,7 @@ mod tests { ] { let outcome = outbound_delivery_outcome(service_error(code)) .unwrap_or_else(|_| panic!("{code:?} must be recoverable")); - let summary = match outcome { - CapabilityOutcome::Failed(failure) => failure.safe_summary, - CapabilityOutcome::Denied(denied) => denied.safe_summary, - other => panic!("expected a recoverable outcome, got {other:?}"), - }; + let summary = recoverable_summary(&outcome); assert!( !summary.contains("slack/"), "summary must not interpolate the service error field: {summary}" @@ -1053,7 +1042,8 @@ mod tests { let denied = approval_lease_outcome("claim_approval_lease", lease_error_unknown()) .expect("an expired approval lease must be a model-visible denial, not terminal"); - LoopSafeSummary::new(denied.safe_summary) + assert!(matches!(denied, Resolution::Denied(_))); + LoopSafeSummary::new(recoverable_summary(&denied)) .expect("denial safe summary must satisfy the loop validator"); } diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/project_create.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/project_create.rs index 1eec2b992f..7134adc953 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/project_create.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/project_create.rs @@ -1,15 +1,14 @@ use std::sync::Arc; use async_trait::async_trait; -use ironclaw_host_api::{InvocationId, UserId}; +use ironclaw_host_api::{InvocationId, Resolution, UserId}; use ironclaw_loop_host::{CapabilityResultWrite, DurablePersistence}; use ironclaw_product_workflow::{ ProjectCaller, ProjectService, ProjectServiceError, RebornCreateProjectRequest, }; use ironclaw_turns::run_profile::{ - AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailure, CapabilityFailureKind, - CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, ConcurrencyHint, - LoopRunContext, + AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailureKind, CapabilityProgress, + ConcurrencyHint, LoopRunContext, resolution, }; use crate::runtime::local_dev::synthetic_capability::{ @@ -62,7 +61,7 @@ impl SyntheticCapabilityHandler for ProjectCreateHandler { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { let input = parse_project_create_input(&invocation.input)?; // Identity is authority-bearing: the caller is derived from the trusted // run scope, never from the model's arguments. The capability accepts @@ -109,15 +108,15 @@ impl SyntheticCapabilityHandler for ProjectCreateHandler { durable_persistence: DurablePersistence::Persist, }) .await?; - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write_result.result_ref, + Ok(resolution::completed( + write_result.result_ref, safe_summary, - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write_result.byte_len, - output_digest: write_result.output_digest, - model_observation: write_result.model_observation, - })) + CapabilityProgress::MadeProgress, + false, + write_result.byte_len, + write_result.output_digest, + write_result.model_observation, + )) } } @@ -189,7 +188,7 @@ fn project_create_input_schema() -> serde_json::Value { /// the turn. fn project_service_outcome( error: ProjectServiceError, -) -> Result { +) -> Result { let (error_kind, safe_summary) = match error { // Keep the safe summary fixed and host-authored — `field` is a // free-form `String` and could carry a forbidden delimiter/marker @@ -224,11 +223,7 @@ fn project_service_outcome( )); } }; - Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind, - safe_summary, - detail: None, - })) + Ok(resolution::failed(error_kind, safe_summary, None)) } /// Resolve the user the run acts on behalf of: the explicit thread owner, else @@ -293,12 +288,7 @@ mod tests { }) .expect("invalid input must be a model-visible failure, not terminal"); - match outcome { - CapabilityOutcome::Failed(failure) => { - assert_eq!(failure.error_kind, CapabilityFailureKind::InvalidInput); - } - other => panic!("expected CapabilityOutcome::Failed, got {other:?}"), - } + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::InvalidInput); } #[test] @@ -306,11 +296,22 @@ mod tests { let outcome = project_service_outcome(ProjectServiceError::Unavailable) .expect("transient unavailability must not kill the run"); - match outcome { - CapabilityOutcome::Failed(failure) => { - assert_eq!(failure.error_kind, CapabilityFailureKind::Unavailable); - } - other => panic!("expected CapabilityOutcome::Failed, got {other:?}"), + assert_recoverable_failure(&outcome, ironclaw_host_api::FailureKind::Unavailable); + } + + /// A recoverable model-visible failure is `Resolution::Done` carrying the + /// expected `RecoverableFailure` verdict (the §5.3 collapse of the old + /// `CapabilityOutcome::Failed`). + fn assert_recoverable_failure( + resolution: &ironclaw_host_api::Resolution, + expected: ironclaw_host_api::FailureKind, + ) { + match resolution { + ironclaw_host_api::Resolution::Done(outcome) => assert_eq!( + outcome.verdict, + ironclaw_host_api::ToolVerdict::recoverable_failure(expected) + ), + other => panic!("expected Resolution::Done recoverable failure, got {other:?}"), } } diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/result_read.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/result_read.rs index 1788187aae..af33b2582b 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/result_read.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/result_read.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use async_trait::async_trait; -use ironclaw_host_api::{DispatchInputIssueCode, InvocationId, UserId}; +use ironclaw_host_api::{DispatchInputIssueCode, InvocationId, Resolution, UserId}; use ironclaw_loop_host::{CapabilityResultWrite, DurablePersistence}; use ironclaw_threads::{ MessageKind, MessageStatus, ReadToolResultRecordRequest, SessionThreadError, @@ -9,11 +9,11 @@ use ironclaw_threads::{ ToolResultReferenceEnvelope, }; use ironclaw_turns::run_profile::{ - AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailure, CapabilityFailureDetail, - CapabilityFailureKind, CapabilityInputIssue, CapabilityOutcome, CapabilityProgress, - CapabilityResultMessage, ConcurrencyHint, MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, - ModelVisibleArtifact, ModelVisibleToolObservation, ObservationTrust, ToolObservationDetail, - ToolObservationStatus, sanitize_model_visible_text, + AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailureDetail, CapabilityFailureKind, + CapabilityInputIssue, CapabilityProgress, ConcurrencyHint, + MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, ModelVisibleArtifact, ModelVisibleToolObservation, + ObservationTrust, ToolObservationDetail, ToolObservationStatus, resolution, + sanitize_model_visible_text, }; use super::{ @@ -111,10 +111,10 @@ impl SyntheticCapabilityHandler for ResultReadHandler { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { let input = match parse_result_read_input(&invocation.input) { Ok(input) => input, - Err(failure) => return Ok(CapabilityOutcome::Failed(failure)), + Err(resolution) => return Ok(*resolution), }; let scope = local_dev_thread_scope_for_run(&invocation.run_context, &self.fallback_user_id) .ok_or_else(|| { @@ -206,15 +206,15 @@ impl SyntheticCapabilityHandler for ResultReadHandler { next_offset, sanitize_model_visible_text(content), )); - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write.result_ref, - safe_summary: "result chunk returned".to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write.byte_len, - output_digest: write.output_digest, - model_observation: write.model_observation, - })) + Ok(resolution::completed( + write.result_ref, + "result chunk returned".to_string(), + CapabilityProgress::MadeProgress, + false, + write.byte_len, + write.output_digest, + write.model_observation, + )) } } @@ -247,20 +247,20 @@ fn result_read_observation( } } -fn unavailable_result_reference() -> CapabilityOutcome { - CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "result reference is unavailable in this thread".to_string(), - detail: None, - }) +fn unavailable_result_reference() -> Resolution { + resolution::failed( + CapabilityFailureKind::InvalidInput, + "result reference is unavailable in this thread".to_string(), + None, + ) } -fn non_text_result_content() -> CapabilityOutcome { - CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "stored tool result cannot be returned as text".to_string(), - detail: None, - }) +fn non_text_result_content() -> Resolution { + resolution::failed( + CapabilityFailureKind::InvalidInput, + "stored tool result cannot be returned as text".to_string(), + None, + ) } fn storage_unavailable_error( @@ -280,17 +280,18 @@ struct ResultReadInput { max_bytes: u64, } -/// Builds the `InvalidInput` `CapabilityFailure` every +/// Builds the `InvalidInput` recoverable-failure `Resolution` every /// `parse_result_read_input` error arm returns, carrying one structured -/// repair issue. -fn invalid_input_failure(safe_summary: &str, issue: CapabilityInputIssue) -> CapabilityFailure { - CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: safe_summary.to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { +/// repair issue. Boxed because a `Resolution` in the `Err` position of the +/// parse result is large (`clippy::result_large_err`). +fn invalid_input_failure(safe_summary: &str, issue: CapabilityInputIssue) -> Box { + Box::new(resolution::failed( + CapabilityFailureKind::InvalidInput, + safe_summary.to_string(), + Some(CapabilityFailureDetail::InvalidInput { issues: vec![issue], }), - } + )) } /// JSON type name for a `CapabilityInputIssue::received` value, distinct from @@ -330,7 +331,7 @@ fn safe_issue_path(key: &str) -> String { fn parse_result_read_input( value: &serde_json::Value, -) -> Result { +) -> Result> { let object = value.as_object().ok_or_else(|| { invalid_input_failure( "result_read arguments must be an object", diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/skill_activation.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/skill_activation.rs index 7fe753d3d7..ec95b75235 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/skill_activation.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/skill_activation.rs @@ -1,11 +1,10 @@ use std::{collections::HashSet, sync::Arc}; use async_trait::async_trait; -use ironclaw_host_api::InvocationId; +use ironclaw_host_api::{InvocationId, Resolution}; use ironclaw_loop_host::{CapabilityResultWrite, DurablePersistence}; use ironclaw_turns::run_profile::{ - AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailure, CapabilityFailureKind, - CapabilityOutcome, CapabilityResultMessage, ConcurrencyHint, + AgentLoopHostError, AgentLoopHostErrorKind, CapabilityFailureKind, ConcurrencyHint, resolution, }; use crate::runtime::{ @@ -55,7 +54,7 @@ impl SyntheticCapabilityHandler for SkillActivationHandler { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { // Normalise to lowercase at the parse boundary so that `names` (passed // to `activate_skills_for_run`) and the response-filter set both use the // same canonical form. `activate_skills_for_run` matches with @@ -107,15 +106,15 @@ impl SyntheticCapabilityHandler for SkillActivationHandler { durable_persistence: DurablePersistence::Persist, }) .await?; - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write_result.result_ref, - safe_summary: format!("activated {} skill(s)", activated.len()), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write_result.byte_len, - output_digest: write_result.output_digest, - model_observation: write_result.model_observation, - })) + Ok(resolution::completed( + write_result.result_ref, + format!("activated {} skill(s)", activated.len()), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + write_result.byte_len, + write_result.output_digest, + write_result.model_observation, + )) } } @@ -226,20 +225,19 @@ fn skill_activation_host_error( /// adjusting its request. fn skill_activation_selection_outcome( error: ironclaw_first_party_extension_ports::SkillActivationSelectionError, -) -> Result { +) -> Result { use ironclaw_first_party_extension_ports::SkillActivationSelectionError as SelectionError; match error { - SelectionError::ContextBudgetExceeded => Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "skill activation exceeds the per-run skill context budget; activate fewer or smaller skills".to_string(), - detail: None, - })), - SelectionError::AmbiguousSkill { .. } => Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "ambiguous skill name; specify a single unique skill to activate" - .to_string(), - detail: None, - })), + SelectionError::ContextBudgetExceeded => Ok(resolution::failed( + CapabilityFailureKind::InvalidInput, + "skill activation exceeds the per-run skill context budget; activate fewer or smaller skills".to_string(), + None, + )), + SelectionError::AmbiguousSkill { .. } => Ok(resolution::failed( + CapabilityFailureKind::InvalidInput, + "ambiguous skill name; specify a single unique skill to activate".to_string(), + None, + )), other => Err(skill_activation_host_error(other)), } } @@ -289,12 +287,7 @@ mod tests { ) .expect("budget-exceeded must be a model-visible failure, not a terminal host error"); - match outcome { - CapabilityOutcome::Failed(failure) => { - assert_eq!(failure.error_kind, CapabilityFailureKind::InvalidInput); - } - other => panic!("expected CapabilityOutcome::Failed, got {other:?}"), - } + assert_recoverable_invalid_input(&outcome); } #[test] @@ -307,11 +300,21 @@ mod tests { ) .expect("ambiguous skill must be a model-visible failure, not a terminal host error"); - match outcome { - CapabilityOutcome::Failed(failure) => { - assert_eq!(failure.error_kind, CapabilityFailureKind::InvalidInput); - } - other => panic!("expected CapabilityOutcome::Failed, got {other:?}"), + assert_recoverable_invalid_input(&outcome); + } + + /// A recoverable model-visible failure is `Resolution::Done` carrying a + /// `RecoverableFailure(InvalidInput)` verdict (the §5.3 collapse of the old + /// `CapabilityOutcome::Failed { InvalidInput }`). + fn assert_recoverable_invalid_input(resolution: &ironclaw_host_api::Resolution) { + match resolution { + ironclaw_host_api::Resolution::Done(outcome) => assert_eq!( + outcome.verdict, + ironclaw_host_api::ToolVerdict::recoverable_failure( + ironclaw_host_api::FailureKind::InvalidInput + ) + ), + other => panic!("expected Resolution::Done recoverable failure, got {other:?}"), } } diff --git a/crates/ironclaw_reborn_composition/src/runtime/local_dev/synthetic_capability.rs b/crates/ironclaw_reborn_composition/src/runtime/local_dev/synthetic_capability.rs index 2dab3ef3b3..76340235e3 100644 --- a/crates/ironclaw_reborn_composition/src/runtime/local_dev/synthetic_capability.rs +++ b/crates/ironclaw_reborn_composition/src/runtime/local_dev/synthetic_capability.rs @@ -11,10 +11,10 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, CapabilityCallCandidate, CapabilityDescriptorView, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilitySurfaceVersion, ConcurrencyHint, - LoopCapabilityPort, LoopRunContext, ProviderToolCall, ProviderToolCallCapabilityIds, - ProviderToolCallReplay, ProviderToolDefinition, RegisterProviderToolCallRequest, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, + CapabilityInvocation, CapabilitySurfaceVersion, ConcurrencyHint, LoopCapabilityPort, + LoopRunContext, ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolCallReplay, + ProviderToolDefinition, RegisterProviderToolCallRequest, VisibleCapabilityRequest, + VisibleCapabilitySurface, }, }; @@ -163,7 +163,7 @@ pub(super) trait SyntheticCapabilityHandler: Send + Sync { async fn invoke( &self, invocation: SyntheticCapabilityInvocation, - ) -> Result; + ) -> Result; } struct SyntheticCapabilityPort { @@ -567,18 +567,15 @@ impl LoopCapabilityPort for SyntheticCapabilityPort { ); } } - // Synthetic handlers still produce `CapabilityOutcome` (unchanged - // internal trait); collapse it to the host `Resolution` at this - // `LoopCapabilityPort` boundary. - let outcome = handler + // Synthetic handlers emit the host `Resolution` directly (§5.3 Stage 2b). + handler .invoke(SyntheticCapabilityInvocation { run_context: self.run_context.clone(), request, input, result_writer: Arc::clone(&self.result_writer), }) - .await?; - Ok(capability_outcome_to_resolution(outcome).resolution) + .await } async fn invoke_capability_batch( @@ -610,6 +607,7 @@ mod tests { use super::*; use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId}; + use ironclaw_turns::run_profile::resolution; use ironclaw_loop_host::{ CapabilityResultWrite, CapabilityWriteResult, EmptyLoopCapabilityPort, }; @@ -694,7 +692,7 @@ mod tests { async fn invoke( &self, _invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { Err(AgentLoopHostError::new( AgentLoopHostErrorKind::Internal, "test handler should not be invoked", @@ -718,19 +716,16 @@ mod tests { async fn invoke( &self, _invocation: SyntheticCapabilityInvocation, - ) -> Result { + ) -> Result { self.invocations.fetch_add(1, Ordering::SeqCst); - Ok(CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: LoopResultRef::new("result:synthetic-handler") - .expect("valid result ref"), - safe_summary: "synthetic handler completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }, + Ok(resolution::completed( + LoopResultRef::new("result:synthetic-handler").expect("valid result ref"), + "synthetic handler completed".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, )) } } From 556cc9d801c904e6b23066067d08f3370bec5623 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:05:47 +0000 Subject: [PATCH 06/12] =?UTF-8?q?refactor(reborn):=20runner=20tool-disclos?= =?UTF-8?q?ure=20+=20subagent=20flavors=20emit=20Resolution=20directly=20(?= =?UTF-8?q?=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tool_disclosure_port bridge helpers (invoke_bridge/tool_search/describe/ describe_first/completed_bridge_result, failed_invalid_input) and the test double now build host_api Resolution via the producer constructors; subagent flavors test double likewise. No capability_outcome_to_resolution re-map remains in runner producers. Runner tests + clippy -D warnings green (all-features). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironclaw_runner/src/subagent/flavors.rs | 26 +++--- .../src/tool_disclosure_port.rs | 89 +++++++++---------- 2 files changed, 55 insertions(+), 60 deletions(-) diff --git a/crates/ironclaw_runner/src/subagent/flavors.rs b/crates/ironclaw_runner/src/subagent/flavors.rs index 644c3971ff..16ec5af3dc 100644 --- a/crates/ironclaw_runner/src/subagent/flavors.rs +++ b/crates/ironclaw_runner/src/subagent/flavors.rs @@ -429,10 +429,9 @@ mod tests { }; use ironclaw_turns::run_profile::{ AgentLoopHostError, CapabilityBatchInvocation, CapabilityDescriptorView, - CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, - CapabilitySurfaceVersion, ConcurrencyHint, LoopCapabilityPort, LoopDriverId, - ProviderToolDefinition, VisibleCapabilityRequest, VisibleCapabilitySurface, - capability_outcome_to_resolution, + CapabilityInputRef, CapabilityInvocation, CapabilitySurfaceVersion, ConcurrencyHint, + LoopCapabilityPort, LoopDriverId, ProviderToolDefinition, VisibleCapabilityRequest, + VisibleCapabilitySurface, resolution, }; use ironclaw_turns::{LoopResultRef, RunProfileId, RunProfileVersion}; @@ -531,16 +530,15 @@ mod tests { .lock() .expect("invoked lock") .push(request.capability_id.as_str().to_string()); - let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:ok").expect("valid result ref"), - safe_summary: "ok".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }); - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(resolution::completed( + LoopResultRef::new("result:ok").expect("valid result ref"), + "ok".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + )) } async fn invoke_capability_batch( diff --git a/crates/ironclaw_runner/src/tool_disclosure_port.rs b/crates/ironclaw_runner/src/tool_disclosure_port.rs index 886234a2ce..3d66a4f234 100644 --- a/crates/ironclaw_runner/src/tool_disclosure_port.rs +++ b/crates/ironclaw_runner/src/tool_disclosure_port.rs @@ -16,12 +16,11 @@ use ironclaw_turns::{ CapabilityActivityId, TurnId, run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, - CapabilityCallCandidate, CapabilityFailure, CapabilityFailureKind, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, - CapabilitySurfaceVersion, LoopCapabilityPort, LoopRunContext, ProviderToolCall, - ProviderToolCallCapabilityIds, ProviderToolCallReplay, ProviderToolDefinition, - RegisterProviderToolCallRequest, VisibleCapabilityRequest, VisibleCapabilitySurface, - capability_outcome_to_resolution, + CapabilityCallCandidate, CapabilityFailureKind, CapabilityInputRef, CapabilityInvocation, + CapabilityProgress, CapabilitySurfaceVersion, LoopCapabilityPort, LoopRunContext, + ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolCallReplay, + ProviderToolDefinition, RegisterProviderToolCallRequest, VisibleCapabilityRequest, + VisibleCapabilitySurface, resolution, }, }; use serde_json::{Value, json}; @@ -543,8 +542,7 @@ impl LoopCapabilityPort for ToolDisclosureCapabilityPort { } return Ok(resolution); } - let outcome = self.invoke_bridge(request).await?; - Ok(capability_outcome_to_resolution(outcome).resolution) + self.invoke_bridge(request).await } async fn invoke_capability_batch( @@ -895,7 +893,7 @@ impl ToolDisclosureCapabilityPort { async fn invoke_bridge( &self, request: CapabilityInvocation, - ) -> Result { + ) -> Result { let bridge = self .bridge_inputs .lock() @@ -917,7 +915,7 @@ impl ToolDisclosureCapabilityPort { &self, request: &CapabilityInvocation, bridge: &BridgeInvocation, - ) -> Result { + ) -> Result { let Some(query) = bridge.arguments.get("query").and_then(Value::as_str) else { return Ok(failed_invalid_input("tool_search requires query")); }; @@ -963,7 +961,7 @@ impl ToolDisclosureCapabilityPort { &self, request: &CapabilityInvocation, bridge: &BridgeInvocation, - ) -> Result { + ) -> Result { let Some(name) = bridge.arguments.get("name").and_then(Value::as_str) else { return Ok(failed_invalid_input("tool_describe requires name")); }; @@ -1003,7 +1001,7 @@ impl ToolDisclosureCapabilityPort { &self, request: &CapabilityInvocation, bridge: &BridgeInvocation, - ) -> Result { + ) -> Result { let Some(name) = bridge.arguments.get("name").and_then(Value::as_str) else { return Ok(failed_invalid_input("auto-schema requires a target name")); }; @@ -1035,7 +1033,7 @@ impl ToolDisclosureCapabilityPort { request: &CapabilityInvocation, output: Value, safe_summary: &'static str, - ) -> Result { + ) -> Result { let write = self .result_writer .write_capability_result(CapabilityResultWrite { @@ -1048,15 +1046,15 @@ impl ToolDisclosureCapabilityPort { durable_persistence: DurablePersistence::Persist, }) .await?; - Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: write.result_ref, - safe_summary: safe_summary.to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: write.byte_len, - output_digest: write.output_digest, - model_observation: write.model_observation, - })) + Ok(resolution::completed( + write.result_ref, + safe_summary.to_string(), + CapabilityProgress::MadeProgress, + false, + write.byte_len, + write.output_digest, + write.model_observation, + )) } fn target_call( @@ -1291,12 +1289,12 @@ fn provider_call_digest_input(provider_call_id: &str, name: &str, arguments: &Va .to_string() } -fn failed_invalid_input(summary: &'static str) -> CapabilityOutcome { - CapabilityOutcome::Failed(CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: summary.to_string(), - detail: None, - }) +fn failed_invalid_input(summary: &'static str) -> Resolution { + resolution::failed( + CapabilityFailureKind::InvalidInput, + summary.to_string(), + None, + ) } fn invalid_invocation(summary: impl Into) -> AgentLoopHostError { @@ -1473,25 +1471,24 @@ mod tests { .lock() .expect("invocations lock") .push(request); - let outcome = if suspends { - CapabilityOutcome::ApprovalRequired { - gate_ref: ironclaw_turns::LoopGateRef::new("gate:test") - .expect("valid gate ref"), - safe_summary: "approval needed".to_string(), - approval_resume: None, - } + if suspends { + Ok(resolution::approval_required( + ironclaw_turns::LoopGateRef::new("gate:test").expect("valid gate ref"), + "approval needed".to_string(), + None, + ) + .resolution) } else { - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:target").expect("valid result ref"), - safe_summary: "target completed".to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 2, - output_digest: None, - model_observation: None, - }) - }; - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(resolution::completed( + LoopResultRef::new("result:target").expect("valid result ref"), + "target completed".to_string(), + CapabilityProgress::MadeProgress, + false, + 2, + None, + None, + )) + } } async fn invoke_capability_batch( From 57632131cb20e247c84a60eec1235287f44816b3 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:10:11 +0000 Subject: [PATCH 07/12] =?UTF-8?q?refactor(reborn):=20agent=5Floop=20execut?= =?UTF-8?q?or=20test=20fixtures=20build=20Resolution=20via=20constructors?= =?UTF-8?q?=20(=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shared_await_dependent_gate fixtures (await_dependent/completed/approval) now use the producer constructors directly instead of mapping a CapabilityOutcome. 401 agent_loop tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/executor/capabilities.rs | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/crates/ironclaw_agent_loop/src/executor/capabilities.rs b/crates/ironclaw_agent_loop/src/executor/capabilities.rs index 7302a03c22..12676da4b5 100644 --- a/crates/ironclaw_agent_loop/src/executor/capabilities.rs +++ b/crates/ironclaw_agent_loop/src/executor/capabilities.rs @@ -1705,10 +1705,7 @@ mod tests { use super::*; use ironclaw_turns::{ LoopGateRef, LoopResultRef, - run_profile::{ - CapabilityInputRef, CapabilityOutcome, CapabilitySurfaceVersion, - capability_outcome_to_resolution, - }, + run_profile::{CapabilityInputRef, CapabilitySurfaceVersion, resolution}, }; fn call(input: &str) -> CapabilityCallCandidate { @@ -1723,31 +1720,30 @@ mod tests { } } - // The fixtures build a loop-facing `CapabilityOutcome` and map it through the - // production mapping so `shared_await_dependent_gate` sees the exact - // `Resolution` the flip produces (origin preserved on the channel). + // The fixtures build the exact `Resolution` the producer constructors + // emit so `shared_await_dependent_gate` sees the flip's channel shape + // (origin preserved on the channel). fn await_dependent(gate: &str, result: &str) -> Resolution { - capability_outcome_to_resolution(CapabilityOutcome::AwaitDependentRun { - gate_ref: LoopGateRef::new(gate).unwrap(), - result_ref: LoopResultRef::new(format!("result:{result}")).unwrap(), - safe_summary: "summary".to_string(), - byte_len: 0, - model_observation: None, - }) + resolution::await_dependent_run( + LoopGateRef::new(gate).unwrap(), + LoopResultRef::new(format!("result:{result}")).unwrap(), + "summary".to_string(), + 0, + None, + ) .resolution } fn completed(result: &str) -> Resolution { - capability_outcome_to_resolution(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(format!("result:{result}")).unwrap(), - safe_summary: "summary".to_string(), - progress: CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })) - .resolution + resolution::completed( + LoopResultRef::new(format!("result:{result}")).unwrap(), + "summary".to_string(), + CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + ) } #[test] @@ -1788,11 +1784,11 @@ mod tests { let calls = vec![call("a"), call("b")]; let outcomes = vec![ await_dependent("gate:1", "r1"), - capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:approval").unwrap(), - safe_summary: "approval".to_string(), - approval_resume: None, - }) + resolution::approval_required( + LoopGateRef::new("gate:approval").unwrap(), + "approval".to_string(), + None, + ) .resolution, ]; assert!(shared_await_dependent_gate(&calls, &outcomes).is_none()); From 92d7ed1d032c65a4c3db2aa3f7949cc6c471f3ef Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:22:41 +0000 Subject: [PATCH 08/12] =?UTF-8?q?test(reborn):=20migrate=20agent=5Floop=20?= =?UTF-8?q?capability=20fixtures=20off=20CapabilityOutcome=20(=C2=A75.3=20?= =?UTF-8?q?Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockHost stores Resolution/ResolutionBatch directly; scripted_capability_outcome maps ScriptedCapabilityOutcome -> Resolution via the producer constructors; ~90 executor test fixtures rebuilt as resolution::* + ironclaw_host_api::ResolutionBatch (transformed by a comment/brace-aware script). resolution_from_scripted_outcome deleted; resolution_batch_from_scripted takes Resolutions. 401 agent_loop tests green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironclaw_agent_loop/src/executor/tests.rs | 903 ++++-------------- .../src/executor/tests/cancellation.rs | 25 +- .../src/executor/tests/failure_matrix.rs | 61 +- .../src/executor/tests/support.rs | 27 +- .../src/test_support/mod.rs | 123 +-- .../tests/thread_loop_host_contract.rs | 10 +- .../tests/support/planned_agent_loop.rs | 12 +- .../tests/hooks_integration.rs | 24 +- .../tests/agent_loop_host_contract.rs | 6 +- 9 files changed, 247 insertions(+), 944 deletions(-) diff --git a/crates/ironclaw_agent_loop/src/executor/tests.rs b/crates/ironclaw_agent_loop/src/executor/tests.rs index d2328caa6b..80c590ecbf 100644 --- a/crates/ironclaw_agent_loop/src/executor/tests.rs +++ b/crates/ironclaw_agent_loop/src/executor/tests.rs @@ -10,16 +10,15 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityApprovalResume, CapabilityAuthResume, CapabilityCallCandidate, CapabilityFailureDetail, CapabilityFailureKind, - CapabilityInputIssue, CapabilityInputRef, CapabilityInputRepair, CapabilityOutcome, - CapabilityRecoveryHint, CapabilityResultMessage, CapabilityResumeToken, - LoopCancelReasonKind, LoopCancellationSignal, LoopCheckpointKind, LoopCompactionError, - LoopCompactionOutcome, LoopCompactionResponse, LoopContextCompactionKind, LoopInput, - LoopInputAckToken, LoopInputBatch, LoopInputCursor, LoopInterruptKind, LoopProcessRef, - LoopProgressEvent, LoopRunInfoPort, LoopSafeSummary, LoopSummaryArtifactId, - MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, ModelVisibleToolObservation, - ObservationTrust, ParentLoopOutput, ProcessHandleSummary, PromptMode, + CapabilityInputIssue, CapabilityInputRef, CapabilityInputRepair, CapabilityRecoveryHint, + CapabilityResumeToken, LoopCancelReasonKind, LoopCancellationSignal, LoopCheckpointKind, + LoopCompactionError, LoopCompactionOutcome, LoopCompactionResponse, + LoopContextCompactionKind, LoopInput, LoopInputAckToken, LoopInputBatch, LoopInputCursor, + LoopInterruptKind, LoopProcessRef, LoopProgressEvent, LoopRunInfoPort, LoopSafeSummary, + LoopSummaryArtifactId, MODEL_VISIBLE_TOOL_OBSERVATION_SCHEMA_VERSION, + ModelVisibleToolObservation, ObservationTrust, ParentLoopOutput, PromptMode, ProviderToolCallReplay, SameCallRetryConstraint, ToolObservationDetail, - ToolObservationStatus, VisibleCapabilityRequest, + ToolObservationStatus, VisibleCapabilityRequest, resolution, }, }; @@ -1626,16 +1625,8 @@ async fn assistant_reply_stage_returns_reply_summary() { async fn reply_admission_rejects_candidate_before_finalizing_and_continues() { let result_ref = LoopResultRef::new("result:done").expect("valid"); let host = MockHost::new(vec![reply_response(), calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); let family = family_with_reply_admission(FixedReplyAdmissionPolicy::RejectFirst); @@ -1725,16 +1716,8 @@ async fn cumulative_usage_counts_capability_call_and_reply_turns() { ..reply_response() }; let host = MockHost::new(vec![calls, reply]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref, - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref, "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -1768,16 +1751,8 @@ async fn cumulative_usage_counts_capability_call_and_reply_turns() { async fn reply_admission_rendered_flag_stays_false_when_context_suppresses_control_message() { let result_ref = LoopResultRef::new("result:done").expect("valid"); let host = MockHost::new(vec![reply_response(), calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); let family = @@ -1947,16 +1922,8 @@ async fn prompt_stage_host_unavailable_on_build_prompt_bundle_propagates_error() async fn capability_stage_returns_after_batch_summary() { let result_ref = LoopResultRef::new("result:done").expect("valid"); let host = MockHost::new(Vec::new()).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -2295,16 +2262,8 @@ async fn completion_nudge_lets_model_use_tools_to_finish_after_trailing_off() { reply_response_with_text("Done — wrote the recommendations to the output file."), ]) .with_driver_nudges_enabled() - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "wrote file".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "wrote file".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); let executor = CanonicalAgentLoopExecutor; @@ -2576,16 +2535,8 @@ async fn exit_stage_aborted_exits_with_requested_failure_kind() { async fn stopped_on_suspension_completed_outcome_still_appends_result() { let result_ref = LoopResultRef::new("result:stopped-completed").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "stopped batch completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "stopped batch completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: true, }, ]); @@ -2668,16 +2619,8 @@ async fn stop_stage_preserves_ack_and_returns_stop_kind() { #[tokio::test] async fn terminate_hint_after_batch_completes_without_extra_model_call() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:done").expect("valid"), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:done").expect("valid"), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -2730,12 +2673,8 @@ async fn terminate_hint_after_batch_completes_without_extra_model_call() { #[tokio::test] async fn gate_blocks_with_before_block_checkpoint() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:approval").expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new("gate:approval").expect("valid"), "approval required".to_string(), None).resolution], stopped_on_suspension: true, }, ]); @@ -2797,33 +2736,16 @@ async fn approval_resume_metadata_is_replayed_after_before_block_checkpoint() { }; let completed_ref = LoopResultRef::new("result:approval-resumed").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - // Post-§5.3 Stage 2 flip: `approval_request_id` is reconstructed - // from the `gate:approval-{uuid}` routing ref, not carried on the - // channel. The scripted gate ref must encode the SAME id the - // fixture declares (matches production - // `ironclaw_loop_host/src/capability_port.rs` `gate:approval-{id}`). - gate_ref: LoopGateRef::new(format!( + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new(format!( "gate:approval-{}", approval_resume.approval_request_id )) - .expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: Some(approval_resume.clone()), - }], + .expect("valid"), "approval required".to_string(), Some(approval_resume.clone())).resolution], stopped_on_suspension: true, }, - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "approval resumed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref.clone(), "approval resumed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -2916,19 +2838,12 @@ async fn approval_gate_before_block_checkpoint_disposition_is_none() { input_ref: CapabilityInputRef::new("input:disposition-none-test").expect("valid"), }; let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - // Post-§5.3 Stage 2 flip: the gate ref must encode the fixture's - // `approval_request_id` so `pending_approval_resume` reconstructs - // (a non-`gate:approval-{uuid}` ref yields `None`). - gate_ref: LoopGateRef::new(format!( + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new(format!( "gate:approval-{}", approval_resume.approval_request_id )) - .expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: Some(approval_resume.clone()), - }], + .expect("valid"), "approval required".to_string(), Some(approval_resume.clone())).resolution], stopped_on_suspension: true, }, ]); @@ -3054,22 +2969,10 @@ async fn gate_stage_aborts_returns_failed_exit() { async fn parallel_batch_records_completed_results_before_blocking_on_suspension() { let completed_ref = LoopResultRef::new("result:parallel-completed").expect("valid"); // safety: test-only fixture let host = MockHost::new(vec![two_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:approval").expect("valid"), // safety: test-only fixture - safe_summary: "approval required".to_string(), - approval_resume: None, - }, - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "parallel call completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), + ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::approval_required(LoopGateRef::new("gate:approval").expect("valid"), "approval required".to_string(), None).resolution, + resolution::completed(completed_ref.clone(), "parallel call completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), ], stopped_on_suspension: false, }, @@ -3094,8 +2997,8 @@ async fn parallel_batch_records_completed_results_before_blocking_on_suspension( #[tokio::test] async fn non_empty_capability_batch_rejects_empty_outcomes() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: Vec::new(), + ironclaw_host_api::ResolutionBatch { + resolutions: Vec::new(), stopped_on_suspension: true, }, ]); @@ -3120,26 +3023,10 @@ async fn non_empty_capability_batch_rejects_empty_outcomes() { #[tokio::test] async fn capability_batch_rejects_outcome_count_exceeding_invocation_count() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:first").expect("valid"), - safe_summary: "first".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:second").expect("valid"), - safe_summary: "second".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), + ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::completed(LoopResultRef::new("result:first").expect("valid"), "first".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), + resolution::completed(LoopResultRef::new("result:second").expect("valid"), "second".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), ], stopped_on_suspension: true, }, @@ -3800,16 +3687,8 @@ async fn stale_surface_capability_call_is_policy_denied_before_host_invocation() #[tokio::test] async fn terminate_hint_counts_only_visible_invoked_calls() { let host = MockHost::new(vec![mixed_surface_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:visible").expect("valid"), - safe_summary: "visible call completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:visible").expect("valid"), "visible call completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -3882,27 +3761,11 @@ async fn retry_uses_single_call_invocation() { CapabilityFailureKind::Network, ] { let host = MockHost::new(vec![calls_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind, - safe_summary: "temporary failure".to_string(), - detail: None, - }, - )], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(error_kind, "temporary failure".to_string(), None)], stopped_on_suspension: false, }]) - .with_single_outcomes(vec![CapabilityOutcome::Completed( - CapabilityResultMessage { - result_ref: LoopResultRef::new("result:retry").expect("valid"), - safe_summary: "retry completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )]); + .with_single_outcomes(vec![resolution::completed(LoopResultRef::new("result:retry").expect("valid"), "retry completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)]); let executor = CanonicalAgentLoopExecutor; let state = LoopExecutionState::initial_for_run(host.run_context()); @@ -3919,27 +3782,11 @@ async fn retry_uses_single_call_invocation() { #[tokio::test] async fn policy_denied_capability_error_honors_retry_recovery() { let host = MockHost::new(vec![calls_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Denied( - ironclaw_turns::run_profile::CapabilityDenied { - reason_kind: - ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "provider call denied".to_string(), - }, - )], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::denied(ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, "provider call denied".to_string()).resolution], stopped_on_suspension: false, }]) - .with_single_outcomes(vec![CapabilityOutcome::Completed( - CapabilityResultMessage { - result_ref: LoopResultRef::new("result:policy-retry").expect("valid"), // safety: test-only fixture - safe_summary: "policy retry completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )]); + .with_single_outcomes(vec![resolution::completed(LoopResultRef::new("result:policy-retry").expect("valid"), "policy retry completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)]); let executor = CanonicalAgentLoopExecutor; let state = LoopExecutionState::initial_for_run(host.run_context()); @@ -3956,11 +3803,8 @@ async fn policy_denied_capability_error_honors_retry_recovery() { #[tokio::test] async fn spawned_process_fails_closed_until_process_wait_contract_exists() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::SpawnedProcess(ProcessHandleSummary { - process_ref: LoopProcessRef::new("process:alpha").expect("valid"), - safe_summary: "spawned".to_string(), - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::spawned_process(LoopProcessRef::new("process:alpha").expect("valid"))], stopped_on_suspension: false, }, ]); @@ -3993,14 +3837,8 @@ async fn spawned_process_fails_closed_until_process_wait_contract_exists() { async fn spawned_child_run_result_append_failure_propagates_without_completed_result() { let result_ref = LoopResultRef::new("result:spawned-child").expect("valid"); let host = MockHost::new(vec![calls_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref, - safe_summary: "spawned child completed".to_string(), - byte_len: 0, - model_observation: None, - }], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::spawned_child_run(TurnRunId::new(), result_ref, "spawned child completed".to_string(), 0, None)], stopped_on_suspension: false, }]) .with_failing_result_append(); @@ -4033,16 +3871,8 @@ async fn spawned_child_run_redacts_unsafe_safe_summary_to_placeholder() { // A second (reply) turn lets the run complete after the SpawnedChildRun // result is appended with the redacted summary. let host = MockHost::new(vec![calls_response(), reply_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref, - // Unsafe: a filesystem path. The mapping redacts it to the - // placeholder rather than rejecting/terminating. - safe_summary: "/Users/alice/.ssh/id_rsa".to_string(), - byte_len: 0, - model_observation: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::spawned_child_run(TurnRunId::new(), result_ref, "/Users/alice/.ssh/id_rsa".to_string(), 0, None)], stopped_on_suspension: false, }, ]); @@ -4071,16 +3901,8 @@ async fn completed_provider_call_appends_provider_replay_metadata() { let result_ref = LoopResultRef::new("result:provider-call").expect("valid"); let safe_summary = "a".repeat(300); let host = MockHost::new(vec![provider_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: safe_summary.clone(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), safe_summary.clone(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -4141,22 +3963,10 @@ async fn completed_provider_call_appends_provider_replay_metadata() { async fn denied_provider_call_appends_failure_tool_result_for_replay() { let result_ref = LoopResultRef::new("result:provider-call").expect("valid"); let host = MockHost::new(vec![provider_two_calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "provider call completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }), - CapabilityOutcome::Denied(ironclaw_turns::run_profile::CapabilityDenied { - reason_kind: - ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "provider call denied".to_string(), - }), + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::completed(result_ref.clone(), "provider call completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None), + resolution::denied(ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, "provider call denied".to_string()).resolution, ], stopped_on_suspension: false, }]); @@ -4213,12 +4023,8 @@ async fn denied_provider_call_appends_failure_tool_result_for_replay() { #[tokio::test] async fn invalid_provider_tool_failure_appends_structured_model_observation() { let host = MockHost::new(vec![provider_calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::InvalidInput, - safe_summary: "provider arguments failed schema validation".to_string(), - detail: Some(CapabilityFailureDetail::InvalidInput { + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::InvalidInput, "provider arguments failed schema validation".to_string(), Some(CapabilityFailureDetail::InvalidInput { issues: vec![CapabilityInputIssue { path: "file_path".to_string(), code: DispatchInputIssueCode::MissingRequired, @@ -4226,9 +4032,7 @@ async fn invalid_provider_tool_failure_appends_structured_model_observation() { received: None, schema_path: Some("required".to_string()), }], - }), - }, - )], + }))], stopped_on_suspension: false, }]); let executor = CanonicalAgentLoopExecutor; @@ -4288,14 +4092,8 @@ async fn repeated_capability_failures_do_not_trip_no_progress_and_run_can_recove ]) .with_batch_outcomes( (0..3) - .map(|_| ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::OperationFailed, - safe_summary: "filesystem discovery failed".to_string(), - detail: None, - }, - )], + .map(|_| ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::OperationFailed, "filesystem discovery failed".to_string(), None)], stopped_on_suspension: false, }) .collect(), @@ -4346,18 +4144,10 @@ async fn repeated_multi_call_failures_do_not_trip_no_progress_and_run_can_recove ]) .with_batch_outcomes( (0..3) - .map(|_| ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Failed(ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::OperationFailed, - safe_summary: "first discovery failed".to_string(), - detail: None, - }), - CapabilityOutcome::Failed(ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::OperationFailed, - safe_summary: "second discovery failed".to_string(), - detail: None, - }), + .map(|_| ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::failed(CapabilityFailureKind::OperationFailed, "first discovery failed".to_string(), None), + resolution::failed(CapabilityFailureKind::OperationFailed, "second discovery failed".to_string(), None), ], stopped_on_suspension: false, }) @@ -4410,16 +4200,8 @@ async fn completed_output_digest_is_recorded_into_seen_capability_output_digests let digest = ironclaw_turns::run_profile::ContentDigest(4242); let result_ref = LoopResultRef::new("result:digest-recorded").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "completed with digest".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: Some(digest), - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "completed with digest".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, Some(digest), None)], stopped_on_suspension: false, }, ]); @@ -4448,14 +4230,8 @@ async fn repeated_non_provider_replayable_failures_do_not_trigger_no_progress_st let host = MockHost::new(vec![calls_response(), calls_response(), calls_response()]) .with_batch_outcomes( (0..3) - .map(|_| ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::OperationFailed, - safe_summary: "non-replayable capability failed".to_string(), - detail: None, - }, - )], + .map(|_| ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::OperationFailed, "non-replayable capability failed".to_string(), None)], stopped_on_suspension: false, }) .collect(), @@ -4518,14 +4294,8 @@ async fn model_visible_provider_tool_failures_append_failure_tool_result_for_rep ), ] { let host = MockHost::new(vec![provider_calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind, - safe_summary: safe_summary.to_string(), - detail: None, - }, - )], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(error_kind, safe_summary.to_string(), None)], stopped_on_suspension: false, }]); let executor = CanonicalAgentLoopExecutor; @@ -4566,14 +4336,8 @@ async fn model_visible_provider_tool_failures_append_failure_tool_result_for_rep let long_summary = "a".repeat(512); let host = MockHost::new(vec![provider_calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::OutputTooLarge, - safe_summary: long_summary, - detail: None, - }, - )], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::OutputTooLarge, long_summary, None)], stopped_on_suspension: false, }]); let executor = CanonicalAgentLoopExecutor; @@ -4720,17 +4484,8 @@ async fn executor_post_capability_trips_policy_and_sets_flags_in_final_state() { // Use terminate_hint so the loop exits immediately after the capability // turn, giving us a deterministic Final checkpoint to inspect. let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:big").expect("valid"), - safe_summary: "big result".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - // Exceeds the default 32 000-byte cap for unknown capability ids. - byte_len: 33_001, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:big").expect("valid"), "big result".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 33_001, None, None)], stopped_on_suspension: false, }, ]); @@ -4785,16 +4540,8 @@ async fn executor_post_capability_trips_policy_and_sets_flags_in_final_state() { #[tokio::test] async fn executor_post_capability_does_not_trip_under_threshold() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:small").expect("valid"), - safe_summary: "small result".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 100, // well under the 32 000-byte default cap - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:small").expect("valid"), "small result".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 100, None, None)], stopped_on_suspension: false, }, ]); @@ -4833,16 +4580,8 @@ async fn executor_skip_model_turn_bypasses_model_stage() { // Iteration 2: SkipModel (flags cleared by PromptStage, no model call). // Iteration 3: model → reply → GracefulStop. let host = MockHost::new(vec![calls_response(), reply_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:big-no-term").expect("valid"), - safe_summary: "big result no terminate".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, // loop must continue so SkipModel fires - byte_len: 33_001, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:big-no-term").expect("valid"), "big result no terminate".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 33_001, None, None)], stopped_on_suspension: false, }, ]); @@ -4945,26 +4684,10 @@ async fn executor_batch_accumulates_per_capability_bytes_and_trips() { // two_calls_response() emits two calls with capability_id() ("demo.echo"). // Each result carries 20 000 bytes → sum = 40 000 > 32 000 → trip. let host = MockHost::new(vec![two_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:first").expect("valid"), - safe_summary: "first".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, // exit after batch so we can inspect state - byte_len: 20_000, - output_digest: None, - model_observation: None, - }), - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:second").expect("valid"), - safe_summary: "second".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 20_000, - output_digest: None, - model_observation: None, - }), + ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::completed(LoopResultRef::new("result:first").expect("valid"), "first".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 20_000, None, None), + resolution::completed(LoopResultRef::new("result:second").expect("valid"), "second".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 20_000, None, None), ], stopped_on_suspension: false, }, @@ -5032,15 +4755,9 @@ async fn await_dependent_run_preserves_model_observation_for_replay() { let observation = continuation_observation(&result_ref, 4_096); let awaited_summary = "awaited child completed".to_string(); let host = MockHost::new(vec![provider_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AwaitDependentRun { - gate_ref: LoopGateRef::new("gate:await-dependent-preserved-observation") - .expect("valid"), - result_ref, - safe_summary: awaited_summary.clone(), - byte_len: 4_096, - model_observation: Some(observation), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::await_dependent_run(LoopGateRef::new("gate:await-dependent-preserved-observation") + .expect("valid"), result_ref, awaited_summary.clone(), 4_096, Some(observation)).resolution], stopped_on_suspension: true, }, ]); @@ -5103,17 +4820,8 @@ async fn spawned_child_run_byte_len_accumulates_and_trips_policy() { // Iteration 2: SkipModel route — no model call. // Iteration 3: model → reply → GracefulStop. let host = MockHost::new(vec![calls_response(), reply_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref: LoopResultRef::new("result:spawned-child-large").expect("valid"), - safe_summary: "spawned child with large result".to_string(), - // Exceeds the default 32 000-byte fallback cap. - // If byte_len were still hardcoded to 0 in append_spawned_child_result, - // the policy would never trip and both flag assertions below would fail. - byte_len: 49_001, - model_observation: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::spawned_child_run(TurnRunId::new(), LoopResultRef::new("result:spawned-child-large").expect("valid"), "spawned child with large result".to_string(), 49_001, None)], stopped_on_suspension: false, }, ]); @@ -5168,17 +4876,8 @@ async fn await_dependent_run_byte_len_accumulates_and_trips_policy() { // PostCapabilityStage does not evaluate the policy on this turn — but the // bytes ARE accumulated into pending_capability_bytes before the block. let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AwaitDependentRun { - gate_ref: LoopGateRef::new("gate:await-large").expect("valid"), - result_ref: LoopResultRef::new("result:await-large").expect("valid"), - safe_summary: "await dependent run with large result".to_string(), - // Exceeds the default 32 000-byte fallback cap. If byte_len were - // still propagated as 0 in the AwaitDependentRunGateStage path, - // the pending_capability_bytes assertion below would fail. - byte_len: 33_001, - model_observation: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::await_dependent_run(LoopGateRef::new("gate:await-large").expect("valid"), LoopResultRef::new("result:await-large").expect("valid"), "await dependent run with large result".to_string(), 33_001, None).resolution], stopped_on_suspension: true, }, ]); @@ -5256,16 +4955,8 @@ async fn executor_emits_compaction_started_with_capability_result_overflow_initi // one on iter 1 (candidate bundle) and one on iter 3 (final reply prompt). // Iteration 2 (SkipModel) never calls build_prompt_bundle. let host = MockHost::new(vec![calls_response(), reply_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:big-f12").expect("valid"), - safe_summary: "big result for F12".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, // loop must continue so SkipModel iteration fires - byte_len: 33_001, // exceeds the 32 000-byte default cap - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:big-f12").expect("valid"), "big result for F12".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 33_001, None, None)], stopped_on_suspension: false, }]) .with_prompt_compaction_indexes(vec![ @@ -5365,16 +5056,8 @@ async fn executor_continues_after_forced_compaction_rejection_from_tool_result_o calls_response(), reply_response_with_text("final answer"), ]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new("result:big-compaction-rejected").expect("valid"), - safe_summary: "large search result".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 33_001, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(LoopResultRef::new("result:big-compaction-rejected").expect("valid"), "large search result".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 33_001, None, None)], stopped_on_suspension: false, }]) .with_prompt_compaction_indexes(vec![active_task_preserving_compaction_index(), vec![]]) @@ -5522,14 +5205,8 @@ async fn await_dependent_run_gate_skip_and_continue_accumulates_byte_len() { // — so we check result_refs as the persistent proof and also assert the // SkipModel iteration fired (model count == 2 for 3 total iterations). let host = MockHost::new(vec![calls_response(), reply_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AwaitDependentRun { - gate_ref: LoopGateRef::new("gate:await-skip").expect("valid"), - result_ref: LoopResultRef::new(result_ref_str).expect("valid"), - safe_summary: "dependent run skip and continue".to_string(), - byte_len, - model_observation: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::await_dependent_run(LoopGateRef::new("gate:await-skip").expect("valid"), LoopResultRef::new(result_ref_str).expect("valid"), "dependent run skip and continue".to_string(), byte_len, None).resolution], stopped_on_suspension: false, }, ]); @@ -5583,13 +5260,8 @@ async fn auth_gate_block_stores_pending_auth_resume() { // canonical path (cancel-check → progress emit → write_before_block). let gate_ref = LoopGateRef::new("gate:auth-block").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(gate_ref.clone(), Vec::new(), "auth required".to_string(), None).resolution], stopped_on_suspension: true, }, ]); @@ -5655,12 +5327,8 @@ async fn non_auth_gate_block_preserves_pending_auth_resume() { // the outer resume handler can still consume it. let approval_gate_ref = LoopGateRef::new("gate:approval-during-redispatch").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - gate_ref: approval_gate_ref.clone(), - safe_summary: "approval required during redispatch".to_string(), - approval_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(approval_gate_ref.clone(), "approval required during redispatch".to_string(), None).resolution], stopped_on_suspension: true, }, ]); @@ -5719,11 +5387,8 @@ async fn external_tool_gate_block_stores_pending_external_tool_resume() { // re-dispatch the parked client-tool call). let gate_ref = LoopGateRef::new("gate:external_tool-block").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ExternalToolPending { - gate_ref: gate_ref.clone(), - safe_summary: "awaiting client tool output".to_string(), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::external_tool_pending(gate_ref.clone(), "awaiting client tool output".to_string()).resolution], stopped_on_suspension: true, }, ]); @@ -5756,21 +5421,10 @@ async fn parallel_batch_records_completed_results_before_external_tool_block() { let completed_ref = LoopResultRef::new("result:parallel-external-completed").expect("valid"); let external_gate_ref = LoopGateRef::new("gate:external-tool-parallel").expect("valid"); let host = MockHost::new(vec![two_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "parallel call completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), - CapabilityOutcome::ExternalToolPending { - gate_ref: external_gate_ref.clone(), - safe_summary: "awaiting client tool output".to_string(), - }, + ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::completed(completed_ref.clone(), "parallel call completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), + resolution::external_tool_pending(external_gate_ref.clone(), "awaiting client tool output".to_string()).resolution, ], stopped_on_suspension: false, }, @@ -5807,25 +5461,12 @@ async fn resume_after_external_tool_gate_redispatches_without_model_turn() { let gate_ref = LoopGateRef::new("gate:external_tool-resume").expect("valid"); let completed_ref = LoopResultRef::new("result:external-tool-resumed").expect("valid"); let host = MockHost::new(vec![provider_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ExternalToolPending { - gate_ref: gate_ref.clone(), - safe_summary: "awaiting client tool output".to_string(), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::external_tool_pending(gate_ref.clone(), "awaiting client tool output".to_string()).resolution], stopped_on_suspension: true, }, - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "external tool output".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref.clone(), "external tool output".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -5881,28 +5522,13 @@ async fn resume_after_auth_gate_redispatches_original_call_without_model_turn() let gate_ref = LoopGateRef::new("gate:auth-resume-test").expect("valid"); let completed_ref = LoopResultRef::new("result:auth-resumed").expect("valid"); let host = MockHost::new(vec![provider_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(gate_ref.clone(), Vec::new(), "auth required".to_string(), None).resolution], stopped_on_suspension: true, }, // Phase 2 scripted outcome: the auth is now satisfied, call completes. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "auth resumed and completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref.clone(), "auth resumed and completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -6054,27 +5680,12 @@ async fn auth_resume_provider_registration_failure_fails_before_invocation() { let completed_ref = LoopResultRef::new("result:unused-auth-resume").expect("valid"); let host = MockHost::new(vec![provider_calls_response()]) .with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(gate_ref.clone(), Vec::new(), "auth required".to_string(), None).resolution], stopped_on_suspension: true, }, - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref, - safe_summary: "should not invoke".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref, "should not invoke".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]) @@ -6125,27 +5736,12 @@ async fn auth_resume_provider_activity_remap_fails_before_invocation() { let gate_ref = LoopGateRef::new("gate:auth-resume-activity-remap").expect("valid"); let completed_ref = LoopResultRef::new("result:unused-auth-resume-remap").expect("valid"); let host = MockHost::new(vec![provider_calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(gate_ref.clone(), Vec::new(), "auth required".to_string(), None).resolution], stopped_on_suspension: true, }, - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref, - safe_summary: "should not invoke".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref, "should not invoke".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -6208,23 +5804,13 @@ async fn resume_with_still_missing_credentials_blocks_again_without_model_turn() // BeforeBlock checkpoint carrying a pending_auth_resume record. let gate_ref = LoopGateRef::new("gate:auth-still-missing").expect("valid"); let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required (phase 1)".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(gate_ref.clone(), Vec::new(), "auth required (phase 1)".to_string(), None).resolution], stopped_on_suspension: true, }, // Phase 2 scripted outcome: credentials are STILL missing — block again. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: LoopGateRef::new("gate:auth-still-missing-2").expect("valid"), - credential_requirements: Vec::new(), - safe_summary: "auth required (phase 2 — still missing)".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(LoopGateRef::new("gate:auth-still-missing-2").expect("valid"), Vec::new(), "auth required (phase 2 — still missing)".to_string(), None).resolution], stopped_on_suspension: true, }, ]); @@ -6603,41 +6189,19 @@ async fn auth_resume_after_approval_carries_resume_token_and_approval_request_id let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ // Phase 1: approval gate blocks with resume metadata - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - // Post-§5.3 Stage 2 flip: the gate ref must encode the fixture's - // `approval_request_id` so the approval identity (request id + - // resume token) round-trips through reconstruction. - gate_ref: LoopGateRef::new(format!("gate:approval-{approval_request_id}")) - .expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: Some(approval_resume.clone()), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new(format!("gate:approval-{approval_request_id}")) + .expect("valid"), "approval required".to_string(), Some(approval_resume.clone())).resolution], stopped_on_suspension: true, }, // Phase 2: auth gate blocks after approval-resume re-dispatch - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: auth_gate_ref.clone(), - credential_requirements: Vec::new(), - safe_summary: "auth required after approval".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(auth_gate_ref.clone(), Vec::new(), "auth required after approval".to_string(), None).resolution], stopped_on_suspension: true, }, // Phase 3: auth-resume re-dispatch completes - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "completed after auth resume".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref.clone(), "completed after auth resume".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -6854,44 +6418,22 @@ async fn auth_resume_after_approval_carries_original_correlation_id() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ // Phase 1: approval gate blocks. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - // Post-§5.3 Stage 2 flip: the gate ref must encode the fixture's - // `approval_request_id` so the approval identity reconstructs and - // prior_approval is folded into pending_auth_resume. - gate_ref: LoopGateRef::new(format!("gate:approval-{approval_request_id}")) - .expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: Some(approval_resume.clone()), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new(format!("gate:approval-{approval_request_id}")) + .expect("valid"), "approval required".to_string(), Some(approval_resume.clone())).resolution], stopped_on_suspension: true, }, // Phase 2: auth gate blocks after approval-resume re-dispatch. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: LoopGateRef::new("gate:corr-id-auth").expect("valid"), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: Some(CapabilityAuthResume { + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(LoopGateRef::new("gate:corr-id-auth").expect("valid"), Vec::new(), "auth required".to_string(), Some(CapabilityAuthResume { resume_token: auth_gate_resume_token, prior_approval: None, - }), - }], + })).resolution], stopped_on_suspension: true, }, // Phase 3: auth-resume re-dispatch completes. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: completed_ref.clone(), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(completed_ref.clone(), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -7004,30 +6546,10 @@ async fn auth_resume_slot_consumed_on_first_batch_match_not_reused_for_second_ca // Two outcomes for the two calls; both complete so no suspension complicates things. let host = MockHost::new(Vec::new()).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![ - CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: LoopResultRef::new("result:first").expect("valid"), - safe_summary: "first done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - ), - CapabilityOutcome::Completed( - ironclaw_turns::run_profile::CapabilityResultMessage { - result_ref: LoopResultRef::new("result:second").expect("valid"), - safe_summary: "second done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - ), + ironclaw_host_api::ResolutionBatch { + resolutions: vec![ + resolution::completed(LoopResultRef::new("result:first").expect("valid"), "first done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), + resolution::completed(LoopResultRef::new("result:second").expect("valid"), "second done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), ], stopped_on_suspension: false, }, @@ -7202,27 +6724,14 @@ async fn resume_origin_backend_failure_does_not_die_as_scope_mismatch() { // [1] Phase 2: cap1 approval-resume → Failed(Backend) — the bug trigger. let batch_outcomes = vec![ // [0] cap1 → ApprovalRequired (gate blocked). - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::ApprovalRequired { - // Post-§5.3 Stage 2 flip: the gate ref must encode cap1's - // `approval_request_id` so `pending_approval_resume` reconstructs - // (feeds the phase-2 approval-resume dispatch). - gate_ref: LoopGateRef::new(format!("gate:approval-{cap1_request_id}")) - .expect("valid"), - safe_summary: "cap1 needs approval".to_string(), - approval_resume: Some(cap1_approval_resume), - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::approval_required(LoopGateRef::new(format!("gate:approval-{cap1_request_id}")) + .expect("valid"), "cap1 needs approval".to_string(), Some(cap1_approval_resume)).resolution], stopped_on_suspension: true, }, // [1] cap1 approval-resume → Backend failure. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::Backend, - safe_summary: "transient backend error during cap1 resume".to_string(), - detail: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::Backend, "transient backend error during cap1 resume".to_string(), None)], stopped_on_suspension: false, }, ]; @@ -7374,24 +6883,13 @@ async fn auth_resume_origin_backend_failure_does_not_die_as_scope_mismatch() { // [1] Phase 2: cap1 auth-resume → Failed(Backend) — the bug trigger. let batch_outcomes = vec![ // [0] Phase 1: cap1 → AuthRequired (gate blocked). - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: LoopGateRef::new("gate:auth-sm-test-cap1").expect("valid"), - credential_requirements: Vec::new(), - safe_summary: "cap1 needs auth".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(LoopGateRef::new("gate:auth-sm-test-cap1").expect("valid"), Vec::new(), "cap1 needs auth".to_string(), None).resolution], stopped_on_suspension: true, }, // [1] Phase 2: cap1 auth-resume → Backend failure. - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::Backend, - safe_summary: "transient backend error during cap1 auth-resume".to_string(), - detail: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::Backend, "transient backend error during cap1 auth-resume".to_string(), None)], stopped_on_suspension: false, }, ]; @@ -7724,13 +7222,8 @@ async fn auth_gate_without_resume_token_records_activity_id_for_denial_failure() let blocked_activity_id = calls[0].activity_id; let host = MockHost::new(Vec::new()).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::AuthRequired { - gate_ref: LoopGateRef::new("gate:hook-auth-tokenless").expect("valid"), - credential_requirements: Vec::new(), - safe_summary: "hook requested auth".to_string(), - auth_resume: None, - }], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::auth_required(LoopGateRef::new("gate:hook-auth-tokenless").expect("valid"), Vec::new(), "hook requested auth".to_string(), None).resolution], stopped_on_suspension: true, }, ]); @@ -7854,16 +7347,8 @@ async fn capability_stage_denied_auth_resume_only_fails_matching_call_remaining_ parameters_schema: serde_json::json!({"type":"object","properties":{}}), }, ]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: y_result_ref.clone(), - safe_summary: "list done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(y_result_ref.clone(), "list done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); @@ -8052,16 +7537,8 @@ async fn capability_stage_denied_auth_resume_only_fails_matching_activity_when_c { let y_result_ref = LoopResultRef::new("result:same-cap-y-outcome").expect("valid"); let host = MockHost::new(Vec::new()).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: y_result_ref.clone(), - safe_summary: "same capability second call done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(y_result_ref.clone(), "same capability second call done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }, ]); @@ -8204,27 +7681,11 @@ async fn capability_stage_denied_auth_resume_one_denied_two_remaining_all_dispat parameters_schema: serde_json::json!({"type":"object","properties":{}}), }, ]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { // Two outcomes for Y and Z — order matches invocations. - outcomes: vec![ - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: y_result_ref.clone(), - safe_summary: "list done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: z_result_ref.clone(), - safe_summary: "write done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }), + resolutions: vec![ + resolution::completed(y_result_ref.clone(), "list done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), + resolution::completed(z_result_ref.clone(), "write done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None), ], stopped_on_suspension: false, }]); @@ -8450,16 +7911,8 @@ async fn capability_stage_denied_approval_resume_only_fails_matching_call_remain parameters_schema: serde_json::json!({"type":"object","properties":{}}), }, ]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: y_result_ref.clone(), - safe_summary: "list done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(y_result_ref.clone(), "list done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); @@ -8665,16 +8118,8 @@ async fn capability_stage_denied_approval_resume_no_matching_call_dispatches_unr parameters_schema: serde_json::json!({"type":"object","properties":{}}), }, ]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: y_result_ref.clone(), - safe_summary: "list done no-match".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(y_result_ref.clone(), "list done no-match".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None)], stopped_on_suspension: false, }]); diff --git a/crates/ironclaw_agent_loop/src/executor/tests/cancellation.rs b/crates/ironclaw_agent_loop/src/executor/tests/cancellation.rs index b4fdcc233d..cad272ea6f 100644 --- a/crates/ironclaw_agent_loop/src/executor/tests/cancellation.rs +++ b/crates/ironclaw_agent_loop/src/executor/tests/cancellation.rs @@ -1,6 +1,7 @@ use super::{ + resolution, AgentLoopExecutor, AgentLoopExecutorError, AgentLoopHostError, AgentLoopHostErrorKind, - CanonicalAgentLoopExecutor, CapabilityFailureKind, CapabilityOutcome, CapabilityResultMessage, + CanonicalAgentLoopExecutor, CapabilityFailureKind, CheckpointKind, HostStage, LoopCancelReasonKind, LoopCancelledReasonKind, LoopCheckpointKind, LoopExecutionState, LoopExit, LoopGateRef, LoopInput, LoopInputAckToken, LoopInputBatch, LoopInputCursor, LoopInterruptKind, LoopResultRef, LoopRunInfoPort, LoopSafeSummary, MockHost, @@ -539,14 +540,8 @@ async fn cancellation_after_retry_prompt_rebuild_skips_second_model_call() { #[tokio::test] async fn capability_cancelled_returns_cancelled_exit_without_retry() { let host = MockHost::new(vec![calls_response()]).with_batch_outcomes(vec![ - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::Cancelled, - safe_summary: "capability cancelled".to_string(), - detail: None, - }, - )], + ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::failed(CapabilityFailureKind::Cancelled, "capability cancelled".to_string(), None)], stopped_on_suspension: false, }, ]); @@ -670,16 +665,8 @@ async fn cancellation_after_before_side_effect_checkpoint_skips_capability_call( async fn cancellation_after_capability_batch_preserves_completed_result() { let result_ref = LoopResultRef::new("result:late-cancel").expect("valid"); let host = MockHost::new(vec![calls_response()]) - .with_batch_outcomes(vec![ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: result_ref.clone(), - safe_summary: "completed before cancellation".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: true, - byte_len: 0, - output_digest: None, - model_observation: None, - })], + .with_batch_outcomes(vec![ironclaw_host_api::ResolutionBatch { + resolutions: vec![resolution::completed(result_ref.clone(), "completed before cancellation".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, true, 0, None, None)], stopped_on_suspension: false, }]) .cancel_after_batch_invocation(); diff --git a/crates/ironclaw_agent_loop/src/executor/tests/failure_matrix.rs b/crates/ironclaw_agent_loop/src/executor/tests/failure_matrix.rs index 54af1bbbe0..debe0b068b 100644 --- a/crates/ironclaw_agent_loop/src/executor/tests/failure_matrix.rs +++ b/crates/ironclaw_agent_loop/src/executor/tests/failure_matrix.rs @@ -1,6 +1,7 @@ use super::{ + resolution, AgentLoopExecutor, AgentLoopExecutorError, AgentLoopHostError, AgentLoopHostErrorKind, - CanonicalAgentLoopExecutor, CapabilityFailureKind, CapabilityOutcome, CapabilityResultMessage, + CanonicalAgentLoopExecutor, CapabilityFailureKind, CheckpointKind, DefaultCompactionStrategy, FixedReplyAdmissionPolicy, GateOutcome, HostStage, LoopCheckpointKind, LoopCompactionError, LoopExecutionState, LoopExit, LoopFailureKind, LoopGateRef, LoopResultRef, LoopSafeSummary, MockHost, active_task_preserving_compaction_index, @@ -279,13 +280,7 @@ async fn run_setup(setup: FailureSetup) -> ObservedTerminal { calls_response(), reply_response_with_text("explanation"), ]) - .with_batch_outcomes(vec![batch_outcome(CapabilityOutcome::Failed( - ironclaw_turns::run_profile::CapabilityFailure { - error_kind: CapabilityFailureKind::Permanent, - safe_summary: "permanent protocol failure".to_string(), - detail: None, - }, - ))]); + .with_batch_outcomes(vec![batch_outcome(resolution::failed(CapabilityFailureKind::Permanent, "permanent protocol failure".to_string(), None))]); run_local(crate::families::default(), host, None).await } FailureSetup::CapabilityInvalidInputRecoverable => { @@ -334,11 +329,7 @@ async fn run_setup(setup: FailureSetup) -> ObservedTerminal { reply_response_with_text("completed"), ]) .with_batch_outcomes(vec![batch_outcome_stopped( - CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:approval-skip").expect("valid"), - safe_summary: "approval required".to_string(), - approval_resume: None, - }, + resolution::approval_required(LoopGateRef::new("gate:approval-skip").expect("valid"), "approval required".to_string(), None).resolution, )]); run_local( family_with_gate_outcome(GateOutcome::SkipAndContinue { @@ -363,13 +354,7 @@ async fn run_setup(setup: FailureSetup) -> ObservedTerminal { calls_response(), reply_response_with_text("completed"), ]) - .with_batch_outcomes(vec![batch_outcome(CapabilityOutcome::Denied( - ironclaw_turns::run_profile::CapabilityDenied { - reason_kind: - ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "provider call denied".to_string(), - }, - ))]); + .with_batch_outcomes(vec![batch_outcome(resolution::denied(ironclaw_turns::run_profile::CapabilityDeniedReasonKind::EmptySurface, "provider call denied".to_string()).resolution)]); run_local(crate::families::default(), host, None).await } FailureSetup::CapabilityPolicyDeniedRecoverable => { @@ -580,39 +565,27 @@ fn assert_explanation_refs(row: &MatrixRow, refs: &[ironclaw_turns::LoopMessageR } fn batch_outcome( - outcome: CapabilityOutcome, -) -> ironclaw_turns::run_profile::CapabilityBatchOutcome { - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![outcome], + outcome: ironclaw_host_api::Resolution, +) -> ironclaw_host_api::ResolutionBatch { + ironclaw_host_api::ResolutionBatch { + resolutions: vec![outcome], stopped_on_suspension: false, } } -fn failed_capability(error_kind: CapabilityFailureKind, safe_summary: &str) -> CapabilityOutcome { - CapabilityOutcome::Failed(ironclaw_turns::run_profile::CapabilityFailure { - error_kind, - safe_summary: safe_summary.to_string(), - detail: None, - }) +fn failed_capability(error_kind: CapabilityFailureKind, safe_summary: &str) -> ironclaw_host_api::Resolution { + resolution::failed(error_kind, safe_summary.to_string(), None) } fn batch_outcome_stopped( - outcome: CapabilityOutcome, -) -> ironclaw_turns::run_profile::CapabilityBatchOutcome { - ironclaw_turns::run_profile::CapabilityBatchOutcome { - outcomes: vec![outcome], + outcome: ironclaw_host_api::Resolution, +) -> ironclaw_host_api::ResolutionBatch { + ironclaw_host_api::ResolutionBatch { + resolutions: vec![outcome], stopped_on_suspension: true, } } -fn no_change_result(result_ref: &str) -> CapabilityOutcome { - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(result_ref).expect("valid"), - safe_summary: "completed without progress".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::NoChange, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }) +fn no_change_result(result_ref: &str) -> ironclaw_host_api::Resolution { + resolution::completed(LoopResultRef::new(result_ref).expect("valid"), "completed without progress".to_string(), ironclaw_turns::run_profile::CapabilityProgress::NoChange, false, 0, None, None) } diff --git a/crates/ironclaw_agent_loop/src/executor/tests/support.rs b/crates/ironclaw_agent_loop/src/executor/tests/support.rs index 2ed536c5a5..3ac68c8246 100644 --- a/crates/ironclaw_agent_loop/src/executor/tests/support.rs +++ b/crates/ironclaw_agent_loop/src/executor/tests/support.rs @@ -9,7 +9,7 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, AssistantReply, CancellationPolicy, CapabilityBatchInvocation, CapabilityCallCandidate, - CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, + CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilitySurfaceProfileId, CapabilitySurfaceVersion, CheckpointPolicy, CheckpointSchemaId, ConcurrencyClass, ContextProfileId, FinalizeAssistantMessage, LoopCancelReasonKind, LoopCancellationPort, LoopCancellationSignal, LoopCheckpointKind, LoopCheckpointRequest, @@ -22,7 +22,7 @@ use ironclaw_turns::{ RegisterProviderToolCallRequest, ResolvedRunProfile, ResourceBudgetPolicy, ResourceBudgetTier, RunClassId, RunProfileFingerprint, RuntimeProfileConstraints, SchedulingClass, StageCheckpointPayloadRequest, SteeringPolicy, VisibleCapabilityRequest, - VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilitySurface, }, }; @@ -53,8 +53,8 @@ pub(super) struct MockHost { compaction: MockCompactionSupport, input_batches: Arc>>, acked_input_tokens: Arc>>, - batch_outcomes: Arc>>, - single_outcomes: Arc>>, + batch_outcomes: Arc>>, + single_outcomes: Arc>>, checkpoints: Arc>>, batch_invocations: Arc>>, single_invocations: Arc>>, @@ -167,13 +167,16 @@ impl MockHost { pub(super) fn with_batch_outcomes( self, - outcomes: Vec, + outcomes: Vec, ) -> Self { *self.batch_outcomes.lock().expect("lock") = outcomes.into(); self } - pub(super) fn with_single_outcomes(self, outcomes: Vec) -> Self { + pub(super) fn with_single_outcomes( + self, + outcomes: Vec, + ) -> Self { *self.single_outcomes.lock().expect("lock") = outcomes.into(); self } @@ -775,7 +778,6 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for MockHost { .ok_or_else(|| { AgentLoopHostError::new(AgentLoopHostErrorKind::Internal, "single script exhausted") }) - .map(|outcome| capability_outcome_to_resolution(outcome).resolution) } async fn invoke_capability_batch( @@ -786,7 +788,7 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for MockHost { if let Some(kind) = *self.fail_batch_with.lock().expect("lock") { return Err(AgentLoopHostError::new(kind, "scripted batch failure")); } - let outcome = self + let batch = self .batch_outcomes .lock() .expect("lock") @@ -797,14 +799,7 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for MockHost { if *self.cancel_after_batch_invocation.lock().expect("lock") { self.request_cancellation(LoopCancelReasonKind::UserRequested); } - Ok(ironclaw_host_api::ResolutionBatch { - resolutions: outcome - .outcomes - .into_iter() - .map(|o| capability_outcome_to_resolution(o).resolution) - .collect(), - stopped_on_suspension: outcome.stopped_on_suspension, - }) + Ok(batch) } } diff --git a/crates/ironclaw_agent_loop/src/test_support/mod.rs b/crates/ironclaw_agent_loop/src/test_support/mod.rs index c1d733e509..baa61b4b59 100644 --- a/crates/ironclaw_agent_loop/src/test_support/mod.rs +++ b/crates/ironclaw_agent_loop/src/test_support/mod.rs @@ -19,9 +19,9 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, AssistantReply, CancellationPolicy, CapabilityBatchInvocation, CapabilityCallCandidate, - CapabilityDescriptorView, CapabilityFailure, CapabilityFailureKind, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, - CapabilitySurfaceProfileId, CapabilitySurfaceVersion, CheckpointPolicy, CheckpointSchemaId, + CapabilityDescriptorView, CapabilityFailureKind, CapabilityInputRef, CapabilityInvocation, + CapabilityProgress, CapabilitySurfaceProfileId, CapabilitySurfaceVersion, CheckpointPolicy, + CheckpointSchemaId, ConcurrencyClass, ConcurrencyHint, ContentDigest, ContextProfileId, FinalizeAssistantMessage, LoopCancellationPort, LoopCancellationSignal, LoopCheckpointKind, LoopCheckpointRequest, LoopCheckpointStateRef, LoopCompactionError, LoopCompactionOutcome, @@ -34,7 +34,7 @@ use ironclaw_turns::{ RedactedRunProfileProvenance, ResolvedRunProfile, ResourceBudgetPolicy, ResourceBudgetTier, RunClassId, RunProfileFingerprint, RuntimeProfileConstraints, SchedulingClass, StageCheckpointPayloadRequest, SteeringPolicy, VisibleCapabilityRequest, - VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilitySurface, resolution, }, }; @@ -596,33 +596,16 @@ impl ScriptedCapabilityOutcome { } } -/// Convert a fixture [`CapabilityOutcome`] to its host_api [`Resolution`] channel -/// via the production mapping ([`capability_outcome_to_resolution`]), discarding -/// the side records the pure mapping also emits. -/// -/// The §5.3 capability-result flip re-points the loop's ~150 existing -/// `CapabilityOutcome` test fixtures at `Resolution`. This lets each of those -/// convert through the real mapping instead of hand-rolling a `Resolution` by -/// hand (which would drift from the acceptance table). Additive: nothing wires -/// it yet. -pub fn resolution_from_scripted_outcome(outcome: CapabilityOutcome) -> Resolution { - capability_outcome_to_resolution(outcome).resolution -} - -/// Convert a batch of fixture [`CapabilityOutcome`]s to a [`ResolutionBatch`], -/// mapping each through [`resolution_from_scripted_outcome`] and carrying the -/// `stopped_on_suspension` flag through unchanged — the `Resolution`-over- -/// `CapabilityOutcome` analogue of `CapabilityBatchOutcome`, for the flip's -/// batch-loop test sites. +/// Bundle fixture [`Resolution`]s into a [`ResolutionBatch`], carrying the +/// `stopped_on_suspension` flag through unchanged — the batch-loop test sites' +/// helper (§5.3 Stage 2b: producers emit `Resolution` directly, so fixtures +/// build them through `ironclaw_turns::run_profile::resolution::*`). pub fn resolution_batch_from_scripted( - outcomes: impl IntoIterator, + resolutions: impl IntoIterator, stopped_on_suspension: bool, ) -> ResolutionBatch { ResolutionBatch { - resolutions: outcomes - .into_iter() - .map(resolution_from_scripted_outcome) - .collect(), + resolutions: resolutions.into_iter().collect(), stopped_on_suspension, } } @@ -866,7 +849,6 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for MockAgentLoopDriverHost "single-call retry script exhausted", )) }) - .map(|outcome| capability_outcome_to_resolution(outcome).resolution) } async fn invoke_capability_batch( @@ -877,17 +859,13 @@ impl ironclaw_turns::run_profile::LoopCapabilityPort for MockAgentLoopDriverHost call_count: request.invocations.len(), stop_on_first_suspension: request.stop_on_first_suspension, }); - let outcomes = lock_or_panic(&self.script) + let resolutions: Vec = lock_or_panic(&self.script) .capability_outcomes .pop_front() .unwrap_or_default() .into_iter() .map(scripted_capability_outcome) .collect::, _>>()?; - let resolutions: Vec = outcomes - .into_iter() - .map(|outcome| capability_outcome_to_resolution(outcome).resolution) - .collect(); // `parks()`, not `is_suspension()` (H1): a re-entrant gate stops the batch too. let stopped_on_suspension = request.stop_on_first_suspension && resolutions.iter().any(Resolution::parks); @@ -1146,73 +1124,37 @@ fn scripted_capability_call(call: ScriptedCapabilityCall) -> CapabilityCallCandi fn scripted_capability_outcome( outcome: ScriptedCapabilityOutcome, -) -> Result { +) -> Result { match outcome { ScriptedCapabilityOutcome::Completed { result_ref, progress, terminate_hint, output_digest, - } => Ok(CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(result_ref) - .unwrap_or_else(|error| panic!("test result ref should be valid: {error}")), - safe_summary: "completed".to_string(), - progress, - terminate_hint, - byte_len: 0, - output_digest, - model_observation: None, - })), + } => Ok(resolution::completed(LoopResultRef::new(result_ref) + .unwrap_or_else(|error| panic!("test result ref should be valid: {error}")), "completed".to_string(), progress, terminate_hint, 0, output_digest, None)), ScriptedCapabilityOutcome::ApprovalRequired { gate_ref } => { - Ok(CapabilityOutcome::ApprovalRequired { - gate_ref: loop_gate_ref(&gate_ref), - safe_summary: "approval required".to_string(), - approval_resume: None, - }) + Ok(resolution::approval_required(loop_gate_ref(&gate_ref), "approval required".to_string(), None).resolution) } ScriptedCapabilityOutcome::AuthRequired { gate_ref } => { - Ok(CapabilityOutcome::AuthRequired { - gate_ref: loop_gate_ref(&gate_ref), - credential_requirements: Vec::new(), - safe_summary: "auth required".to_string(), - auth_resume: None, - }) + Ok(resolution::auth_required(loop_gate_ref(&gate_ref), Vec::new(), "auth required".to_string(), None).resolution) } ScriptedCapabilityOutcome::ResourceBlocked { gate_ref } => { - Ok(CapabilityOutcome::ResourceBlocked { - gate_ref: loop_gate_ref(&gate_ref), - safe_summary: "resource blocked".to_string(), - }) + Ok(resolution::resource_blocked(loop_gate_ref(&gate_ref), "resource blocked".to_string()).resolution) } ScriptedCapabilityOutcome::AwaitDependentRun { gate_ref, result_ref, byte_len, - } => Ok(CapabilityOutcome::AwaitDependentRun { - gate_ref: loop_gate_ref(&gate_ref), - result_ref: loop_result_ref(&result_ref), - safe_summary: "await dependent run".to_string(), - byte_len, - model_observation: None, - }), + } => Ok(resolution::await_dependent_run(loop_gate_ref(&gate_ref), loop_result_ref(&result_ref), "await dependent run".to_string(), byte_len, None).resolution), ScriptedCapabilityOutcome::SpawnedChildRun { child_run_id, result_ref, byte_len, - } => Ok(CapabilityOutcome::SpawnedChildRun { - child_run_id, - result_ref: LoopResultRef::new(result_ref) - .unwrap_or_else(|error| panic!("test result ref should be valid: {error}")), - safe_summary: "spawned child run".to_string(), - byte_len, - model_observation: None, - }), + } => Ok(resolution::spawned_child_run(child_run_id, LoopResultRef::new(result_ref) + .unwrap_or_else(|error| panic!("test result ref should be valid: {error}")), "spawned child run".to_string(), byte_len, None)), ScriptedCapabilityOutcome::Failed { error_kind } => { - Ok(CapabilityOutcome::Failed(CapabilityFailure { - error_kind, - safe_summary: "failed".to_string(), - detail: None, - })) + Ok(resolution::failed(error_kind, "failed".to_string(), None)) } } } @@ -1307,36 +1249,31 @@ fn clone_mutex_vec(mutex: &Mutex>) -> Vec { mod tests { use super::*; - /// Build a fixture `CapabilityOutcome` from a scripted outcome via the same + /// Build a fixture [`Resolution`] from a scripted outcome via the same /// private mapper the mock host uses, so these tests exercise the real - /// fixture-shaped inputs the flip will feed the helpers. - fn outcome(scripted: ScriptedCapabilityOutcome) -> CapabilityOutcome { - scripted_capability_outcome(scripted).expect("scripted fixture builds an outcome") + /// fixture-shaped inputs the helpers consume. + fn outcome(scripted: ScriptedCapabilityOutcome) -> Resolution { + scripted_capability_outcome(scripted).expect("scripted fixture builds a resolution") } #[test] fn scripted_outcome_maps_to_the_right_resolution_channel() { // Completed → Done (ran, does not park). - let completed = resolution_from_scripted_outcome(outcome( - ScriptedCapabilityOutcome::completed("result:one"), - )); + let completed = outcome(ScriptedCapabilityOutcome::completed("result:one")); assert_eq!(completed.kind(), "done"); assert!(!completed.parks()); // Failed → Done (a recoverable failure rides the Done channel; the model // can retry) — it does not park. - let failed = - resolution_from_scripted_outcome(outcome(ScriptedCapabilityOutcome::failed("network"))); + let failed = outcome(ScriptedCapabilityOutcome::failed("network")); assert_eq!(failed.kind(), "done"); assert!(!failed.parks()); // ApprovalRequired → Blocked (a re-entrant gate): it parks but is NOT a // suspension — the distinction parks() exists to preserve. - let approval = resolution_from_scripted_outcome(outcome( - ScriptedCapabilityOutcome::ApprovalRequired { - gate_ref: "gate:approve-1".to_string(), - }, - )); + let approval = outcome(ScriptedCapabilityOutcome::ApprovalRequired { + gate_ref: "gate:approve-1".to_string(), + }); assert_eq!(approval.kind(), "blocked"); assert!(approval.parks()); assert!(!approval.is_suspension()); diff --git a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs index c6345e8d62..0b36c8bccf 100644 --- a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs +++ b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs @@ -4935,10 +4935,7 @@ impl LoopCapabilityPort for StaticToolDefinitionPort { &self, _request: CapabilityInvocation, ) -> Result { - let outcome = CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "test capability port does not execute tools".to_string(), - }); + let outcome = resolution::denied(CapabilityDeniedReasonKind::EmptySurface, "test capability port does not execute tools".to_string()).resolution; Ok(capability_outcome_to_resolution(outcome).resolution) } @@ -4950,10 +4947,7 @@ impl LoopCapabilityPort for StaticToolDefinitionPort { .invocations .into_iter() .map(|_| { - let outcome = CapabilityOutcome::Denied(CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "test capability port does not execute tools".to_string(), - }); + let outcome = resolution::denied(CapabilityDeniedReasonKind::EmptySurface, "test capability port does not execute tools".to_string()).resolution; capability_outcome_to_resolution(outcome).resolution }) .collect(); diff --git a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs index f30fac7dc4..054e526854 100644 --- a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs +++ b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs @@ -954,16 +954,8 @@ impl LoopCapabilityPort for RecordingCapabilityPort { .lock() .expect("harness capability invocation lock poisoned") .push(request); - let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(self.capability.result_ref.clone()) - .expect("valid harness result ref"), - safe_summary: self.capability.safe_summary.clone(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: self.capability.terminate_hint, - byte_len: 0, - output_digest: None, - model_observation: None, - }); + let outcome = resolution::completed(LoopResultRef::new(self.capability.result_ref.clone()) + .expect("valid harness result ref"), self.capability.safe_summary.clone(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, self.capability.terminate_hint, 0, None, None); Ok(capability_outcome_to_resolution(outcome).resolution) } diff --git a/crates/ironclaw_runner/tests/hooks_integration.rs b/crates/ironclaw_runner/tests/hooks_integration.rs index b0ac87c533..39e43a0b0c 100644 --- a/crates/ironclaw_runner/tests/hooks_integration.rs +++ b/crates/ironclaw_runner/tests/hooks_integration.rs @@ -222,16 +222,8 @@ impl LoopCapabilityPort for RecordingCapabilityPort { .lock() .expect("invocations mutex not poisoned") .push(request.capability_id.clone()); - let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(format!("result:{}", request.capability_id)) - .expect("result ref literal is valid"), - safe_summary: "stub capability completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }); + let outcome = resolution::completed(LoopResultRef::new(format!("result:{}", request.capability_id)) + .expect("result ref literal is valid"), "stub capability completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None); Ok(capability_outcome_to_resolution(outcome).resolution) } @@ -298,16 +290,8 @@ impl LoopCapabilityPort for ProviderAwareCapabilityPort { .lock() .expect("invocations mutex not poisoned") .push(request.capability_id.clone()); - let outcome = CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(format!("result:{}", request.capability_id)) - .expect("result ref literal is valid"), - safe_summary: "stub capability completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }); + let outcome = resolution::completed(LoopResultRef::new(format!("result:{}", request.capability_id)) + .expect("result ref literal is valid"), "stub capability completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None); Ok(capability_outcome_to_resolution(outcome).resolution) } diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index 3f9242d17d..dd3b2b5f22 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -100,11 +100,7 @@ async fn two_fake_drivers_use_the_same_per_run_agent_loop_host_contract() { effective_model_profile_id: host.context.resolved_run_profile.model_profile_id.clone(), usage: None, }); - host.push_capability_outcome(CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:approval-needed").unwrap(), - safe_summary: "approval required".to_string(), - approval_resume: None, - }); + host.push_capability_outcome(resolution::approval_required(LoopGateRef::new("gate:approval-needed").unwrap(), "approval required".to_string(), None).resolution); let reply_exit = ReplyDriver .run(driver_run_request(&host), host.as_ref()) From 0c5fe5f3c348a4a8456692f878ec5abde67b5951 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:37:17 +0000 Subject: [PATCH 09/12] =?UTF-8?q?refactor(reborn):=20delete=20CapabilityOu?= =?UTF-8?q?tcome=20and=20the=20transitional=20mapping=20(=C2=A75.3=20Stage?= =?UTF-8?q?=202b=20=E2=80=94=20collapse=20complete)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deletes CapabilityOutcome, CapabilityBatchOutcome, CapabilityResultMessage, CapabilityFailure, CapabilityDenied, ProcessHandleSummary from run_profile::host, the resolution_mapping delegator (capability_outcome_to_resolution / MappedResolution / RefBindings), and their re-exports. Retargets the last serde-fixture tests (turn_coordinator, agent_loop_host_contract, content_digest) onto the surviving vocabulary (CapabilityDeniedReasonKind / CapabilityProgress / Resolution). CapabilityApprovalResume/AuthResume/ResumeToken kept (resume requests). Architecture ratchet trims CapabilityOutcome from FROZEN_COLLAPSE_DTOS per its own shrink-only instructions. No production code references CapabilityOutcome; turns + architecture ratchet green. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../reborn_capability_dto_collapse_ratchet.rs | 10 +- .../src/capability_surface_filter.rs | 61 +++---- .../src/subagent_spawn_port/tests.rs | 107 +++++------- .../tests/thread_loop_host_contract.rs | 22 ++- .../tests/support/planned_agent_loop.rs | 8 +- .../tests/hooks_integration.rs | 8 +- .../src/run_profile/content_digest.rs | 27 --- crates/ironclaw_turns/src/run_profile/host.rs | 135 +-------------- crates/ironclaw_turns/src/run_profile/mod.rs | 13 +- .../src/run_profile/resolution_mapping.rs | 136 ---------------- .../tests/agent_loop_host_contract.rs | 110 ++++--------- .../tests/turn_coordinator_contract.rs | 154 +----------------- 12 files changed, 138 insertions(+), 653 deletions(-) delete mode 100644 crates/ironclaw_turns/src/run_profile/resolution_mapping.rs diff --git a/crates/ironclaw_architecture/tests/reborn_capability_dto_collapse_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_capability_dto_collapse_ratchet.rs index 3b15d037a5..e42e244cbc 100644 --- a/crates/ironclaw_architecture/tests/reborn_capability_dto_collapse_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_capability_dto_collapse_ratchet.rs @@ -65,7 +65,11 @@ const FROZEN_COLLAPSE_DTOS: &[&str] = &[ // dispatcher (`Authorized` + resolved handles replace it, §3.1) "RuntimeAdapterRequest", // ── result side: the overloaded ten-variant enum (§1.2 → `Resolution`) ── - "CapabilityOutcome", + // `CapabilityOutcome` (and its `CapabilityBatchOutcome`/`CapabilityResultMessage`/ + // `CapabilityFailure`/`CapabilityDenied`/`ProcessHandleSummary` payloads) are + // DELETED (§5.3 Stage 2b): producers emit `host_api::Resolution` directly via + // the `ironclaw_turns::run_profile::resolution::*` constructors. The result-lane + // collapse is complete; only the request-side shapes above remain to retire. ]; /// Matches exactly the frozen collapse-target names (exact identifier, not a @@ -128,7 +132,7 @@ fn reborn_capability_dto_allowlist_is_frozen_and_only_shrinks() { fn collapse_dto_predicate_is_exact_name() { let sample = r#" pub struct RuntimeCapabilityRequest { a: u8 } // frozen -> flagged - pub enum CapabilityOutcome { A, B } // frozen -> flagged + pub struct CapabilityDispatchRequest { a: u8 } // frozen -> flagged pub struct RuntimeAdapterRequestBuilder; // suffix -> NOT flagged pub struct Invocation; // the target -> NOT flagged pub struct CapabilityDispatchResult; // sibling result -> NOT flagged @@ -137,5 +141,5 @@ fn collapse_dto_predicate_is_exact_name() { .into_iter() .map(|(ident, _)| ident) .collect(); - assert_eq!(got, vec!["RuntimeCapabilityRequest", "CapabilityOutcome"]); + assert_eq!(got, vec!["RuntimeCapabilityRequest", "CapabilityDispatchRequest"]); } diff --git a/crates/ironclaw_loop_host/src/capability_surface_filter.rs b/crates/ironclaw_loop_host/src/capability_surface_filter.rs index b175bec360..814d8eb05e 100644 --- a/crates/ironclaw_loop_host/src/capability_surface_filter.rs +++ b/crates/ironclaw_loop_host/src/capability_surface_filter.rs @@ -722,9 +722,8 @@ mod tests { Blocked, CapabilityId, ProviderToolName, RuntimeKind, TenantId, ThreadId, }; use ironclaw_turns::run_profile::{ - CancellationPolicy, CapabilityBatchOutcome, CapabilityDescriptorView, CapabilityInputRef, - CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, CheckpointPolicy, - CheckpointSchemaId, capability_outcome_to_resolution, + CancellationPolicy, CapabilityDescriptorView, CapabilityInputRef, CapabilitySurfaceVersion, + CheckpointPolicy, CheckpointSchemaId, resolution, ConcurrencyClass, ConcurrencyHint, ContextProfileId, LoopDriverId, ModelProfileId, PersonalContextPolicy, RedactedRunProfileProvenance, ResolvedRunProfile, ResourceBudgetPolicy, ResourceBudgetTier, RuntimeProfileConstraints, SchedulingClass, @@ -740,7 +739,7 @@ mod tests { #[derive(Default)] struct SpyPort { surface: Mutex>, - batch_outcome: Mutex>, + batch_outcome: Mutex>, tool_definitions: Mutex>, provider_call_capability_ids: Mutex>, @@ -851,7 +850,7 @@ mod tests { .lock() .expect("invocation lock") .push(request); - Ok(capability_outcome_to_resolution(completed("result:single")).resolution) + Ok(completed("result:single")) } async fn invoke_capability_batch( @@ -859,23 +858,15 @@ mod tests { request: CapabilityBatchInvocation, ) -> Result { self.batches.lock().expect("batch lock").push(request); - let batch = self + Ok(self .batch_outcome .lock() .expect("batch outcome lock") .clone() - .unwrap_or_else(|| CapabilityBatchOutcome { - outcomes: vec![completed("result:first"), completed("result:second")], + .unwrap_or_else(|| ironclaw_host_api::ResolutionBatch { + resolutions: vec![completed("result:first"), completed("result:second")], stopped_on_suspension: false, - }); - Ok(ResolutionBatch { - resolutions: batch - .outcomes - .into_iter() - .map(|o| capability_outcome_to_resolution(o).resolution) - .collect(), - stopped_on_suspension: batch.stopped_on_suspension, - }) + })) } } @@ -958,24 +949,12 @@ mod tests { call } - fn completed(result_ref: &str) -> CapabilityOutcome { - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(result_ref).expect("test result ref is valid"), - safe_summary: "done".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }) + fn completed(result_ref: &str) -> Resolution { + resolution::completed(LoopResultRef::new(result_ref).expect("test result ref is valid"), "done".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None) } - fn approval_required(gate_ref: &str) -> CapabilityOutcome { - CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new(gate_ref).expect("test gate ref is valid"), - safe_summary: "approval needed".to_string(), - approval_resume: None, - } + fn approval_required(gate_ref: &str) -> Resolution { + resolution::approval_required(LoopGateRef::new(gate_ref).expect("test gate ref is valid"), "approval needed".to_string(), None).resolution } // The §5.3 collapse maps the open-set loop `reason_kind` @@ -1428,8 +1407,8 @@ mod tests { #[tokio::test] async fn visible_filter_batches_staged_capability_info_invocation() { let inner = Arc::new(SpyPort::default()); - *inner.batch_outcome.lock().expect("batch outcome lock") = Some(CapabilityBatchOutcome { - outcomes: vec![completed("result:capability-info")], + *inner.batch_outcome.lock().expect("batch outcome lock") = Some(ironclaw_host_api::ResolutionBatch { + resolutions: vec![completed("result:capability-info")], stopped_on_suspension: false, }); *inner @@ -1600,8 +1579,8 @@ mod tests { #[tokio::test] async fn batch_partitions_correctly() { let inner = Arc::new(SpyPort::default()); - *inner.batch_outcome.lock().expect("batch outcome lock") = Some(CapabilityBatchOutcome { - outcomes: vec![completed("result:first"), completed("result:second")], + *inner.batch_outcome.lock().expect("batch outcome lock") = Some(ironclaw_host_api::ResolutionBatch { + resolutions: vec![completed("result:first"), completed("result:second")], stopped_on_suspension: false, }); let filter = CapabilitySurfaceProfileFilter::new( @@ -1645,8 +1624,8 @@ mod tests { #[tokio::test] async fn partial_inner_outcomes_truncate_correctly() { let inner = Arc::new(SpyPort::default()); - *inner.batch_outcome.lock().expect("batch outcome lock") = Some(CapabilityBatchOutcome { - outcomes: vec![completed("result:first"), completed("result:second")], + *inner.batch_outcome.lock().expect("batch outcome lock") = Some(ironclaw_host_api::ResolutionBatch { + resolutions: vec![completed("result:first"), completed("result:second")], stopped_on_suspension: true, }); let filter = CapabilitySurfaceProfileFilter::new( @@ -1682,8 +1661,8 @@ mod tests { #[tokio::test] async fn stopped_inner_batch_truncates_denials_after_last_allowed_outcome() { let inner = Arc::new(SpyPort::default()); - *inner.batch_outcome.lock().expect("batch outcome lock") = Some(CapabilityBatchOutcome { - outcomes: vec![approval_required("gate:first")], + *inner.batch_outcome.lock().expect("batch outcome lock") = Some(ironclaw_host_api::ResolutionBatch { + resolutions: vec![approval_required("gate:first")], stopped_on_suspension: true, }); let filter = CapabilitySurfaceProfileFilter::new( diff --git a/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs b/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs index e8f2182ee1..64519baf85 100644 --- a/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs +++ b/crates/ironclaw_loop_host/src/subagent_spawn_port/tests.rs @@ -17,9 +17,8 @@ use ironclaw_turns::{ SubmitTurnRequest, TurnId, TurnRunProfile, TurnRunRecord, TurnRunState, TurnStateStore, TurnStatus, run_profile::{ - CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, - ModelVisibleToolObservation, ObservationTrust, RegisterProviderToolCallRequest, - ToolObservationDetail, ToolObservationStatus, capability_outcome_to_resolution, + CapabilitySurfaceVersion, ModelVisibleToolObservation, ObservationTrust, + RegisterProviderToolCallRequest, ToolObservationDetail, ToolObservationStatus, resolution, }, }; use serde_json::json; @@ -288,20 +287,15 @@ impl LoopCapabilityPort for SurfacePrimedSpawnAuthPort { &self, _request: CapabilityInvocation, ) -> Result { - Ok( - capability_outcome_to_resolution(CapabilityOutcome::Completed( - CapabilityResultMessage { - result_ref: LoopResultRef::new("result:auth").unwrap(), - safe_summary: "authorized".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )) - .resolution, - ) + Ok(resolution::completed( + LoopResultRef::new("result:auth").unwrap(), + "authorized".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + )) } async fn invoke_capability_batch( @@ -418,20 +412,15 @@ impl LoopCapabilityPort for AuthPassPort { &self, _request: CapabilityInvocation, ) -> Result { - Ok( - capability_outcome_to_resolution(CapabilityOutcome::Completed( - CapabilityResultMessage { - result_ref: LoopResultRef::new("result:auth").unwrap(), - safe_summary: "authorized".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }, - )) - .resolution, - ) + Ok(resolution::completed( + LoopResultRef::new("result:auth").unwrap(), + "authorized".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + )) } async fn invoke_capability_batch( @@ -484,8 +473,7 @@ impl LoopCapabilityPort for FixedToolPort { request: CapabilityInvocation, ) -> Result { Ok( - capability_outcome_to_resolution(completed_outcome(request.capability_id.as_str())) - .resolution, + completed_outcome(request.capability_id.as_str()), ) } @@ -522,8 +510,7 @@ impl LoopCapabilityPort for RecordingBatchPort { request: CapabilityInvocation, ) -> Result { Ok( - capability_outcome_to_resolution(completed_outcome(request.capability_id.as_str())) - .resolution, + completed_outcome(request.capability_id.as_str()), ) } @@ -537,10 +524,9 @@ impl LoopCapabilityPort for RecordingBatchPort { .invocations .iter() .map(|invocation| { - capability_outcome_to_resolution(completed_outcome( + completed_outcome( invocation.capability_id.as_str(), - )) - .resolution + ) }) .collect(), stopped_on_suspension: false, @@ -565,14 +551,12 @@ impl LoopCapabilityPort for SuspendedBatchPort { &self, _request: CapabilityInvocation, ) -> Result { - Ok( - capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:inner-suspended").unwrap(), - safe_summary: "approval required".to_string(), - approval_resume: None, - }) - .resolution, + Ok(resolution::approval_required( + LoopGateRef::new("gate:inner-suspended").unwrap(), + "approval required".to_string(), + None, ) + .resolution) } async fn invoke_capability_batch( @@ -582,11 +566,11 @@ impl LoopCapabilityPort for SuspendedBatchPort { self.batches.lock().unwrap().push(request); Ok(ResolutionBatch { resolutions: vec![ - capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:inner-suspended").unwrap(), - safe_summary: "approval required".to_string(), - approval_resume: None, - }) + resolution::approval_required( + LoopGateRef::new("gate:inner-suspended").unwrap(), + "approval required".to_string(), + None, + ) .resolution, ], stopped_on_suspension: true, @@ -648,8 +632,7 @@ impl LoopCapabilityPort for FailingBatchPort { request: CapabilityInvocation, ) -> Result { Ok( - capability_outcome_to_resolution(completed_outcome(request.capability_id.as_str())) - .resolution, + completed_outcome(request.capability_id.as_str()), ) } @@ -1373,16 +1356,16 @@ async fn invoke_spawn_for_activity( .unwrap() } -fn completed_outcome(label: &str) -> CapabilityOutcome { - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: LoopResultRef::new(format!("result:{label}")).unwrap(), - safe_summary: "completed".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }) +fn completed_outcome(label: &str) -> Resolution { + resolution::completed( + LoopResultRef::new(format!("result:{label}")).unwrap(), + "completed".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + ) } // After the §5.3 collapse the open-set loop reason_kind no longer survives on diff --git a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs index 0b36c8bccf..1bffc59afb 100644 --- a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs +++ b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs @@ -43,9 +43,9 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AgentLoopHostErrorReasonKind, AppendCapabilityResultRef, AssistantReply, BeginAssistantDraft, CapabilityBatchInvocation, - CapabilityDenied, CapabilityDeniedReasonKind, CapabilityInputIssue, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilitySurfaceVersion, - FinalizeAssistantMessage, HostManagedLoopPromptPort, + CapabilityDeniedReasonKind, CapabilityInputIssue, CapabilityInputRef, CapabilityInvocation, + CapabilitySurfaceVersion, FinalizeAssistantMessage, HostManagedLoopPromptPort, + resolution, InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, InMemoryRunProfileResolver, LoopCapabilityPort, LoopContextBundle, LoopContextCompactionKind, LoopContextMessage, LoopContextPort, LoopContextRequest, @@ -57,7 +57,7 @@ use ironclaw_turns::{ ParentLoopOutput, PersonalContextPolicy, PromptMode, PromptSkillContextMetadata, ProviderToolCallReference, ProviderToolDefinition, SkillVisibility, ToolObservationDetail, ToolObservationStatus, UpdateAssistantDraft, VisibleCapabilityRequest, - VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilitySurface, }, }; use tracing_test::traced_test; @@ -4935,8 +4935,11 @@ impl LoopCapabilityPort for StaticToolDefinitionPort { &self, _request: CapabilityInvocation, ) -> Result { - let outcome = resolution::denied(CapabilityDeniedReasonKind::EmptySurface, "test capability port does not execute tools".to_string()).resolution; - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(resolution::denied( + CapabilityDeniedReasonKind::EmptySurface, + "test capability port does not execute tools".to_string(), + ) + .resolution) } async fn invoke_capability_batch( @@ -4947,8 +4950,11 @@ impl LoopCapabilityPort for StaticToolDefinitionPort { .invocations .into_iter() .map(|_| { - let outcome = resolution::denied(CapabilityDeniedReasonKind::EmptySurface, "test capability port does not execute tools".to_string()).resolution; - capability_outcome_to_resolution(outcome).resolution + resolution::denied( + CapabilityDeniedReasonKind::EmptySurface, + "test capability port does not execute tools".to_string(), + ) + .resolution }) .collect(); Ok(ResolutionBatch { diff --git a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs index 054e526854..9834823c78 100644 --- a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs +++ b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs @@ -65,12 +65,12 @@ use ironclaw_turns::{ TurnStatus, run_profile::{ AgentLoopHostError, CapabilityBatchInvocation, CapabilityCallCandidate, - CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, - CapabilityResultMessage, CapabilitySurfaceVersion, ConcurrencyHint, + CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, + CapabilitySurfaceVersion, ConcurrencyHint, InMemoryLoopHostMilestoneSink, InstructionSafetyContext, LoopCancelReasonKind, LoopCapabilityPort, LoopInputAckToken, LoopInputCursorToken, LoopRunContext, NoOpBudgetAccountant, NoOpPolicyGuard, ParentLoopOutput, PromptMode, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilityRequest, VisibleCapabilitySurface, resolution, }, }; use tokio::time::{sleep, timeout}; @@ -956,7 +956,7 @@ impl LoopCapabilityPort for RecordingCapabilityPort { .push(request); let outcome = resolution::completed(LoopResultRef::new(self.capability.result_ref.clone()) .expect("valid harness result ref"), self.capability.safe_summary.clone(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, self.capability.terminate_hint, 0, None, None); - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(outcome) } async fn invoke_capability_batch( diff --git a/crates/ironclaw_runner/tests/hooks_integration.rs b/crates/ironclaw_runner/tests/hooks_integration.rs index 39e43a0b0c..bf5a324b81 100644 --- a/crates/ironclaw_runner/tests/hooks_integration.rs +++ b/crates/ironclaw_runner/tests/hooks_integration.rs @@ -97,12 +97,12 @@ use ironclaw_turns::{ TurnRunnerId, TurnScope, TurnStateStore, TurnStatus, run_profile::{ AgentLoopHostError, CapabilityBatchInvocation, CapabilityDescriptorView, - CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, + CapabilityInputRef, CapabilityInvocation, CapabilitySurfaceVersion, InMemoryLoopHostMilestoneSink, InstructionSafetyContext, LoopCapabilityPort, LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, LoopHostMilestoneKind, LoopModelPort, LoopModelRequest, LoopPromptPort, LoopRunContext, LoopTranscriptPort, RunScopedHookMilestoneSink, VisibleCapabilityRequest, - VisibleCapabilitySurface, capability_outcome_to_resolution, + VisibleCapabilitySurface, resolution, }, runner::ClaimedTurnRun, }; @@ -224,7 +224,7 @@ impl LoopCapabilityPort for RecordingCapabilityPort { .push(request.capability_id.clone()); let outcome = resolution::completed(LoopResultRef::new(format!("result:{}", request.capability_id)) .expect("result ref literal is valid"), "stub capability completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None); - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(outcome) } async fn invoke_capability_batch( @@ -292,7 +292,7 @@ impl LoopCapabilityPort for ProviderAwareCapabilityPort { .push(request.capability_id.clone()); let outcome = resolution::completed(LoopResultRef::new(format!("result:{}", request.capability_id)) .expect("result ref literal is valid"), "stub capability completed".to_string(), ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, false, 0, None, None); - Ok(capability_outcome_to_resolution(outcome).resolution) + Ok(outcome) } async fn invoke_capability_batch( diff --git a/crates/ironclaw_turns/src/run_profile/content_digest.rs b/crates/ironclaw_turns/src/run_profile/content_digest.rs index 3cd541ba4e..564517d38e 100644 --- a/crates/ironclaw_turns/src/run_profile/content_digest.rs +++ b/crates/ironclaw_turns/src/run_profile/content_digest.rs @@ -229,10 +229,6 @@ mod tests { use serde_json::json; use super::*; - use crate::{ - LoopResultRef, - run_profile::{CapabilityProgress, CapabilityResultMessage}, - }; #[test] fn content_digest_is_deterministic_for_identical_content() { @@ -275,27 +271,4 @@ mod tests { ); } - #[test] - fn capability_result_message_without_output_digest_decodes_to_none() { - let payload = json!({ - "result_ref": "result:legacy", - "safe_summary": "capability completed", - "progress": "made_progress", - "terminate_hint": false, - "byte_len": 42 - }); - - let decoded: CapabilityResultMessage = - serde_json::from_value(payload).expect("decode legacy result"); - - assert_eq!( - decoded.output_digest, None, - "legacy result payload without output_digest must decode to None" - ); - assert_eq!( - decoded.result_ref, - LoopResultRef::new("result:legacy").expect("valid result ref") - ); - assert_eq!(decoded.progress, CapabilityProgress::MadeProgress); - } } diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index b79ed6fb68..57afea6ed5 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -8,7 +8,7 @@ use chrono::{DateTime, Utc}; use ironclaw_host_api::{ ApprovalRequestId, CapabilityId, CorrelationId, ExtensionId, HostApiError, INPUT_ENCODE_HUMAN_SUMMARY, ProviderToolName, Resolution, ResolutionBatch, - RuntimeCredentialAuthRequirement, RuntimeKind, ThreadId, + RuntimeKind, ThreadId, }; use serde::{Deserialize, Deserializer, Serialize}; use thiserror::Error; @@ -21,9 +21,8 @@ use crate::{ use super::{ compaction::{CompactionInitiator, LoopCompactionPort}, - content_digest::ContentDigest, instruction_bundle::InstructionBundleFingerprint, - model_observation::{CapabilityFailureDetail, ModelVisibleToolObservation}, + model_observation::ModelVisibleToolObservation, prompt_text::{PromptTextSurface, validate_prompt_text}, refs::{CheckpointSchemaId, LoopDriverId, ModelProfileId}, snapshot::ResolvedRunProfile, @@ -1812,116 +1811,6 @@ pub struct CapabilityBatchInvocation { pub stop_on_first_suspension: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityBatchOutcome { - pub outcomes: Vec, - pub stopped_on_suspension: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum CapabilityOutcome { - Completed(CapabilityResultMessage), - ApprovalRequired { - gate_ref: LoopGateRef, - safe_summary: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - approval_resume: Option, - }, - AuthRequired { - gate_ref: LoopGateRef, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - credential_requirements: Vec, - safe_summary: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - auth_resume: Option, - }, - ResourceBlocked { - gate_ref: LoopGateRef, - safe_summary: String, - }, - /// The model called a client-supplied ("external") tool. The host does not - /// execute it: the run parks and control returns to the API client, which - /// resumes by submitting the tool output. Carries only the opaque gate ref - /// and a bounded safe summary (never the raw caller tool args or output). - ExternalToolPending { - gate_ref: LoopGateRef, - safe_summary: String, - }, - SpawnedProcess(ProcessHandleSummary), - AwaitDependentRun { - gate_ref: LoopGateRef, - result_ref: LoopResultRef, - safe_summary: String, - /// Size in bytes of the payload staged at `result_ref` time - /// (i.e. the serialized capability output, not the size of this struct). - /// Propagated from LoopCapabilityResultWriter::write_capability_result. - /// Used by ByteCapStrategy to evaluate per-capability byte caps. - #[serde(default)] - byte_len: u64, - /// Bounded model-visible metadata for the child result. - #[serde(default, skip_serializing_if = "Option::is_none")] - model_observation: Option, - }, - SpawnedChildRun { - child_run_id: TurnRunId, - result_ref: LoopResultRef, - safe_summary: String, - /// Size in bytes of the payload staged at `result_ref` time - /// (i.e. the serialized capability output, not the size of this struct). - /// Same semantics as AwaitDependentRun.byte_len. - #[serde(default)] - byte_len: u64, - /// Bounded model-visible metadata for the child result. - #[serde(default, skip_serializing_if = "Option::is_none")] - model_observation: Option, - }, - Denied(CapabilityDenied), - Failed(CapabilityFailure), -} - -impl CapabilityOutcome { - pub fn is_suspension(&self) -> bool { - matches!( - self, - Self::ApprovalRequired { .. } - | Self::AuthRequired { .. } - | Self::ResourceBlocked { .. } - | Self::ExternalToolPending { .. } - | Self::AwaitDependentRun { .. } - | Self::SpawnedProcess(_) - ) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityResultMessage { - pub result_ref: LoopResultRef, - pub safe_summary: String, - /// Typed host signal describing whether this result advanced the loop's - /// evidence/state. This lets the loop distinguish deterministic - /// no-change outcomes from productive calls without inferring progress - /// from prose summaries or token counts. - #[serde(default)] - pub progress: CapabilityProgress, - /// Host hint that this completed capability result should end the loop - /// naturally after the current batch. Defaults to false for compatibility - /// with older hosts. - #[serde(default)] - pub terminate_hint: bool, - /// Serialized output size in bytes — pure metadata, no PII. - #[serde(default)] - pub byte_len: u64, - /// Digest over normalized output content. Optional for backward - /// compatibility and for synthetic results that do not stage real output. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub output_digest: Option, - /// Bounded, model-visible result metadata or preview. Full output remains - /// host-owned and is retrieved only through the result reference. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub model_observation: Option, -} - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum CapabilityProgress { @@ -1938,18 +1827,6 @@ pub enum CapabilityProgress { Blocked, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ProcessHandleSummary { - pub process_ref: LoopProcessRef, - pub safe_summary: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityDenied { - pub reason_kind: CapabilityDeniedReasonKind, - pub safe_summary: String, -} - #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CapabilityDeniedReasonKind { @@ -2011,14 +1888,6 @@ impl<'de> Deserialize<'de> for CapabilityDeniedReasonKind { } } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CapabilityFailure { - pub error_kind: CapabilityFailureKind, - pub safe_summary: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub detail: Option, -} - // Deliberately NOT `#[non_exhaustive]`: the `Unknown(CapabilityFailureKindValue)` // variant is the forward-compat / open-set escape hatch (a newer producer's // unrecognized wire string deserializes into `Unknown`), and the manual diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index 34d8e2269d..3f803f7858 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -24,7 +24,6 @@ mod prompt; mod prompt_text; mod refs; pub mod resolution; -mod resolution_mapping; mod resolver; mod runtime_context; mod skill_context; @@ -49,11 +48,10 @@ pub use host::{ AgentLoopDriverHost, AgentLoopHostError, AgentLoopHostErrorKind, AgentLoopHostErrorReasonKind, AppendCapabilityResultRef, AssistantReply, AuthResumeApprovalIdentity, BatchPolicyKind, BeginAssistantDraft, CapabilityApprovalResume, CapabilityAuthResume, CapabilityBatchInvocation, - CapabilityBatchOutcome, CapabilityCallCandidate, CapabilityDenied, CapabilityDeniedReasonKind, - CapabilityDeniedReasonKindValue, CapabilityDescriptorView, CapabilityFailure, - CapabilityFailureKind, CapabilityFailureKindValue, CapabilityInputRef, CapabilityInvocation, - CapabilityOutcome, CapabilityProgress, CapabilityResultMessage, CapabilityResumeToken, - CapabilitySurfaceVersion, ConcurrencyHint, FinalizeAssistantMessage, + CapabilityCallCandidate, CapabilityDeniedReasonKind, CapabilityDeniedReasonKindValue, + CapabilityDescriptorView, CapabilityFailureKind, CapabilityFailureKindValue, CapabilityInputRef, + CapabilityInvocation, CapabilityProgress, CapabilityResumeToken, CapabilitySurfaceVersion, + ConcurrencyHint, FinalizeAssistantMessage, LOOP_CONTEXT_SNIPPET_MODEL_CONTENT_MAX_BYTES, LOOP_CONTEXT_TOTAL_MODEL_CONTENT_MAX_BYTES, LoadCheckpointPayloadRequest, LoadedCheckpointPayload, LoopCancelReasonKind, LoopCancellationPort, LoopCancellationSignal, LoopCapabilityPort, LoopCheckpointKind, @@ -67,7 +65,7 @@ pub use host::{ LoopProgressEvent, LoopProgressPort, LoopPromptBundle, LoopPromptBundleAuthority, LoopPromptBundleGrant, LoopPromptBundleRef, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopSafeSummary, LoopTranscriptPort, ModelStreamChunk, - ParentLoopOutput, ProcessHandleSummary, PromptMode, ProviderToolCall, + ParentLoopOutput, PromptMode, ProviderToolCall, ProviderToolCallCapabilityIds, ProviderToolCallReference, ProviderToolCallReplay, ProviderToolDefinition, RegisterProviderToolCallRequest, StageCheckpointPayloadRequest, UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, @@ -114,7 +112,6 @@ pub use refs::{ RunProfileSourceLayer, RunProfileSourceRef, RunnerPoolId, SchedulingClass, }; pub use resolution::{DeniedResolution, GatedResolution}; -pub use resolution_mapping::{MappedResolution, RefBindings, capability_outcome_to_resolution}; pub use resolver::{ InMemoryRunProfileRegistry, InMemoryRunProfileResolver, RunProfileDefinition, RunProfileRegistryError, RunProfileResolutionRequest, RunProfileResolver, diff --git a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs b/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs deleted file mode 100644 index ee562dab2d..0000000000 --- a/crates/ironclaw_turns/src/run_profile/resolution_mapping.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Transitional `CapabilityOutcome` → `Resolution` adapter (arch-simplification -//! §5.3 Stage 2b — being deleted). -//! -//! The non-lossy redaction now lives in the producer-facing constructors in -//! [`super::resolution`]; this module is a thin delegator kept ONLY so producers -//! not yet migrated to the constructors keep compiling through the collapse. It -//! mints no refs and carries no side-table: [`RefBindings`] is always empty (the -//! flip routes loop-ref recovery via the channel's preserved `origin`), and the -//! sibling records are re-collected from the constructor results. Once every -//! producer emits a `Resolution` directly, this file and `MappedResolution`/ -//! `RefBindings` are deleted. - -use ironclaw_host_api::{DenyRecord, GateRecord, Resolution}; - -use super::host::CapabilityOutcome; -use super::resolution; - -/// A [`Resolution`] plus the side records its opaque refs render from (§5.2.9). -/// -/// Retained transitionally for callers of [`capability_outcome_to_resolution`]. -/// `bindings` is always empty; loop-ref recovery rides the channel `origin`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MappedResolution { - pub resolution: Resolution, - pub gate_record: Option, - pub deny_record: Option, - pub bindings: RefBindings, -} - -/// Retained transitionally; always empty (the flip preserves loop refs on the -/// channel `origin`, not this side-table). Deleted with `resolution_mapping`. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct RefBindings; - -/// Map one loop-facing [`CapabilityOutcome`] onto its host_api [`Resolution`] -/// channel plus any side record, delegating to the producer-facing constructors -/// in [`super::resolution`]. -pub fn capability_outcome_to_resolution(outcome: CapabilityOutcome) -> MappedResolution { - let bare = |resolution: Resolution| MappedResolution { - resolution, - gate_record: None, - deny_record: None, - bindings: RefBindings, - }; - match outcome { - CapabilityOutcome::Completed(message) => bare(resolution::completed( - message.result_ref, - message.safe_summary, - message.progress, - message.terminate_hint, - message.byte_len, - message.output_digest, - message.model_observation, - )), - CapabilityOutcome::Failed(failure) => bare(resolution::failed( - failure.error_kind, - failure.safe_summary, - failure.detail, - )), - CapabilityOutcome::SpawnedProcess(process) => { - bare(resolution::spawned_process(process.process_ref)) - } - CapabilityOutcome::SpawnedChildRun { - child_run_id, - result_ref, - safe_summary, - byte_len, - model_observation, - } => bare(resolution::spawned_child_run( - child_run_id, - result_ref, - safe_summary, - byte_len, - model_observation, - )), - CapabilityOutcome::Denied(denied) => { - let denied = resolution::denied(denied.reason_kind, denied.safe_summary); - MappedResolution { - resolution: denied.resolution, - gate_record: None, - deny_record: Some(denied.deny_record), - bindings: RefBindings, - } - } - CapabilityOutcome::ApprovalRequired { - gate_ref, - safe_summary, - approval_resume, - } => gated(resolution::approval_required( - gate_ref, - safe_summary, - approval_resume, - )), - CapabilityOutcome::AuthRequired { - gate_ref, - credential_requirements, - safe_summary, - auth_resume, - } => gated(resolution::auth_required( - gate_ref, - credential_requirements, - safe_summary, - auth_resume, - )), - CapabilityOutcome::ResourceBlocked { - gate_ref, - safe_summary, - } => gated(resolution::resource_blocked(gate_ref, safe_summary)), - CapabilityOutcome::AwaitDependentRun { - gate_ref, - result_ref, - safe_summary, - byte_len, - model_observation, - } => gated(resolution::await_dependent_run( - gate_ref, - result_ref, - safe_summary, - byte_len, - model_observation, - )), - CapabilityOutcome::ExternalToolPending { - gate_ref, - safe_summary, - } => gated(resolution::external_tool_pending(gate_ref, safe_summary)), - } -} - -fn gated(gated: resolution::GatedResolution) -> MappedResolution { - MappedResolution { - resolution: gated.resolution, - gate_record: gated.gate_record, - deny_record: None, - bindings: RefBindings, - } -} diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index dd3b2b5f22..3ea6c88bb1 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -11,16 +11,16 @@ use ironclaw_turns::{ AcceptedMessageRef, AgentLoopDriver, AgentLoopDriverDescriptor, AgentLoopDriverError, DefaultTurnCoordinator, IdempotencyKey, InMemoryTurnStateStore, LoopBlocked, LoopBlockedKind, LoopCompleted, LoopCompletionKind, LoopExit, LoopExitId, LoopGateRef, LoopMessageRef, - LoopResultRef, ProductTurnContext, ReplyTargetBindingRef, RunOriginAdapter, RunProfileRequest, + ProductTurnContext, ReplyTargetBindingRef, RunOriginAdapter, RunProfileRequest, RunProfileVersion, SourceBindingRef, SubmitTurnRequest, SubmitTurnResponse, TurnActor, TurnCheckpointId, TurnCoordinator, TurnLeaseToken, TurnOriginKind, TurnOwner, TurnRunId, TurnRunState, TurnRunnerId, TurnStatus, events::EventCursor, run_profile::{ AgentLoopDriverHost, AgentLoopHostError, AgentLoopHostErrorKind, AssistantReply, - BatchPolicyKind, CapabilityBatchInvocation, CapabilityDenied, CapabilityDeniedReasonKind, - CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilityOutcome, - CapabilityProgress, CapabilityResultMessage, CapabilitySurfaceVersion, + BatchPolicyKind, CapabilityBatchInvocation, CapabilityDeniedReasonKind, + CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilityProgress, + CapabilitySurfaceVersion, CommunicationRuntimeContext, ConcurrencyHint, ConnectedChannelSummary, ConnectedChannelsState, DeliveryTargetState, DeliveryTargetSummary, FinalizeAssistantMessage, HostManagedLoopModelPort, HostManagedLoopPromptPort, @@ -42,7 +42,7 @@ use ironclaw_turns::{ LoopPromptPort, LoopRunContext, LoopRunInfoPort, LoopRuntimeContext, LoopSafeSummary, LoopTranscriptPort, ModelWorkOutcome, ModelWorkRequest, ParentLoopOutput, PromptMode, PromptSkillContextMetadata, VisibleCapabilityRequest, VisibleCapabilitySurface, - capability_outcome_to_resolution, + resolution, }, runner::{ClaimRunRequest, TurnRunTransitionPort}, }; @@ -2467,43 +2467,32 @@ fn loop_host_refs_validate_when_deserialized() { #[test] fn capability_denied_reason_kind_is_typed_and_wire_compatible() { - let denied = CapabilityDenied { - reason_kind: CapabilityDeniedReasonKind::EmptySurface, - safe_summary: "no capabilities are available to this loop".to_string(), - }; - - let wire = serde_json::to_string(&denied).unwrap(); - assert!(wire.contains(r#""reason_kind":"empty_surface""#)); - - let legacy = serde_json::json!({ - "reason_kind": "empty_surface", - "safe_summary": "no capabilities are available to this loop" - }); - let decoded = serde_json::from_value::(legacy).unwrap(); + // The producer-facing `resolution::denied` consumes `CapabilityDeniedReasonKind`; + // its open-set wire compatibility (fixed tags + free-form `Unknown`, secret + // markers rejected) is the loop contract that survives the §5.3 collapse. assert_eq!( - decoded.reason_kind, - CapabilityDeniedReasonKind::EmptySurface + serde_json::to_string(&CapabilityDeniedReasonKind::EmptySurface).unwrap(), + r#""empty_surface""# ); - assert_eq!(decoded.reason_kind.as_str(), "empty_surface"); - assert_eq!(decoded.reason_kind.to_string(), "empty_surface"); - let historical_unknown = serde_json::json!({ - "reason_kind": "host_policy_denied", - "safe_summary": "capability denied by host policy" - }); - let decoded_unknown = serde_json::from_value::(historical_unknown).unwrap(); - assert_eq!(decoded_unknown.reason_kind.as_str(), "host_policy_denied"); - assert_eq!( - decoded_unknown.reason_kind.to_string(), - "host_policy_denied" - ); + let decoded: CapabilityDeniedReasonKind = + serde_json::from_value(serde_json::json!("empty_surface")).unwrap(); + assert_eq!(decoded, CapabilityDeniedReasonKind::EmptySurface); + assert_eq!(decoded.as_str(), "empty_surface"); + assert_eq!(decoded.to_string(), "empty_surface"); + + let decoded_unknown: CapabilityDeniedReasonKind = + serde_json::from_value(serde_json::json!("host_policy_denied")).unwrap(); + assert_eq!(decoded_unknown.as_str(), "host_policy_denied"); + assert_eq!(decoded_unknown.to_string(), "host_policy_denied"); assert!(matches!( - decoded_unknown.reason_kind, + decoded_unknown, CapabilityDeniedReasonKind::Unknown(_) )); - - let unknown_wire = serde_json::to_string(&decoded_unknown).unwrap(); - assert!(unknown_wire.contains(r#""reason_kind":"host_policy_denied""#)); + assert_eq!( + serde_json::to_string(&decoded_unknown).unwrap(), + r#""host_policy_denied""# + ); let constructed_unknown = CapabilityDeniedReasonKind::unknown("host_policy_denied").unwrap(); assert_eq!(constructed_unknown.as_str(), "host_policy_denied"); @@ -2511,44 +2500,16 @@ fn capability_denied_reason_kind_is_typed_and_wire_compatible() { assert!(CapabilityDeniedReasonKind::unknown("secret_policy").is_err()); } -#[test] -fn capability_result_message_byte_len_round_trips() { - let json = serde_json::json!({ - "result_ref": "result:big", - "safe_summary": "big result", - "byte_len": 33_001u64 - }); - let decoded: CapabilityResultMessage = serde_json::from_value(json).unwrap(); - assert_eq!(decoded.byte_len, 33_001); -} - -#[test] -fn capability_result_message_byte_len_defaults_to_zero_for_legacy_payload() { - // Legacy hosts that don't yet emit byte_len must still decode cleanly. - let json = serde_json::json!({ - "result_ref": "result:legacy", - "safe_summary": "no byte_len field" - }); - let decoded: CapabilityResultMessage = serde_json::from_value(json).unwrap(); - assert_eq!(decoded.byte_len, 0); -} - #[test] fn capability_progress_accepts_legacy_complete_wire_value() { - let legacy_result = serde_json::json!({ - "result_ref": "result:legacy-complete", - "safe_summary": "legacy host completed the requested objective", - "progress": "complete" - }); - - let decoded = serde_json::from_value::(legacy_result).unwrap(); - - assert_eq!( - decoded.result_ref, - LoopResultRef::new("result:legacy-complete").unwrap() - ); - assert_eq!(decoded.progress, CapabilityProgress::MadeProgress); - assert!(!decoded.terminate_hint); + // `CapabilityProgress` is consumed by `resolution::completed`; its legacy + // "complete" alias must still decode to `MadeProgress`. + let decoded: CapabilityProgress = + serde_json::from_value(serde_json::json!("complete")).unwrap(); + assert_eq!(decoded, CapabilityProgress::MadeProgress); + let decoded: CapabilityProgress = + serde_json::from_value(serde_json::json!("made_progress")).unwrap(); + assert_eq!(decoded, CapabilityProgress::MadeProgress); } #[tokio::test] @@ -2902,7 +2863,7 @@ struct RecordingAgentLoopHost { effects: Mutex>, context_requests: Mutex>, model_responses: Mutex>, - capability_outcomes: Mutex>, + capability_outcomes: Mutex>, visible_surface: VisibleCapabilitySurface, milestone_sink: Arc, context_message_safe_summary: String, @@ -3018,7 +2979,7 @@ impl RecordingAgentLoopHost { self.model_responses.lock().unwrap().push(response); } - fn push_capability_outcome(&self, outcome: CapabilityOutcome) { + fn push_capability_outcome(&self, outcome: ironclaw_host_api::Resolution) { self.capability_outcomes.lock().unwrap().push(outcome); } @@ -3221,7 +3182,6 @@ impl LoopCapabilityPort for RecordingAgentLoopHost { "capability outcome unavailable", ) }) - .map(|outcome| capability_outcome_to_resolution(outcome).resolution) } async fn invoke_capability_batch( diff --git a/crates/ironclaw_turns/tests/turn_coordinator_contract.rs b/crates/ironclaw_turns/tests/turn_coordinator_contract.rs index 50a7933f00..40f9a7ec14 100644 --- a/crates/ironclaw_turns/tests/turn_coordinator_contract.rs +++ b/crates/ironclaw_turns/tests/turn_coordinator_contract.rs @@ -16,7 +16,7 @@ use ironclaw_turns::{ DefaultTurnLifecycleEventBus, GateRef, GetRunStateRequest, IdempotencyKey, InMemoryRunProfileResolver, InMemoryTurnEventSink, InMemoryTurnStateStore, InMemoryTurnStateStoreLimits, LifecyclePublicationErrorPort, LifecyclePublishingTurnStateStore, - LoopBlockedKind, LoopCheckpointStateRef, LoopExitMapping, LoopGateRef, LoopResultRef, + LoopBlockedKind, LoopCheckpointStateRef, LoopExitMapping, LoopGateRef, ProductTurnContext, ReplyTargetBindingRef, ResolvedRunProfile, ResumeTurnRequest, RetryTurnRequest, RetryTurnResponse, RunOriginAdapter, RunProfileId, RunProfileRequest, RunProfileResolutionError, RunProfileResolutionRequest, RunProfileResolver, RunProfileVersion, @@ -34,11 +34,7 @@ use ironclaw_turns::{ TurnSpawnTreePort, TurnSpawnTreeStateStore, TurnStateBlockPersistence, TurnStateStore, TurnStatus, TurnSurfaceType, events::EventCursor, - run_profile::{ - CapabilityOutcome, LoopGateKind, LoopModelRouteSnapshot, LoopModelUsage, - ModelVisibleToolObservation, ObservationTrust, ToolObservationDetail, - ToolObservationStatus, - }, + run_profile::{LoopGateKind, LoopModelRouteSnapshot, LoopModelUsage}, runner::{ ApplyValidatedLoopExitRequest, BlockRunRequest, CancelRunCompletionRequest, ClaimRunRequest, ClaimedTurnRun, CompleteRunRequest, FailRunRequest, HeartbeatRequest, @@ -150,152 +146,6 @@ fn turn_scope_agent_id_is_optional() { assert_eq!(scope.agent_id, None); } -#[test] -fn subagent_capability_outcomes_round_trip_with_suspension_semantics() { - let child_run_id = TurnRunId::new(); - let result_ref = LoopResultRef::new("result:child").unwrap(); - let spawned = CapabilityOutcome::SpawnedChildRun { - child_run_id, - result_ref: result_ref.clone(), - safe_summary: "spawned in background".to_string(), - byte_len: 0, - model_observation: None, - }; - let spawned_json = serde_json::to_value(&spawned).unwrap(); - // #[serde(default)] ensures legacy wire payloads (without byte_len) decode - // cleanly with byte_len = 0. Non-zero values must always round-trip through - // serialize→deserialize since byte_len has no skip_serializing_if attribute - // (so 0 is also always present on the wire, as this assertion verifies). - assert_eq!( - spawned_json, - serde_json::json!({ - "spawned_child_run": { - "child_run_id": child_run_id, - "result_ref": result_ref, - "safe_summary": "spawned in background", - "byte_len": 0 - } - }) - ); - assert!(!spawned.is_suspension()); - assert_eq!( - serde_json::from_value::(spawned_json).unwrap(), - spawned - ); - - let gate_ref = LoopGateRef::new("gate:dependent-run").unwrap(); - let result_ref = LoopResultRef::new("result:dependent-run").unwrap(); - let awaiting = CapabilityOutcome::AwaitDependentRun { - gate_ref: gate_ref.clone(), - result_ref: result_ref.clone(), - safe_summary: "waiting on child".to_string(), - byte_len: 0, - model_observation: None, - }; - let awaiting_json = serde_json::to_value(&awaiting).unwrap(); - assert_eq!( - awaiting_json, - serde_json::json!({ - "await_dependent_run": { - "gate_ref": gate_ref, - "result_ref": result_ref, - "safe_summary": "waiting on child", - "byte_len": 0 - } - }) - ); - assert!(awaiting.is_suspension()); - assert_eq!( - serde_json::from_value::(awaiting_json).unwrap(), - awaiting - ); -} - -#[test] -fn subagent_capability_outcomes_round_trip_with_non_zero_byte_len() { - // Verify byte_len survives serde round-trip for BOTH AwaitDependentRun - // and SpawnedChildRun (each variant). A regression that silently - // decoded byte_len: 0 from the wire would defeat ByteCapStrategy for - // exactly these paths. - let gate_ref = LoopGateRef::new("gate:test-bytes").expect("valid"); - let result_ref = LoopResultRef::new("result:test-bytes").expect("valid"); - let await_dep = CapabilityOutcome::AwaitDependentRun { - gate_ref, - result_ref, - safe_summary: "await large".to_string(), - byte_len: 48_500, - model_observation: None, - }; - let json = serde_json::to_value(&await_dep).expect("serialize"); - let decoded: CapabilityOutcome = serde_json::from_value(json).expect("decode"); - if let CapabilityOutcome::AwaitDependentRun { byte_len, .. } = decoded { - assert_eq!(byte_len, 48_500); - } else { - panic!("expected AwaitDependentRun variant"); - } - - let child_run_id = TurnRunId::new(); - let result_ref = LoopResultRef::new("result:child-bytes").expect("valid"); - let spawn = CapabilityOutcome::SpawnedChildRun { - child_run_id, - result_ref, - safe_summary: "spawn large".to_string(), - byte_len: 60_000, - model_observation: None, - }; - let json = serde_json::to_value(&spawn).expect("serialize"); - let decoded: CapabilityOutcome = serde_json::from_value(json).expect("decode"); - if let CapabilityOutcome::SpawnedChildRun { byte_len, .. } = decoded { - assert_eq!(byte_len, 60_000); - } else { - panic!("expected SpawnedChildRun variant"); - } -} - -#[test] -fn subagent_capability_outcomes_round_trip_model_observation() { - let result_ref = LoopResultRef::new("result:child-observation").expect("valid"); - let observation = ModelVisibleToolObservation { - schema_version: 1, - status: ToolObservationStatus::Success, - summary: "Use result_read to continue this child result.".to_string(), - detail: ToolObservationDetail::ResultReference { - result_ref: result_ref.as_str().to_string(), - byte_len: 2_048, - preview: Some("first bounded chunk".to_string()), - total_bytes: Some(4_096), - next_offset: Some(2_048), - item_count: None, - }, - artifacts: Vec::new(), - recovery: None, - trust: ObservationTrust::UntrustedToolOutput, - }; - let spawned = CapabilityOutcome::SpawnedChildRun { - child_run_id: TurnRunId::new(), - result_ref: result_ref.clone(), - safe_summary: "spawned child".to_string(), - byte_len: 2_048, - model_observation: Some(observation.clone()), - }; - let awaiting = CapabilityOutcome::AwaitDependentRun { - gate_ref: LoopGateRef::new("gate:child-observation").expect("valid"), - result_ref, - safe_summary: "awaiting child".to_string(), - byte_len: 2_048, - model_observation: Some(observation), - }; - - for outcome in [spawned, awaiting] { - let serialized = serde_json::to_value(&outcome).expect("serialize"); - assert!(serialized.to_string().contains("next_offset")); - assert_eq!( - serde_json::from_value::(serialized).expect("deserialize"), - outcome - ); - } -} - #[test] fn retry_turn_request_wire_shape_matches_resume_without_gate_resolution() { let run_id = TurnRunId::new(); From cc280e5a1e48ebd57a2714234add925747107aa4 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:45:06 +0000 Subject: [PATCH 10/12] refactor(reborn): retain CapabilityResultMessage/CapabilityFailure as executor-internal vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope correction: these two are NOT dead payloads — the agent_loop executor reconstructs them from host_api::Outcome (capability_result_from_outcome / capability_failure_from_recoverable, added by the flip) and consumes them across gates/capability_helpers/strategies. They are loop-internal working types now (no producer emits them; documented as such). The genuinely-dead CapabilityOutcome/CapabilityBatchOutcome/CapabilityDenied/ProcessHandleSummary stay deleted. Workspace builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/ironclaw_turns/src/run_profile/host.rs | 38 ++++++++++++++++++- crates/ironclaw_turns/src/run_profile/mod.rs | 6 +-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/ironclaw_turns/src/run_profile/host.rs b/crates/ironclaw_turns/src/run_profile/host.rs index 57afea6ed5..a398a3a1ed 100644 --- a/crates/ironclaw_turns/src/run_profile/host.rs +++ b/crates/ironclaw_turns/src/run_profile/host.rs @@ -21,8 +21,9 @@ use crate::{ use super::{ compaction::{CompactionInitiator, LoopCompactionPort}, + content_digest::ContentDigest, instruction_bundle::InstructionBundleFingerprint, - model_observation::ModelVisibleToolObservation, + model_observation::{CapabilityFailureDetail, ModelVisibleToolObservation}, prompt_text::{PromptTextSurface, validate_prompt_text}, refs::{CheckpointSchemaId, LoopDriverId, ModelProfileId}, snapshot::ResolvedRunProfile, @@ -1827,6 +1828,41 @@ pub enum CapabilityProgress { Blocked, } +/// The agent-loop executor's reconstructed view of a completed capability result. +/// +/// Producers no longer emit this (they emit [`Resolution`](ironclaw_host_api::Resolution) +/// directly, §5.3 Stage 2b); the executor rebuilds it from the host_api +/// [`Outcome`](ironclaw_host_api::Outcome) channel (`capability_result_from_outcome`) +/// to feed its result-admission/strategy pipeline. It is loop-internal working +/// vocabulary, no longer a wire/producer DTO. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityResultMessage { + pub result_ref: LoopResultRef, + pub safe_summary: String, + #[serde(default)] + pub progress: CapabilityProgress, + #[serde(default)] + pub terminate_hint: bool, + #[serde(default)] + pub byte_len: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_digest: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_observation: Option, +} + +/// The agent-loop executor's reconstructed view of a recoverable capability +/// failure, rebuilt from the host_api `RecoverableFailure` verdict +/// (`capability_failure_from_recoverable`) to drive retry/explain recovery. +/// Loop-internal working vocabulary, no longer a wire/producer DTO. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityFailure { + pub error_kind: CapabilityFailureKind, + pub safe_summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum CapabilityDeniedReasonKind { diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index 3f803f7858..ca2b79471c 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -49,9 +49,9 @@ pub use host::{ AppendCapabilityResultRef, AssistantReply, AuthResumeApprovalIdentity, BatchPolicyKind, BeginAssistantDraft, CapabilityApprovalResume, CapabilityAuthResume, CapabilityBatchInvocation, CapabilityCallCandidate, CapabilityDeniedReasonKind, CapabilityDeniedReasonKindValue, - CapabilityDescriptorView, CapabilityFailureKind, CapabilityFailureKindValue, CapabilityInputRef, - CapabilityInvocation, CapabilityProgress, CapabilityResumeToken, CapabilitySurfaceVersion, - ConcurrencyHint, FinalizeAssistantMessage, + CapabilityDescriptorView, CapabilityFailure, CapabilityFailureKind, CapabilityFailureKindValue, + CapabilityInputRef, CapabilityInvocation, CapabilityProgress, CapabilityResultMessage, + CapabilityResumeToken, CapabilitySurfaceVersion, ConcurrencyHint, FinalizeAssistantMessage, LOOP_CONTEXT_SNIPPET_MODEL_CONTENT_MAX_BYTES, LOOP_CONTEXT_TOTAL_MODEL_CONTENT_MAX_BYTES, LoadCheckpointPayloadRequest, LoadedCheckpointPayload, LoopCancelReasonKind, LoopCancellationPort, LoopCancellationSignal, LoopCapabilityPort, LoopCheckpointKind, From b1d85e68b9af599eebd859fbbc0910f7a4805e62 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 22:59:04 +0000 Subject: [PATCH 11/12] =?UTF-8?q?test(reborn):=20migrate=20integration=20h?= =?UTF-8?q?arness=20capability=20double=20off=20CapabilityOutcome=20(?= =?UTF-8?q?=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordingTestCapabilityPort (the shared tests/integration double) builds host_api::Resolution via the producer constructors; completed_result returns Resolution, approval/echo paths emit resolution::approval_required/completed directly. Unblocks every reborn_integration_* binary. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../doubles/recording_test_capability_port.rs | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/tests/integration/support/doubles/recording_test_capability_port.rs b/tests/integration/support/doubles/recording_test_capability_port.rs index 7efa57341e..c42ec24f0c 100644 --- a/tests/integration/support/doubles/recording_test_capability_port.rs +++ b/tests/integration/support/doubles/recording_test_capability_port.rs @@ -20,9 +20,9 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, CapabilityBatchInvocation, CapabilityCallCandidate, CapabilityDescriptorView, CapabilityInputRef, - CapabilityInvocation, CapabilityOutcome, CapabilityResultMessage, CapabilitySurfaceVersion, - ConcurrencyHint, LoopCapabilityPort, ProviderToolCallReplay, ProviderToolDefinition, - VisibleCapabilityRequest, VisibleCapabilitySurface, capability_outcome_to_resolution, + CapabilityInvocation, CapabilitySurfaceVersion, ConcurrencyHint, LoopCapabilityPort, + ProviderToolCallReplay, ProviderToolDefinition, VisibleCapabilityRequest, + VisibleCapabilitySurface, resolution, }, }; use serde_json::json; @@ -157,18 +157,18 @@ impl RecordingTestCapabilityPort { allowlist } - fn completed_result(&self) -> CapabilityOutcome { + fn completed_result(&self) -> Resolution { let ordinal = self.next_result.fetch_add(1, Ordering::SeqCst); - CapabilityOutcome::Completed(CapabilityResultMessage { - result_ref: ironclaw_turns::LoopResultRef::new(format!("result:test-echo-{ordinal}")) + resolution::completed( + ironclaw_turns::LoopResultRef::new(format!("result:test-echo-{ordinal}")) .expect("valid result ref"), - safe_summary: "echo: hi".to_string(), - progress: ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, - terminate_hint: false, - byte_len: 0, - output_digest: None, - model_observation: None, - }) + "echo: hi".to_string(), + ironclaw_turns::run_profile::CapabilityProgress::MadeProgress, + false, + 0, + None, + None, + ) } } @@ -272,35 +272,30 @@ impl LoopCapabilityPort for RecordingTestCapabilityPort { if matches!(self.mode, CapabilityMode::ApprovalThenEcho) && self.approval_calls.fetch_add(1, Ordering::SeqCst) == 0 { - return Ok( - capability_outcome_to_resolution(CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:test-approval").expect("valid gate ref"), - safe_summary: "test approval required".to_string(), - approval_resume: None, - }) - .resolution, - ); + return Ok(resolution::approval_required( + LoopGateRef::new("gate:test-approval").expect("valid gate ref"), + "test approval required".to_string(), + None, + ) + .resolution); } if matches!(self.mode, CapabilityMode::SpawnAuthThenApprovalThenEcho) { match self.approval_calls.fetch_add(1, Ordering::SeqCst) { 0 => { - return Ok(capability_outcome_to_resolution(self.completed_result()).resolution); + return Ok(self.completed_result()); } 1 => { - return Ok(capability_outcome_to_resolution( - CapabilityOutcome::ApprovalRequired { - gate_ref: LoopGateRef::new("gate:test-approval") - .expect("valid gate ref"), - safe_summary: "test approval required".to_string(), - approval_resume: None, - }, + return Ok(resolution::approval_required( + LoopGateRef::new("gate:test-approval").expect("valid gate ref"), + "test approval required".to_string(), + None, ) .resolution); } _ => {} } } - Ok(capability_outcome_to_resolution(self.completed_result()).resolution) + Ok(self.completed_result()) } async fn invoke_capability_batch( From 5ec51081919cbe3d3542d93bedd09dfe86411f83 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sun, 19 Jul 2026 23:07:57 +0000 Subject: [PATCH 12/12] =?UTF-8?q?docs(reborn):=20refresh=20loop=5Fhost=20s?= =?UTF-8?q?eam=20comments=20post-collapse=20(=C2=A75.3=20Stage=202b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam TODO to delete CapabilityOutcome is done; dispatch emits GatedResolution directly. Comment-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironclaw_loop_host/src/capability_port.rs | 32 +++++++------------ 1 file changed, 12 insertions(+), 20 deletions(-) diff --git a/crates/ironclaw_loop_host/src/capability_port.rs b/crates/ironclaw_loop_host/src/capability_port.rs index 0ed38d08bb..c0c20cdf60 100644 --- a/crates/ironclaw_loop_host/src/capability_port.rs +++ b/crates/ironclaw_loop_host/src/capability_port.rs @@ -1788,26 +1788,22 @@ impl LoopCapabilityPort for HostRuntimeLoopCapabilityPort { &self, request: CapabilityInvocation, ) -> Result { - // §5.3 Stage 2 (the atomic flip): the loop-facing result IS the host_api - // `Resolution` now. Dispatch produces the loop-facing `CapabilityOutcome` - // internally; `persist_gate_record_for_mapped` maps it to persist the - // durable gate record its channel renders from; and the boundary returns - // the mapped `Resolution` (its `origin`/refs carry every loop-side ref the - // executor reconstructs from). The idempotency key is derived INSIDE - // `persist_gate_record_for_mapped`, after dispatch and only for a + // §5.3 Stage 2b (collapse complete): dispatch produces the host_api + // `Resolution` directly, paired with the durable `GateRecord` its channel + // renders from (a `GatedResolution`) — mapped ONCE, by construction, so + // the returned resolution carries the SAME gate ref the record is + // persisted under. `persist_gate_record_for_mapped` persists that record + // and returns the resolution to hand back; on a concurrent duplicate it + // is the OWNER's resolution (whose gate ref the record is under), returned + // only AFTER its durable save completes (#6287). The idempotency key is + // derived INSIDE `persist_gate_record_for_mapped`, after dispatch and only + // for a // gate-bearing outcome — its `resume.input_ref` binding is the // STORE-derived one (same derivation the dispatch cache uses), so it stays // byte-stable and identical to dispatch's (§5.3 Stage 0). Deriving it there // (rather than up front) keeps dispatch's own resume identity/activity // validation the FIRST error a malformed resume surfaces — a missing/stale // resume payload must not pre-empt an `InvalidInvocation` activity mismatch. - // Stage 2b: dispatch produces the `GatedResolution` (resolution + the - // durable gate record its channel renders from) directly — mapped once, - // by construction, so the return value carries the SAME gate ref the - // record is persisted under. `persist_gate_record_for_mapped` persists - // that record and returns the resolution to actually hand back; on a - // concurrent duplicate it is the OWNER's resolution (whose gate ref the - // record is under), returned only AFTER its durable save completes (#6287). let gated = self.invoke_capability_dispatch(request.clone()).await?; self.persist_gate_record_for_mapped(&request, gated).await } @@ -1840,12 +1836,8 @@ impl LoopCapabilityPort for HostRuntimeLoopCapabilityPort { } impl HostRuntimeLoopCapabilityPort { - // TODO(Slice C result-wiring): producer still emits `CapabilityOutcome`, - // converted at the loop_host seam; migrate to emit `Resolution` directly then - // delete `CapabilityOutcome`. - // - /// Derive the host_api [`Resolution`] for a loop-facing outcome and persist - /// the durable, model-visible [`GateRecord`] a later resume turn renders from + /// Persist the durable, model-visible [`GateRecord`] a later resume turn + /// renders from /// (§5.2.9), keyed by the freshly-minted [`GateRef`] on the resolution /// channel (#6242 mapping / #6243 store). `DenyRecord` is terminal and /// same-turn (per #6243) and is intentionally NOT persisted; `Done` and