Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
b161bb5
fix(reborn): surface specific failure summaries for loop-exit categor…
italic-jinxin Jun 26, 2026
97f5024
fix(reborn): surface capability failure detail in per-tool UI preview…
italic-jinxin Jun 26, 2026
ed52129
fix(reborn): also surface failure safe_summary when no structured iss…
italic-jinxin Jun 26, 2026
27ce600
fix(reborn): address PR review on failure-preview staging (#5289)
italic-jinxin Jun 26, 2026
2e64432
fix(reborn): carry tool failure detail to the live per-tool UI card (…
italic-jinxin Jun 27, 2026
f44efd4
fix(reborn): render dispatch failure kinds as plain language, not cat…
italic-jinxin Jun 27, 2026
7cf7198
fix(reborn): close prune_run race in record_result_with_preview (#5289)
italic-jinxin Jun 27, 2026
7ce1cb8
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 27, 2026
e0d1370
test: update webui v2 capability activity fixtures
italic-jinxin Jun 27, 2026
5a5731a
test: expect human dispatch failure summary
italic-jinxin Jun 27, 2026
3ac067d
test: stabilize reborn composer active-run smoke
italic-jinxin Jun 27, 2026
a2c39e0
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 27, 2026
4a0daff
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 28, 2026
f7e2421
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 28, 2026
d02a2a2
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 28, 2026
864de3d
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 29, 2026
66c3bcd
fix(webui): stop bare error-kind activity frame clobbering failure de…
italic-jinxin Jun 29, 2026
2700246
fix: stream capability failure detail in webui events
italic-jinxin Jun 29, 2026
226aa77
test: update event stream capability activity fixture
italic-jinxin Jun 29, 2026
38ac397
fix: redact filename-bearing failure summaries
italic-jinxin Jun 29, 2026
10208a0
fix: preserve redacted capability failure summaries
italic-jinxin Jun 29, 2026
9a6aede
fix: keep invalid tool failure summaries nonfatal
italic-jinxin Jun 29, 2026
9c6bd68
fix: harden capability failure summary redaction
italic-jinxin Jun 29, 2026
21104ae
fix: require filesystem context for filename summaries
italic-jinxin Jun 29, 2026
cab3eea
fix: tighten workspace failure summary matching
italic-jinxin Jun 29, 2026
ad0969d
fix: preserve invalid input failure summary
italic-jinxin Jun 29, 2026
00772b5
fix: keep input encode summaries replay-safe
italic-jinxin Jun 29, 2026
c81dd2a
fix: preserve safe invalid input summaries
italic-jinxin Jun 29, 2026
ff084e9
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 29, 2026
d06b58b
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 29, 2026
cf10520
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 30, 2026
e0baf96
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 30, 2026
9c4b157
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 30, 2026
656d8ab
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 30, 2026
fc25e2e
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jun 30, 2026
94f4b10
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jul 1, 2026
9cd8e1c
fix: harden capability failure summaries
italic-jinxin Jul 1, 2026
f3dbfa2
fix: redact sensitive capability issue fields
italic-jinxin Jul 1, 2026
644e35c
Merge branch 'main' into issue-5289-surface-capability-failure-detail
italic-jinxin Jul 1, 2026
64af484
fix: document projected capability error details
italic-jinxin Jul 1, 2026
933e168
fix: catch sensitive issue marker variants
italic-jinxin Jul 1, 2026
4915a16
docs: clarify projected error detail boundary
italic-jinxin Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions crates/ironclaw_agent_loop/src/executor/capabilities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::ops::ControlFlow;

use async_trait::async_trait;
use ironclaw_host_api::INPUT_ENCODE_HUMAN_SUMMARY;
use ironclaw_turns::{
LoopFailureKind, LoopResultRef,
run_profile::{
Expand Down Expand Up @@ -38,6 +39,7 @@ use super::{
pub(crate) struct CapabilityStage;

const MAX_SAFE_SUMMARY_BYTES: usize = 512;
const STRATEGY_INPUT_COULD_NOT_BE_ENCODED_SUMMARY: &str = "input could not be encoded";

pub(super) struct CapabilityInput {
pub(super) state: LoopExecutionState,
Expand Down Expand Up @@ -534,6 +536,7 @@ fn prefixed_capability_summary(
prefix: String,
safe_summary: String,
) -> Result<SanitizedStrategySummary, AgentLoopExecutorError> {
let safe_summary = strategy_safe_capability_summary_detail(safe_summary);
let detail = sanitized_strategy_summary(safe_summary)?;
let detail = truncate_summary_detail(
detail.as_str(),
Expand All @@ -542,6 +545,14 @@ fn prefixed_capability_summary(
sanitized_strategy_summary(format!("{prefix}{detail}"))
}

fn strategy_safe_capability_summary_detail(safe_summary: String) -> String {
if safe_summary == INPUT_ENCODE_HUMAN_SUMMARY {
STRATEGY_INPUT_COULD_NOT_BE_ENCODED_SUMMARY.to_string()
} else {
safe_summary
}
}

fn truncate_summary_detail(detail: &str, max_bytes: usize) -> &str {
if detail.len() <= max_bytes {
return detail;
Expand Down Expand Up @@ -1117,6 +1128,9 @@ impl CapabilityStage {
activity_id: denied_activity_id,
capability_id: call.capability_id.clone(),
reason_kind: CapabilityFailureKind::GateDeclined,
// Gate denial carries no host-authored message; the
// model-visible text is produced separately below.
safe_summary: None,
},
)
.await;
Expand Down Expand Up @@ -1455,4 +1469,18 @@ mod tests {
if detail == "host returned unsafe strategy summary"
));
}

#[test]
fn prefixed_capability_summary_rephrases_fixed_input_encode_summary() {
let summary = prefixed_capability_summary(
"capability failed with invalid_input: ".to_string(),
INPUT_ENCODE_HUMAN_SUMMARY.to_string(),
)
.expect("fixed input encode summary should be strategy-safe");

assert_eq!(
summary.as_str(),
"capability failed with invalid_input: input could not be encoded"
);
}
}
4 changes: 4 additions & 0 deletions crates/ironclaw_agent_loop/src/executor/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6120,6 +6120,7 @@ async fn capability_stage_denied_approval_resume_surfaces_gate_declined_failure_
activity_id,
capability_id: emitted_capability_id,
reason_kind: CapabilityFailureKind::GateDeclined,
..
} if *activity_id == denied_activity_id && *emitted_capability_id == capability_id()
)),
"denied approval resume must emit a persistent failed capability activity"
Expand Down Expand Up @@ -6230,6 +6231,7 @@ async fn capability_stage_denied_auth_resume_surfaces_gate_declined_failure_and_
activity_id,
capability_id: emitted_capability_id,
reason_kind: CapabilityFailureKind::GateDeclined,
..
} if *activity_id == denied_activity_id && *emitted_capability_id == capability_id()
)),
"denied auth resume must emit a persistent failed capability activity"
Expand Down Expand Up @@ -6371,6 +6373,7 @@ async fn auth_gate_without_resume_token_records_activity_id_for_denial_failure()
activity_id,
capability_id: emitted_capability_id,
reason_kind: CapabilityFailureKind::GateDeclined,
..
} if *activity_id == blocked_activity_id && *emitted_capability_id == capability_id()
)),
"denied tokenless auth gate must emit CapabilityActivityFailed for the original activity"
Expand Down Expand Up @@ -6707,6 +6710,7 @@ async fn capability_stage_denied_auth_resume_only_fails_matching_activity_when_c
activity_id,
capability_id: emitted_capability_id,
reason_kind: CapabilityFailureKind::GateDeclined,
..
} if *activity_id == denied_activity_id && *emitted_capability_id == capability_id()
)),
"only the parked activity should receive the gate-declined failure"
Expand Down
19 changes: 19 additions & 0 deletions crates/ironclaw_event_projections/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,25 @@ This crate is above `ironclaw_events` and below product adapters. Keep it:
- non-mutating: projection failures must not mutate durable logs or kernel state;
- backend-independent: do not depend on JSONL/PostgreSQL/libSQL adapter crates directly.

The one allowed product-display exception is `CapabilityActivityProjection.error_detail`:
it may carry only the sanitized `RuntimeEvent.error_summary` value after replay
re-runs `ironclaw_events::sanitize_error_summary`. This field is still not a
general backend-detail channel; raw tool input/output, host paths, secrets, and
provider messages that fail the runtime-event sanitizer must remain collapsed to
the fixed safe summaries.

Sanitization ownership for this exception is:

- runtime producers should pass only host-authored summaries into
`RuntimeEvent::with_error_summary`;
- `ironclaw_events` owns durable-log sanitization at construction,
serialization, and deserialization boundaries;
- `ironclaw_event_projections` must re-run the same sanitizer when deriving
`error_detail`, because product projections are a separate user-facing
boundary;
- product workflow and WebUI layers must treat `error_detail` as already
display-bounded and must not recover or append raw backend detail.

Current slices:

- replay-derived `ThreadTimeline` and `RunStatusProjection` over `DurableEventLog`;
Expand Down
8 changes: 8 additions & 0 deletions crates/ironclaw_event_projections/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,14 @@ pub struct CapabilityActivityProjection {
pub process_id: Option<ProcessId>,
pub output_bytes: Option<u64>,
pub error_kind: Option<String>,
/// Sanitized display detail derived from `RuntimeEvent.error_summary`.
///
/// This intentionally uses the product-facing `error_detail` wire name:
/// consumers render it as optional per-tool failure detail, not as the
/// durable event's source summary field. Projection replay re-runs the
/// runtime-event sanitizer before populating this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error_detail: Option<String>,
#[serde(default)]
pub first_cursor: EventCursor,
pub last_cursor: EventCursor,
Expand Down
23 changes: 22 additions & 1 deletion crates/ironclaw_event_projections/src/runtime_projection.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use std::{cmp::Ordering, collections::HashMap};

use ironclaw_events::{EventLogEntry, RuntimeEvent, RuntimeEventKind, sanitize_error_kind};
use ironclaw_events::{
EventLogEntry, RuntimeEvent, RuntimeEventKind, sanitize_error_kind, sanitize_error_summary,
};
use ironclaw_host_api::InvocationId;

use crate::{
Expand Down Expand Up @@ -270,6 +272,7 @@ fn apply_capability_activity_event(
return;
};
let sanitized_error_kind = event.error_kind.clone().map(sanitize_error_kind);
let sanitized_error_summary = projection_error_detail(event);
let activity = activities
.entry(event.scope.invocation_id)
.or_insert_with(|| capability_activity_projection_for_entry(entry, status));
Expand Down Expand Up @@ -299,8 +302,10 @@ fn apply_capability_activity_event(
| CapabilityActivityStatus::Completed
) {
activity.error_kind = None;
activity.error_detail = None;
} else if sanitized_error_kind.is_some() {
activity.error_kind = sanitized_error_kind;
activity.error_detail = sanitized_error_summary;
}
activity.last_cursor = entry.cursor;
activity.updated_at = event.timestamp;
Expand All @@ -322,12 +327,28 @@ fn capability_activity_projection_for_entry(
process_id: event.process_id,
output_bytes: event.output_bytes,
error_kind: event.error_kind.clone().map(sanitize_error_kind),
error_detail: projection_error_detail(event),
first_cursor: entry.cursor,
last_cursor: entry.cursor,
updated_at: event.timestamp,
}
}

fn projection_error_detail(event: &RuntimeEvent) -> Option<String> {
// Product-facing projections are a second channel boundary after the
// durable runtime log. Re-run the sanitizer here so direct in-memory
// construction, legacy replay payloads, or future event producers cannot
// surface backend-authored detail strings unless they satisfy the
// redacted display-summary contract. The projection field is named
// `error_detail` intentionally: product/WebUI consumers render it as
// optional per-tool failure detail, while the durable event keeps the
// source field name `error_summary`.
event
.error_summary
.as_deref()
.and_then(sanitize_error_summary)
}

fn capability_activity_status_for_event(
kind: RuntimeEventKind,
current_status: Option<CapabilityActivityStatus>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1411,6 +1411,42 @@ async fn replay_projection_capability_activity_stays_metadata_only() {
);
}

#[tokio::test]
async fn replay_projection_preserves_safe_capability_error_detail() {
let log = Arc::new(InMemoryDurableEventLog::new());
let service = ReplayEventProjectionService::new(Arc::clone(&log));
let scope = scope_for_thread(ThreadId::new("thread-tool-activity-detail").unwrap());
let detail = "json parsing failed: unexpected comma at line 4";

log.append(
RuntimeEvent::capability_activity_failed(
scope.clone(),
CapabilityId::new("builtin.json").unwrap(),
None,
None,
"invalid_input",
)
.with_error_summary(detail),
)
.await
.unwrap();

let snapshot = service
.snapshot(ProjectionRequest {
scope: ProjectionScope::from_resource_scope(&scope),
after: None,
limit: 16,
})
.await
.unwrap();

assert_eq!(snapshot.capability_activities.len(), 1);
let activity = &snapshot.capability_activities[0];
assert_eq!(activity.status, CapabilityActivityStatus::Failed);
assert_eq!(activity.error_kind.as_deref(), Some("invalid_input"));
assert_eq!(activity.error_detail.as_deref(), Some(detail));
}

#[tokio::test]
async fn replay_projection_keeps_model_completed_running_until_reply_finalized() {
let log = Arc::new(InMemoryDurableEventLog::new());
Expand Down Expand Up @@ -2045,6 +2081,7 @@ async fn replay_projection_re_sanitizes_unsanitized_runtime_events_from_custom_b
process_id: Some(ProcessId::new()),
output_bytes: None,
error_kind: Some(raw.to_string()),
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2758,6 +2795,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)), // 64-char blake3 hex
hook_point: Some("before_capability".to_string()),
hook_trust_class: Some("installed".to_string()),
Expand All @@ -2777,6 +2815,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand All @@ -2796,6 +2835,7 @@ async fn hook_runtime_events_project_with_sanitized_hook_metadata() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("fedcba9876543210".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2874,6 +2914,7 @@ async fn non_hook_runtime_events_project_with_no_hook_metadata() {
process_id: Some(ProcessId::new()),
output_bytes: Some(42),
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -2940,6 +2981,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: None,
hook_point: None,
hook_trust_class: None,
Expand All @@ -2962,6 +3004,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand All @@ -2981,6 +3024,7 @@ async fn hook_runtime_events_do_not_alter_run_status_projection() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: None,
hook_trust_class: None,
Expand Down Expand Up @@ -3043,6 +3087,7 @@ async fn hook_only_runtime_events_default_run_status_to_running() {
process_id: None,
output_bytes: None,
error_kind: None,
error_summary: None,
hook_id: Some("0123456789abcdef".repeat(4)),
hook_point: Some("before_capability".to_string()),
hook_trust_class: Some("installed".to_string()),
Expand Down
5 changes: 5 additions & 0 deletions crates/ironclaw_event_streams/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ pub enum ThreadLiveProjectionItem {
output_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
error_kind: Option<String>,
/// Bounded, sanitized failure summary for a failed activity (e.g. a
/// builtin's `"invalid JSON: ..."` message). Additive; absent for
/// non-failures and pre-existing producers.
#[serde(default, skip_serializing_if = "Option::is_none")]
error_detail: Option<String>,
},
WorkSummary {
id: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ fn capability_activity(scope: &ProjectionScope, cursor: u64) -> CapabilityActivi
process_id: None,
output_bytes: Some(12),
error_kind: None,
error_detail: None,
first_cursor: EventCursor::new(cursor),
last_cursor: EventCursor::new(cursor),
updated_at: chrono::Utc::now(),
Expand Down
11 changes: 7 additions & 4 deletions crates/ironclaw_events/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
//!
//! # Layering
//!
//! - [`RuntimeEvent`] / [`RuntimeEventKind`] are the metadata-only event
//! shapes. Constructors collapse unsafe error detail into `Unclassified`.
//! - [`RuntimeEvent`] / [`RuntimeEventKind`] are redacted event shapes.
//! Constructors collapse unsafe error categories into `Unclassified` and
//! keep only bounded, sanitized display summaries.
//! - [`EventSink`] / [`AuditSink`] are best-effort delivery traits. Failures
//! are recorded but must not alter runtime or control-plane outcomes.
//! - [`DurableEventLog`] / [`DurableAuditLog`] are explicit-error append-log
Expand All @@ -29,7 +30,9 @@
//! private auth tokens, raw request/response payloads, approval reasons,
//! invocation fingerprints, lease IDs, or lease contents. Runtime
//! `error_kind` strings are constrained to short classification tokens; any
//! unsafe value is collapsed to `Unclassified`.
//! unsafe value is collapsed to `Unclassified`. Optional `error_summary`
//! strings are host-authored and bounded; unsafe non-empty summaries are
//! collapsed to a fixed safe display marker.
#![warn(unreachable_pub)]

mod cursor;
Expand All @@ -50,7 +53,7 @@ pub use runtime_event::{
RuntimeEvent, RuntimeEventId, RuntimeEventKind, UNCLASSIFIED_ERROR_KIND,
UNCLASSIFIED_HOOK_LABEL, deserialize_trusted_runtime_event,
runtime_event_from_trusted_json_slice, runtime_event_from_trusted_json_str,
sanitize_error_kind, sanitize_hook_id, sanitize_hook_label,
sanitize_error_kind, sanitize_error_summary, sanitize_hook_id, sanitize_hook_label,
};
pub use security_audit::{
InMemorySecurityAuditSink, NoopSecurityAuditSink, SecurityAuditEvent, SecurityAuditSink,
Expand Down
Loading
Loading