Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion crates/app/ironclaw_composition/src/runtime/tests/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -686,7 +686,7 @@ use ironclaw_loop_host::{
HostManagedModelMessage, HostManagedModelMessageRole, HostManagedModelRequest,
HostManagedModelResponse, HostManagedToolResultContent, HostSkillContextBuildError,
HostSkillContextCandidate, HostSkillContextSource, ModelCost, SpawnSubagentMode,
SubagentKindId, SubagentThreadKind, SubagentThreadMetadata,
SubagentKindId, SubagentThreadKind, SubagentThreadMetadata, ToolDisclosureMode,
};
use ironclaw_product_contracts::inbound_requests::{
ProductCreateThreadRequest, ProductListAutomationsRequest, ProductResolveGateRequest,
Expand Down Expand Up @@ -2967,6 +2967,7 @@ async fn build_reborn_runtime_wires_trajectory_observer_through_unified_runtime(
)),
))),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-observer-reject-tenant".to_string(),
agent_id: "runtime-observer-reject-agent".to_string(),
Expand Down Expand Up @@ -3723,6 +3724,7 @@ async fn hosted_mcp_activation_stays_pending_until_preparation_completes() {
)
.with_local_runtime_confirmed_host_home_root(host_home),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-auth-gate-tenant".to_string(),
agent_id: "runtime-auth-gate-agent".to_string(),
Expand Down Expand Up @@ -4001,6 +4003,7 @@ async fn standalone_runtime_exposes_host_runtime_capabilities_to_model_calls() {
)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-tools-tenant".to_string(),
agent_id: "runtime-tools-agent".to_string(),
Expand Down Expand Up @@ -4145,6 +4148,7 @@ async fn standalone_runtime_forwards_tool_call_trajectory_to_raw_observer() {
)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-trajectory-tenant".to_string(),
agent_id: "runtime-trajectory-agent".to_string(),
Expand Down Expand Up @@ -4221,6 +4225,7 @@ async fn standalone_runtime_safe_preview_observer_receives_bounded_payload() {
)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-preview-tenant".to_string(),
agent_id: "runtime-preview-agent".to_string(),
Expand Down Expand Up @@ -5079,6 +5084,7 @@ async fn standalone_runtime_maps_workspace_to_configured_root() {
.with_local_runtime_workspace_root(workspace_root.path().to_path_buf())
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-workspace-tenant".to_string(),
agent_id: "runtime-workspace-agent".to_string(),
Expand Down Expand Up @@ -6603,6 +6609,7 @@ async fn multi_tool_call_response_survives_surface_change_mid_register() {
)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-multi-tool-surface-tenant".to_string(),
agent_id: "runtime-multi-tool-surface-agent".to_string(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use ironclaw_loop_contracts::{
use ironclaw_loop_host::{
HostManagedModelError, HostManagedModelErrorKind, HostManagedModelGateway,
HostManagedModelMessageRole, HostManagedModelRequest, HostManagedModelResponse,
ToolDisclosureMode,
};
use ironclaw_outbound::{
DeliveryTargetCapabilities, OutboundDeliveryTargetId,
Expand Down Expand Up @@ -267,6 +268,7 @@ async fn production_reply_attachment_capability_registers_durable_run_intent() {
"runtime-reply-attachment-owner",
root.path().join("standalone"),
))
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-reply-attachment-tenant".to_string(),
agent_id: "runtime-reply-attachment-agent".to_string(),
Expand Down Expand Up @@ -343,6 +345,7 @@ async fn standalone_runtime_selects_outbound_delivery_target_before_trigger_crea
)
.with_local_runtime_confirmed_host_home_root(host_home),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-outbound-trigger-tenant".to_string(),
agent_id: "runtime-outbound-trigger-agent".to_string(),
Expand Down
4 changes: 3 additions & 1 deletion crates/app/ironclaw_composition/tests/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ use ironclaw_loop_contracts::{
};
use ironclaw_loop_host::{
HostManagedModelError, HostManagedModelErrorKind, HostManagedModelGateway,
HostManagedModelRequest, HostManagedModelResponse,
HostManagedModelRequest, HostManagedModelResponse, ToolDisclosureMode,
};
use ironclaw_turns::{
CancelRunRequest, CancelRunResponse, GetRunStateRequest, IdempotencyKey, ResumeTurnRequest,
Expand Down Expand Up @@ -551,6 +551,7 @@ async fn build_reborn_runtime_wires_third_party_hooks_when_enabled() {
ironclaw_composition::local_filesystem_build_input("runtime-hooks-owner", storage_root)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: "runtime-hooks-tenant".to_string(),
agent_id: "runtime-hooks-agent".to_string(),
Expand Down Expand Up @@ -1001,6 +1002,7 @@ async fn standalone_test_support_interaction_services_use_supplied_turn_coordina
)
.with_runtime_policy(standalone_runtime_policy()),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: format!("{tag}-tenant"),
agent_id: format!("{tag}-agent"),
Expand Down
5 changes: 4 additions & 1 deletion crates/app/ironclaw_composition/tests/webui_v2_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ use ironclaw_loop_contracts::{
use ironclaw_loop_host::{
HostManagedModelError, HostManagedModelErrorKind, HostManagedModelGateway,
HostManagedModelMessageRole, HostManagedModelRequest, HostManagedModelResponse,
HostManagedModelStreamSink,
HostManagedModelStreamSink, ToolDisclosureMode,
};
use ironclaw_webui::{WebuiAuthentication, WebuiAuthenticator, WebuiServeConfig, webui_v2_app};
use serde_json::{Value, json};
Expand Down Expand Up @@ -706,6 +706,7 @@ async fn build_harness_at_with_runtime_owner_auth_user_and_google_oauth_backend(
.with_vendor_oauth_client(ironclaw_auth::GOOGLE_PROVIDER_ID, google_oauth_backend);
}
let input = RebornRuntimeInput::from_build_input(build_input)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: TENANT.to_string(),
agent_id: AGENT.to_string(),
Expand Down Expand Up @@ -792,6 +793,7 @@ async fn build_two_user_harness_with_workspace_scoping(
.with_workspace_scoped_per_caller(workspace_scoped_per_caller)
.with_bundled_first_party_for_test(),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: TENANT.to_string(),
agent_id: AGENT.to_string(),
Expand Down Expand Up @@ -2006,6 +2008,7 @@ mod operator_llm_config {
.with_runtime_policy(local_host_effective_policy())
.with_bundled_first_party_for_test(),
)
.with_tool_disclosure(ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: TENANT.to_string(),
agent_id: AGENT.to_string(),
Expand Down
33 changes: 16 additions & 17 deletions crates/loop/ironclaw_loop_host/src/tool_disclosure_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
//! in a different crate from the thing it switches was the split this shed
//! closes.

/// Environment variable that opts a deployment into progressive disclosure.
/// Environment variable that configures progressive disclosure.
pub const REBORN_TOOL_DISCLOSURE_ENV: &str = "REBORN_TOOL_DISCLOSURE";

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ToolDisclosureMode {
#[default]
Off,
#[default]
Bridged,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Expand All @@ -37,10 +37,9 @@ impl ToolDisclosureMode {
}
}

/// Progressive tool disclosure defaults **off** — an unset, empty, or
/// unrecognized `REBORN_TOOL_DISCLOSURE` leaves the request path
/// byte-identical to the pre-disclosure behavior. Only an explicit
/// `REBORN_TOOL_DISCLOSURE=bridged` opts into the bridged path.
/// Progressive tool disclosure defaults to bridged for unset or empty
/// configuration. Explicit `off` remains the rollback path, while
/// unrecognized values fail closed to `Off`.
fn from_raw(raw: Option<&str>) -> Self {
match raw {
Some(value) if value.eq_ignore_ascii_case("off") => Self::Off,
Expand All @@ -49,12 +48,12 @@ impl ToolDisclosureMode {
tracing::debug!(
target: "ironclaw::reborn::runtime",
env = REBORN_TOOL_DISCLOSURE_ENV,
"unrecognized REBORN_TOOL_DISCLOSURE value; falling back to default Off"
"unrecognized REBORN_TOOL_DISCLOSURE value; falling back to Off"
);
Self::Off
}
// unset / empty -> default off (byte-identical request path).
_ => Self::Off,
// Unset / empty follows the production default.
_ => Self::default(),
}
}

Expand All @@ -68,19 +67,19 @@ mod tests {
use super::ToolDisclosureMode;

#[test]
fn tool_disclosure_mode_defaults_off_with_bridged_opt_in() {
assert_eq!(ToolDisclosureMode::default(), ToolDisclosureMode::Off);
// Default off: unset / empty / unrecognized resolve to Off so the
// request path stays byte-identical. Only explicit `bridged` opts in.
fn tool_disclosure_mode_defaults_bridged_with_off_kill_switch() {
assert_eq!(ToolDisclosureMode::default(), ToolDisclosureMode::Bridged);
// Unset / empty use the production default. Invalid configuration and
// explicit `off` fail closed to the rollback path.
// `is_bridged()` is what gates whether the gateway attaches the decorator.
assert!(
!ToolDisclosureMode::from_raw(None).is_bridged(),
"unset must default OFF (byte-identical request path)"
ToolDisclosureMode::from_raw(None).is_bridged(),
"unset must enable progressive disclosure"
);
assert!(!ToolDisclosureMode::from_raw(Some("")).is_bridged());
assert!(ToolDisclosureMode::from_raw(Some("")).is_bridged());
assert!(
!ToolDisclosureMode::from_raw(Some("garbage")).is_bridged(),
"unrecognized values must fall back to the default Off"
"unrecognized values must fail closed to Off"
);
assert!(ToolDisclosureMode::from_raw(Some("bridged")).is_bridged());
assert!(ToolDisclosureMode::from_raw(Some("BRIDGED")).is_bridged());
Expand Down
2 changes: 1 addition & 1 deletion tests/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ One thread, whole real turn. Grouped by what the user experiences.
| Web search/fetch runs the real Exa MCP handshake | `web_access.rs` |
| Outbound HTTP crosses the real security pipeline (network policy + leak scan) | `real_egress_pipeline.rs` |
| Tools marked host-internal are never advertised to the model, and calls to them are rejected | `extension_visibility.rs`, `surface_disclosure.rs` |
| Bridged tool disclosure mode reaches production's decorator wiring | `tool_disclosure.rs` |
| With a large tool catalog, bridged mode and the production default expose `tool_search`, `tool_describe`, and `tool_call` instead of flat tools | `tool_disclosure.rs` |
| Deferred tools can be found from argument-only vocabulary without adding that schema vocabulary to the model prompt | `tool_disclosure.rs::tool_search_discovers_authorized_tools_by_parameter_only_vocabulary` |
| Bridged disclosure never reintroduces host-runtime capability metadata excluded by any resolved host-API surface-policy dimension (ID, runtime, effect, approval, or maximum count) | `tool_disclosure.rs` |
| A capability whose lease expires mid-dispatch does not wedge the run | `lease_wedge.rs` |
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/reborn_webui_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,10 @@ async def start_reborn_webui_v2_server(
"IRONCLAW_REBORN_PROFILE": profile,
"IRONCLAW_REBORN_WEBUI_TOKEN": REBORN_V2_AUTH_TOKEN,
"IRONCLAW_REBORN_WEBUI_USER_ID": USER_ID,
# Recorded provider fixtures assert the pre-disclosure request
# shape. Keep this shared deterministic harness explicit rather
# than inheriting the production default.
"REBORN_TOOL_DISCLOSURE": "off",
"MOCK_LLM_API_KEY": "mock-api-key",
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
Expand Down
3 changes: 3 additions & 0 deletions tests/e2e/scenarios/test_reborn_responses_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ async def reborn_responses_server(
"IRONCLAW_REBORN_PROFILE": PROFILE,
"IRONCLAW_REBORN_WEBUI_TOKEN": REBORN_V2_AUTH_TOKEN,
"IRONCLAW_REBORN_WEBUI_USER_ID": USER_ID,
# External-tool response fixtures assert the pre-disclosure tool
# surface; keep this deterministic server explicit.
"REBORN_TOOL_DISCLOSURE": "off",
"MOCK_LLM_API_KEY": "mock-api-key",
"NO_PROXY": "127.0.0.1,localhost,::1",
"no_proxy": "127.0.0.1,localhost,::1",
Expand Down
7 changes: 7 additions & 0 deletions tests/integration/support/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,13 @@ impl RebornIntegrationHarnessBuilder {
self
}

/// Exercise the production enum default without making the general
/// integration harness depend on ambient process configuration.
pub fn with_tool_disclosure_production_default(mut self) -> Self {
self.tool_disclosure = ToolDisclosureMode::default();
self
}

/// Force `ToolDisclosureMode::Off` for this harness's underlying group,
/// bypassing `REBORN_TOOL_DISCLOSURE`/`from_env()`. Use this to pin a
/// negative-control test's mode explicitly rather than relying on the
Expand Down
37 changes: 33 additions & 4 deletions tests/integration/tool_disclosure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,10 @@ async fn bridged_disclosure_never_advertises_globally_disabled_spawn_subagent()
.expect("the run continues after the recoverable unknown-tool results");
}

/// Negative control: the SAME wide catalog without
/// `.with_tool_disclosure_bridged()` surfaces the flat 48-tool list (today's
/// default, `ToolDisclosureMode::Off`) — proves the bridged assertion above
/// discriminates on the disclosure mode, not on the backend.
/// Negative control: the SAME wide catalog under explicit
/// `ToolDisclosureMode::Off` surfaces the flat 48-tool list — proves the
/// bridged assertion above discriminates on the disclosure mode, not on the
/// backend.
///
/// Pins Off-mode explicitly via `.with_tool_disclosure_off()` rather than
/// leaving this on the `from_env()` default-resolution path: without an
Expand Down Expand Up @@ -279,6 +279,35 @@ async fn explicit_off_surfaces_the_flat_wide_tool_list() {
}
}

/// The production default enables progressive disclosure for a wide catalog.
/// The `ironclaw_loop_host` unit contract separately proves that an unset or
/// empty environment value resolves to this default.
#[tokio::test]
async fn production_default_defers_wide_catalog_to_bridge_meta_tools() {
let harness = RebornIntegrationHarness::test_default()
.with_tool_disclosure_production_default()
.with_github_issue_tools()
.script([RebornScriptedReply::text("done")])
.build()
.await
.expect("production-default disclosure harness builds");

harness.submit_turn("hello").await.expect("turn completes");

for bridge in [TOOL_SEARCH_NAME, TOOL_DESCRIBE_NAME, TOOL_CALL_NAME] {
harness
.assert_model_tools_contains(bridge)
.await
.unwrap_or_else(|error| {
panic!("production default must advertise bridge {bridge:?}: {error}")
});
}
harness
.assert_model_tools_excludes(FLAT_GITHUB_TOOL_NAME)
.await
.expect("production default defers the flat wide catalog");
}

/// General harnesses pin Off rather than inheriting the production environment,
/// so unrelated integration tests remain stable when the production default can
/// safely change after the authorization prerequisite lands.
Expand Down
1 change: 1 addition & 0 deletions tests/reborn_qa_routines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ async fn build_qa_fire_runtime(
.expect("local-yolo runtime input")
.with_local_runtime_confirmed_host_home_root(host_home_root);
let input = RebornRuntimeInput::from_build_input(input)
.with_tool_disclosure(ironclaw_loop_host::ToolDisclosureMode::Off)
.with_identity(RebornRuntimeIdentity {
tenant_id: QA_TENANT.to_string(),
agent_id: QA_AGENT.to_string(),
Expand Down
5 changes: 4 additions & 1 deletion tests/support/reborn_parity_qa/binary_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ use ironclaw_loop_contracts::{
};
use ironclaw_loop_host::{
EmptyUserProfileSource, HostIdentityContextSource, HostManagedModelRequest,
JsonSpawnSubagentInputCodec, RejectingInputEnqueue,
JsonSpawnSubagentInputCodec, RejectingInputEnqueue, ToolDisclosureMode,
};
use ironclaw_network::NetworkHttpRequest;
use ironclaw_product_contracts::binding::ProductBindingResolver;
Expand Down Expand Up @@ -845,6 +845,9 @@ impl RebornBinaryE2EHarness {
// minutes of backoff. Mirrors the integration group harness's
// IRONCLAW_REBORN_MODEL_AVAILABILITY_RETRY_ATTEMPTS=1 pin.
planned_model_availability_retry_attempts: std::num::NonZeroU32::new(1),
// Scripted replay steps assert exact flat tool surfaces. Keep that
// test contract explicit instead of inheriting production's mode.
tool_disclosure: ToolDisclosureMode::Off,
..DefaultPlannedRuntimeConfig::default()
};
if exposes_spawn_subagent {
Expand Down
Loading