diff --git a/crates/app/ironclaw_composition/src/runtime/tests/core.rs b/crates/app/ironclaw_composition/src/runtime/tests/core.rs index 161b1d39d39..c25f20067b1 100644 --- a/crates/app/ironclaw_composition/src/runtime/tests/core.rs +++ b/crates/app/ironclaw_composition/src/runtime/tests/core.rs @@ -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, @@ -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(), @@ -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(), @@ -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(), @@ -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(), @@ -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(), @@ -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(), @@ -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(), diff --git a/crates/app/ironclaw_composition/src/runtime/tests/outbound_delivery.rs b/crates/app/ironclaw_composition/src/runtime/tests/outbound_delivery.rs index 207a0fc5ec7..0ef998959cb 100644 --- a/crates/app/ironclaw_composition/src/runtime/tests/outbound_delivery.rs +++ b/crates/app/ironclaw_composition/src/runtime/tests/outbound_delivery.rs @@ -10,6 +10,7 @@ use ironclaw_loop_contracts::{ use ironclaw_loop_host::{ HostManagedModelError, HostManagedModelErrorKind, HostManagedModelGateway, HostManagedModelMessageRole, HostManagedModelRequest, HostManagedModelResponse, + ToolDisclosureMode, }; use ironclaw_outbound::{ DeliveryTargetCapabilities, OutboundDeliveryTargetId, @@ -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(), @@ -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(), diff --git a/crates/app/ironclaw_composition/tests/runtime.rs b/crates/app/ironclaw_composition/tests/runtime.rs index 1c740011871..101188db227 100644 --- a/crates/app/ironclaw_composition/tests/runtime.rs +++ b/crates/app/ironclaw_composition/tests/runtime.rs @@ -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, @@ -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(), @@ -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"), diff --git a/crates/app/ironclaw_composition/tests/webui_v2_e2e.rs b/crates/app/ironclaw_composition/tests/webui_v2_e2e.rs index 208075d54bc..524b65b0d71 100644 --- a/crates/app/ironclaw_composition/tests/webui_v2_e2e.rs +++ b/crates/app/ironclaw_composition/tests/webui_v2_e2e.rs @@ -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}; @@ -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(), @@ -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(), @@ -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(), diff --git a/crates/loop/ironclaw_loop_host/src/tool_disclosure_mode.rs b/crates/loop/ironclaw_loop_host/src/tool_disclosure_mode.rs index ccb5187b9cf..92d46464b79 100644 --- a/crates/loop/ironclaw_loop_host/src/tool_disclosure_mode.rs +++ b/crates/loop/ironclaw_loop_host/src/tool_disclosure_mode.rs @@ -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, } @@ -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, @@ -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(), } } @@ -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()); diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md index 66f9d55ee3a..f21a7e7ba09 100644 --- a/tests/CLAUDE.md +++ b/tests/CLAUDE.md @@ -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` | diff --git a/tests/e2e/reborn_webui_harness.py b/tests/e2e/reborn_webui_harness.py index 3038dc18f30..3803869855b 100644 --- a/tests/e2e/reborn_webui_harness.py +++ b/tests/e2e/reborn_webui_harness.py @@ -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", diff --git a/tests/e2e/scenarios/test_reborn_responses_api.py b/tests/e2e/scenarios/test_reborn_responses_api.py index f01507f0794..ed569f66c3f 100644 --- a/tests/e2e/scenarios/test_reborn_responses_api.py +++ b/tests/e2e/scenarios/test_reborn_responses_api.py @@ -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", diff --git a/tests/integration/support/builder.rs b/tests/integration/support/builder.rs index a7a9c907440..dd7220c6033 100644 --- a/tests/integration/support/builder.rs +++ b/tests/integration/support/builder.rs @@ -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 diff --git a/tests/integration/tool_disclosure.rs b/tests/integration/tool_disclosure.rs index 2e7fbe0e977..46fdd0ad127 100644 --- a/tests/integration/tool_disclosure.rs +++ b/tests/integration/tool_disclosure.rs @@ -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 @@ -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. diff --git a/tests/reborn_qa_routines.rs b/tests/reborn_qa_routines.rs index eb75720c0b3..a2c78946857 100644 --- a/tests/reborn_qa_routines.rs +++ b/tests/reborn_qa_routines.rs @@ -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(), diff --git a/tests/support/reborn_parity_qa/binary_e2e.rs b/tests/support/reborn_parity_qa/binary_e2e.rs index 56562a4eed1..1741e433595 100644 --- a/tests/support/reborn_parity_qa/binary_e2e.rs +++ b/tests/support/reborn_parity_qa/binary_e2e.rs @@ -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; @@ -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 {