Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
5dfd0cd
docs(reborn): add Hermes-style context-management design (tool disclo…
serrrfirat Jun 22, 2026
8f56d75
feat(reborn): Phase 0 shadow measurement for context management
serrrfirat Jun 22, 2026
db9d099
feat(reborn): Phase 1.1 tool-disclosure catalog + selector + token be…
serrrfirat Jun 22, 2026
dda1e59
feat(reborn): Phase 1.2 wire tool disclosure via capability-port deco…
serrrfirat Jun 23, 2026
3c3cc9d
docs(reborn): record implementation status + codebase grounding for c…
serrrfirat Jun 23, 2026
2dc0127
test(reborn): negative-path + flag-off coverage for tool-disclosure b…
serrrfirat Jun 23, 2026
336aa16
docs(reborn): mark negative-path bridge tests done; only canary remains
serrrfirat Jun 23, 2026
7aff964
chore(reborn)!: TEMPORARY default tool disclosure ON for remote bench…
serrrfirat Jun 23, 2026
5d8f068
fix(reborn): address Gemini + CodeRabbit review feedback on PR #5149
serrrfirat Jun 23, 2026
41bb5ea
fix(reborn): CI fix (pin QA replays to disclosure-off) + 2 newer Code…
serrrfirat Jun 23, 2026
aa12134
fix(reborn): correct+widen disclosure core (B) and forgive deferred t…
serrrfirat Jun 23, 2026
ecaeb06
feat(reborn): log full-vs-disclosed tool-surface savings per turn (sh…
serrrfirat Jun 23, 2026
58a5140
feat(reborn): Hermes-shaped disclosure core + count-aware tool_search
serrrfirat Jun 23, 2026
4aebf24
fix(reborn): forgiving bridge actually dispatches direct deferred too…
serrrfirat Jun 23, 2026
12a1861
test(reborn): prove forgiving direct-deferred dispatch is provider-ag…
serrrfirat Jun 23, 2026
e719896
feat(reborn): teach the model the tool-disclosure protocol in the sys…
serrrfirat Jun 23, 2026
9908836
fix(reborn): address CodeRabbit review — preserve authority surface +…
serrrfirat Jun 23, 2026
2e1e1ac
debug(reborn): temporary diagnostics for direct-deferred resolution miss
serrrfirat Jun 23, 2026
04e8bc7
fix(reborn): make the full extension lifecycle core so onboarding works
serrrfirat Jun 23, 2026
ca69d08
fix(reborn): resolve deferred tools by any name form + revert step-1 …
serrrfirat Jun 24, 2026
44c6183
debug(reborn): startup marker for the tool-disclosure build
serrrfirat Jun 24, 2026
ba77135
style(reborn): rustfmt wrap in matcher test
serrrfirat Jun 24, 2026
2cf4f00
debug(reborn): bump disclosure marker to v2 + matcher self-test
serrrfirat Jun 24, 2026
f52546c
debug(reborn): unconditional entry probe on context_shadow target
serrrfirat Jun 24, 2026
aaadd04
fix(reborn): surface filters must delegate provider-tool-call resolut…
serrrfirat Jun 24, 2026
116f226
debug(reborn): marker v3 echoes loop_support filter build constant
serrrfirat Jun 24, 2026
f9a436a
fix(reborn): SurfaceTrackingLoopCapabilityPort must delegate tool-cal…
serrrfirat Jun 24, 2026
0c34188
fix(reborn): record canonical wire name for forgiving direct-deferred…
serrrfirat Jun 24, 2026
d167e71
fix(reborn): make tool_call bridge resolve undisclosed catalog tools …
serrrfirat Jun 24, 2026
c701c33
fix(reborn): collapse repeated identical error observations in replay…
serrrfirat Jun 24, 2026
83ae258
fix(reborn): log provider tool-call validate/register rejections in t…
serrrfirat Jun 24, 2026
519dbc7
fix(reborn): make forgiving tool_call failures recoverable, not run-b…
serrrfirat Jun 25, 2026
48fd811
Merge origin/main into firat/reborn-context-management
serrrfirat Jun 25, 2026
7086bf6
fix(reborn): log the rejecting layer's reason on gateway tool-call re…
serrrfirat Jun 25, 2026
7ae4fef
fix(reborn): promote gate-suspended tools so they survive a BlockedAu…
serrrfirat Jun 25, 2026
5b4e9be
fix(reborn): describe-first schema-on-blind-call for deferred tools
serrrfirat Jun 25, 2026
fca0548
Merge origin/main into firat/reborn-context-management
pranavraja99 Jun 29, 2026
3e97236
Merge branch 'main' into firat/reborn-context-management
pranavraja99 Jun 30, 2026
81c79b9
Merge main; address thermo-nuclear review + fix CI
pranavraja99 Jul 1, 2026
1359687
fix(reborn): add callable_capability_ids to test harness surface + al…
pranavraja99 Jul 1, 2026
71e5923
feat(reborn): gate repeated-failure collapse behind REBORN_COLLAPSE_R…
pranavraja99 Jul 1, 2026
1f7d99b
Merge branch 'main' into firat/reborn-context-management
pranavraja99 Jul 1, 2026
fa1fac2
refactor(reborn): inherent definition_by_capability_id on CapabilityC…
pranavraja99 Jul 1, 2026
f501039
test(canary): forward REBORN_TOOL_DISCLOSURE/COLLAPSE flags to gatewa…
pranavraja99 Jul 1, 2026
cff1055
fix(reborn): authorize disclosed-but-unadvertised callable tools in t…
pranavraja99 Jul 1, 2026
0e2af9d
fix(reborn): refresh disclosure catalog when tools activate mid-turn …
pranavraja99 Jul 1, 2026
622fbc1
fix(reborn): describe-first on input-schema mismatch, not just resolu…
pranavraja99 Jul 1, 2026
75d77f6
fix(reborn): keep trigger_list + trigger_create in the disclosure cor…
pranavraja99 Jul 1, 2026
78de2bb
feat(reborn): capability_info promotes the inspected tool (search -> …
pranavraja99 Jul 1, 2026
69c231b
feat(reborn): stop advertising tool_describe/tool_call — discovery is…
pranavraja99 Jul 2, 2026
4b36574
Merge branch 'main' into firat/reborn-context-management
pranavraja99 Jul 2, 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
130 changes: 128 additions & 2 deletions crates/ironclaw_llm/src/nearai_chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,14 +581,16 @@ impl LlmProvider for NearAiChatProvider {
};

let (input_tokens, output_tokens) = parse_usage(response.usage.as_ref());
let cached_tokens = parse_cached_tokens(response.usage.as_ref());
emit_context_shadow_usage(input_tokens, output_tokens, cached_tokens);

Ok(CompletionResponse {
content,
finish_reason,
input_tokens,
output_tokens,
reasoning: provider_reasoning,
cache_read_input_tokens: 0,
cache_read_input_tokens: cached_tokens.unwrap_or(0).min(input_tokens),
cache_creation_input_tokens: 0,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
}
Expand Down Expand Up @@ -692,14 +694,16 @@ impl LlmProvider for NearAiChatProvider {
};

let (input_tokens, output_tokens) = parse_usage(response.usage.as_ref());
let cached_tokens = parse_cached_tokens(response.usage.as_ref());
emit_context_shadow_usage(input_tokens, output_tokens, cached_tokens);

Ok(ToolCompletionResponse {
content,
tool_calls,
finish_reason,
input_tokens,
output_tokens,
cache_read_input_tokens: 0,
cache_read_input_tokens: cached_tokens.unwrap_or(0).min(input_tokens),
cache_creation_input_tokens: 0,
reasoning: provider_reasoning,
})
Expand Down Expand Up @@ -1139,6 +1143,16 @@ struct ChatCompletionUsage {
completion_tokens: Option<u64>,
#[serde(default)]
total_tokens: Option<u64>,
#[serde(default)]
prompt_tokens_details: Option<PromptTokensDetails>,
#[serde(default)]
cached_tokens: Option<u64>,
}

#[derive(Debug, Deserialize, Default)]
struct PromptTokensDetails {
#[serde(default)]
cached_tokens: Option<u64>,
}

fn saturate_u32(val: u64) -> u32 {
Expand All @@ -1161,6 +1175,33 @@ fn emit_reasoning_trace(reasoning: Option<&str>) {
}
}

fn emit_context_shadow_usage(
prompt_tokens: u32,
completion_tokens: u32,
cached_tokens: Option<u32>,
) {
const CONTEXT_SHADOW_TARGET: &str = "ironclaw::reborn::context_shadow";
let cached_tokens_field = cached_tokens.map(i64::from).unwrap_or(-1);
if let Some(cached_tokens) = cached_tokens.filter(|_| prompt_tokens > 0) {
tracing::debug!(
target: CONTEXT_SHADOW_TARGET,
prompt_tokens,
completion_tokens,
cached_tokens = cached_tokens_field,
cache_hit_ratio = cached_tokens as f64 / prompt_tokens as f64,
"nearai chat usage shadow measurement"
);
} else {
tracing::debug!(
target: CONTEXT_SHADOW_TARGET,
prompt_tokens,
completion_tokens,
cached_tokens = cached_tokens_field,
"nearai chat usage shadow measurement"
);
}
}

fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
let Some(u) = usage else {
return (0, 0);
Expand All @@ -1177,6 +1218,16 @@ fn parse_usage(usage: Option<&ChatCompletionUsage>) -> (u32, u32) {
(input, output)
}

fn parse_cached_tokens(usage: Option<&ChatCompletionUsage>) -> Option<u32> {
let usage = usage?;
usage
.prompt_tokens_details
.as_ref()
.and_then(|details| details.cached_tokens)
.or(usage.cached_tokens)
.map(saturate_u32)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -2740,6 +2791,8 @@ mod tests {
prompt_tokens: Some(100),
completion_tokens: Some(50),
total_tokens: Some(150),
prompt_tokens_details: None,
cached_tokens: None,
};
assert_eq!(parse_usage(Some(&usage)), (100, 50));
}
Expand All @@ -2755,6 +2808,8 @@ mod tests {
prompt_tokens: Some(100),
completion_tokens: None,
total_tokens: Some(180),
prompt_tokens_details: None,
cached_tokens: None,
};
// output = total - prompt = 80
assert_eq!(parse_usage(Some(&usage)), (100, 80));
Expand All @@ -2766,6 +2821,8 @@ mod tests {
prompt_tokens: None,
completion_tokens: None,
total_tokens: Some(200),
prompt_tokens_details: None,
cached_tokens: None,
};
// input = 0 (no prompt), output = total = 200
assert_eq!(parse_usage(Some(&usage)), (0, 200));
Expand All @@ -2777,6 +2834,8 @@ mod tests {
prompt_tokens: None,
completion_tokens: None,
total_tokens: None,
prompt_tokens_details: None,
cached_tokens: None,
};
assert_eq!(parse_usage(Some(&usage)), (0, 0));
}
Expand Down Expand Up @@ -2931,13 +2990,80 @@ mod tests {
assert_eq!(usage.total_tokens, Some(500));
}

#[test]
fn test_usage_deserialize_nested_cached_tokens() {
let json = r#"{
"prompt_tokens": 100,
"completion_tokens": 25,
"total_tokens": 125,
"prompt_tokens_details": {
"cached_tokens": 80
}
}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(parse_usage(Some(&usage)), (100, 25));
assert_eq!(parse_cached_tokens(Some(&usage)), Some(80));
}

#[test]
fn test_usage_deserialize_top_level_cached_tokens() {
let json = r#"{
"prompt_tokens": 100,
"completion_tokens": 25,
"total_tokens": 125,
"cached_tokens": 40
}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(parse_cached_tokens(Some(&usage)), Some(40));
}

#[test]
fn test_usage_deserialize_prefers_nested_cached_tokens() {
let json = r#"{
"prompt_tokens": 100,
"completion_tokens": 25,
"total_tokens": 125,
"prompt_tokens_details": {
"cached_tokens": 80
},
"cached_tokens": 40
}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(parse_cached_tokens(Some(&usage)), Some(80));
}

#[test]
fn test_usage_deserialize_cached_tokens_absent() {
let json = r#"{
"prompt_tokens": 100,
"completion_tokens": 25,
"total_tokens": 125
}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(parse_cached_tokens(Some(&usage)), None);
}

#[test]
fn test_usage_without_details_still_parses_token_counts() {
let json = r#"{
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}"#;
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert_eq!(parse_usage(Some(&usage)), (10, 5));
assert_eq!(parse_cached_tokens(Some(&usage)), None);
}

#[test]
fn test_usage_deserialize_empty_object() {
let json = "{}";
let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap();
assert!(usage.prompt_tokens.is_none());
assert!(usage.completion_tokens.is_none());
assert!(usage.total_tokens.is_none());
assert!(usage.prompt_tokens_details.is_none());
assert!(usage.cached_tokens.is_none());
}

// -- ChatCompletionToolCall serde roundtrip --------------------------------
Expand Down
99 changes: 99 additions & 0 deletions crates/ironclaw_loop_support/src/capability_surface_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,25 @@ impl LoopCapabilityPort for CapabilitySurfaceVisibleFilter {
Ok(definitions)
}

fn provider_tool_call_capability_ids(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Root-cause / code-judo (blocker #2). This same "delegate to inner, then apply my scope check" body is added ~verbatim to Visible/Deny/Profile filters (and a delegate-only variant in loop_driver_host.rs). They only exist because the LoopCapabilityPort default provider_tool_call_capability_ids resolves against self.tool_definitions() — so every filtering decorator silently rejects deferred tools until you remember to override it. Fixing the default to delegate down the chain by default deletes all four overrides and much of the need to smuggle callable_capability_ids through the shared surface. At minimum, extract the repeated skeleton to one delegate_and_scope(inner, call, predicate, msg) helper.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Took the "at minimum" path in 81c79b9df: extracted one delegate_and_scope_tool_call_capability_ids(inner, call, permits, msg) helper shared by the visible/deny/profile filters (the profile All-guard folds into its predicate). I did not change the trait default itself — it has no inner to delegate to (the base HostRuntimeLoopCapabilityPort is not a decorator), so the delegation has to live in the decorators. The SurfaceTracking passthrough stays a one-line delegate.

&self,
tool_call: &ProviderToolCall,
) -> Result<ProviderToolCallCapabilityIds, AgentLoopHostError> {
// Delegate resolution to the inner port (e.g. the tool-disclosure
// forgiving path) rather than the LoopCapabilityPort default, which only
// searches THIS filter's already-filtered `tool_definitions` and so
// rejects every deferred/disclosed tool before the inner port can resolve
// it. Apply the same visible-scope check as `validate`/`register` so a
// genuinely non-visible call is still rejected.
let capability_ids = self.inner.provider_tool_call_capability_ids(tool_call)?;
validate_provider_tool_call_capability_scope(
capability_ids.clone(),
|capability_id| self.permits(capability_id),
"provider tool call is outside the model-visible capability view",
)?;
Ok(capability_ids)
}

fn validate_provider_tool_call(
&self,
tool_call: &ProviderToolCall,
Expand Down Expand Up @@ -161,6 +180,24 @@ impl LoopCapabilityPort for CapabilitySurfaceProfileFilter {
Ok(definitions)
}

fn provider_tool_call_capability_ids(
&self,
tool_call: &ProviderToolCall,
) -> Result<ProviderToolCallCapabilityIds, AgentLoopHostError> {
// Delegate to the inner port instead of the LoopCapabilityPort default
// (which only searches this filter's filtered `tool_definitions`), then
// apply the run-profile scope — mirroring `validate`/`register`.
let capability_ids = self.inner.provider_tool_call_capability_ids(tool_call)?;
if !matches!(self.allow_set.as_ref(), CapabilityAllowSet::All) {
validate_provider_tool_call_capability_scope(
capability_ids.clone(),
|capability_id| self.allow_set.permits(capability_id),
"provider tool call is outside the run-profile surface",
)?;
}
Ok(capability_ids)
}

fn validate_provider_tool_call(
&self,
tool_call: &ProviderToolCall,
Expand Down Expand Up @@ -1384,4 +1421,66 @@ mod tests {

assert_eq!(surface.version.as_str(), "surface-v1");
}

#[test]
fn visible_filter_delegates_provider_tool_call_resolution_to_inner() {
// Regression: the model gateway pre-check calls
// `provider_tool_call_capability_ids` on whatever capability port it holds
// — here a CapabilitySurfaceVisibleFilter. If the filter fell back to the
// LoopCapabilityPort default (which only searches its OWN already-filtered
// `tool_definitions`), every deferred/disclosed tool would be rejected
// before the inner forgiving port could resolve it — which is exactly the
// "outside the visible capability surface" failure that killed every
// deferred extension-tool call. The filter must delegate to inner, then
// apply the visible-scope check.
let inner = Arc::new(SpyPort::default());
// Only the advertised tool is in tool_definitions (the filtered surface)...
*inner
.tool_definitions
.lock()
.expect("tool definitions lock") =
vec![provider_definition("demo.advertised", "demo__advertised")];
// ...but the inner port can still RESOLVE a deferred tool (mimicking the
// tool-disclosure forgiving path) that is NOT in tool_definitions.
inner
.provider_call_capability_ids
.lock()
.expect("provider call capability ids lock")
.insert(
"demo__deferred".to_string(),
provider_call_capability_ids(&["demo.deferred"]),
);
// The deferred tool's capability id IS in the model-visible view (it was
// disclosed via tool_search), so the call must resolve.
let filter = CapabilitySurfaceVisibleFilter::new(
Arc::clone(&inner) as Arc<dyn LoopCapabilityPort>,
[
capability_id("demo.advertised"),
capability_id("demo.deferred"),
],
);

let resolved = filter
.provider_tool_call_capability_ids(&provider_call("demo__deferred"))
.expect("deferred-but-visible tool resolves through the inner forgiving path");
assert_eq!(
resolved.provider_capability_id,
capability_id("demo.deferred")
);

// A resolvable tool whose capability id is NOT in the visible view is still
// rejected by the scope check.
inner
.provider_call_capability_ids
.lock()
.expect("provider call capability ids lock")
.insert(
"demo__hidden".to_string(),
provider_call_capability_ids(&["demo.hidden"]),
);
let error = filter
.provider_tool_call_capability_ids(&provider_call("demo__hidden"))
.expect_err("non-visible tool is rejected");
assert_eq!(error.kind, AgentLoopHostErrorKind::InvalidInvocation);
}
}
8 changes: 8 additions & 0 deletions crates/ironclaw_loop_support/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
//! not own provider clients, tool dispatchers, secrets, or runtime handles.
#![warn(unreachable_pub)]

/// Build tag for the capability-surface-filter provider-tool-call resolution path.
/// Bumped when that resolution logic changes so a startup marker can prove THIS
/// crate (not just a stale incremental codegen unit) is actually linked in.
/// `delegate-to-inner` => the filters delegate `provider_tool_call_capability_ids`
/// to the inner port (reaching the tool-disclosure forgiving path) instead of the
/// LoopCapabilityPort default.
pub const CAPABILITY_FILTER_RESOLUTION_BUILD: &str = "delegate-to-inner";

use std::{
collections::{HashMap, HashSet},
sync::{
Expand Down
24 changes: 24 additions & 0 deletions crates/ironclaw_reborn/src/context_shadow.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/// Deterministic shadow token estimator for prompt-surface diagnostics.
#[allow(clippy::manual_div_ceil)]
pub(crate) fn estimate_tokens(s: &str) -> u32 {
let char_count = s.chars().count();
((char_count + 3) / 4).min(u32::MAX as usize) as u32
}

#[cfg(test)]
mod tests {
use super::estimate_tokens;

#[test]
fn estimate_tokens_uses_ceiling_char_count_over_four() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("a"), 1);
assert_eq!(estimate_tokens("abcd"), 1);
assert_eq!(estimate_tokens("abcde"), 2);
}

#[test]
fn estimate_tokens_counts_chars_not_bytes() {
assert_eq!(estimate_tokens("ééééé"), 2);
}
}
3 changes: 3 additions & 0 deletions crates/ironclaw_reborn/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
//! the boundary tests are designed to prevent.

pub mod app_loop_family;
mod context_shadow;
pub mod driver_registry;
pub mod failure_categories;
pub mod hook_gate_refs;
Expand All @@ -34,6 +35,8 @@ pub mod runtime;
pub mod subagent;
pub mod text_loop_driver;
pub mod thread_scope;
mod tool_disclosure;
mod tool_disclosure_port;
pub mod turn_run_executor;
pub mod turn_runner;

Expand Down
Loading
Loading