From 3c947cbb8e3047e7926ff4ef812fa1787c953b3e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 13 Oct 2026 11:50:52 +0000 Subject: [PATCH] chore: sync public mirror from internal --- .repository-projection.json | 6 +- packages/dex-host-rs/src/host_turn/tests.rs | 8 +- .../src/host_turn/tests/compaction.rs | 7 +- packages/dex-host-rs/tests/host_tools.rs | 7 +- .../hosted-runner-rs/src/hosted_runner.rs | 62 +-- .../src/hosted_runner/capabilities.rs | 65 +++ .../src/hosted_runner/tests.rs | 403 +---------------- .../src/hosted_runner/tests/capabilities.rs | 406 ++++++++++++++++++ .../examples/compaction_eval/main.rs | 1 + .../compaction_eval/provider_tests.rs | 4 +- .../examples/instruction_eval/main.rs | 1 + .../instruction_eval/provider_tests.rs | 4 +- .../src/agent/codemode_codex_tests.rs | 2 +- packages/local-host-rs/src/agent/harness.rs | 13 +- packages/local-host-rs/src/agent/mod.rs | 85 ++-- .../src/agent/native_admission_tests.rs | 6 + .../src/agent/native_codex_tests.rs | 97 ++++- .../src/agent/native_lifecycle_tests.rs | 6 +- packages/local-host-rs/src/agent/tool_host.rs | 36 +- .../src/agent/tool_host/tests.rs | 159 +++++++ packages/local-host-rs/src/codex_cli.rs | 12 +- packages/local-host-rs/src/embedding.rs | 28 +- packages/local-host-rs/src/headless/mod.rs | 6 +- packages/local-host-rs/src/headless_server.rs | 120 ++---- .../src/headless_server/state_init.rs | 74 ++++ .../workspace_observation_tests.rs | 3 +- packages/local-host-rs/src/safety/policy.rs | 39 +- .../safety/policy/tests/native_admission.rs | 57 +++ .../local-host-rs/src/subagents/factory.rs | 13 +- .../local-host-rs/src/subagents/lifecycle.rs | 7 +- .../src/subagents/lifetime_tests.rs | 1 + packages/local-host-rs/src/subagents/mod.rs | 96 ++--- .../local-host-rs/src/subagents/tool_host.rs | 74 ++++ packages/local-host-rs/src/tools/registry.rs | 19 +- .../src/tools/registry/subagent_host.rs | 34 ++ .../local-host-rs/src/tools/registry/tests.rs | 44 +- .../src/tools/registry/tests/subagent_host.rs | 108 +++++ packages/runtime-gateway-rs/src/dex_chat.rs | 15 +- packages/tui-rs/benches/agent_loop_bench.rs | 7 +- packages/tui-rs/src/app.rs | 132 +----- packages/tui-rs/src/app/agent_launch.rs | 137 ++++++ packages/tui-rs/src/app/tests.rs | 47 +- packages/tui-rs/src/app/tests/agent_launch.rs | 48 +++ .../tui-rs/src/app/tests/session_restore.rs | 2 + .../tui-rs/src/app/tests/startup_recovery.rs | 1 + packages/tui-rs/src/bin/maestro_perf_bench.rs | 7 +- packages/tui-rs/src/print_mode.rs | 7 +- packages/tui-rs/src/rubber_duck.rs | 1 + packages/tui-rs/src/scenario_cli/execute.rs | 13 +- .../tui-rs/src/workflow_cli/native_child.rs | 4 +- 50 files changed, 1551 insertions(+), 983 deletions(-) create mode 100644 packages/hosted-runner-rs/src/hosted_runner/capabilities.rs create mode 100644 packages/hosted-runner-rs/src/hosted_runner/tests/capabilities.rs create mode 100644 packages/local-host-rs/src/agent/tool_host/tests.rs create mode 100644 packages/local-host-rs/src/headless_server/state_init.rs create mode 100644 packages/local-host-rs/src/safety/policy/tests/native_admission.rs create mode 100644 packages/local-host-rs/src/subagents/tool_host.rs create mode 100644 packages/local-host-rs/src/tools/registry/subagent_host.rs create mode 100644 packages/local-host-rs/src/tools/registry/tests/subagent_host.rs create mode 100644 packages/tui-rs/src/app/agent_launch.rs create mode 100644 packages/tui-rs/src/app/tests/agent_launch.rs diff --git a/.repository-projection.json b/.repository-projection.json index 202611994..ce4a80849 100644 --- a/.repository-projection.json +++ b/.repository-projection.json @@ -3,11 +3,11 @@ "projection": "deixic-code", "projectionSchemaVersion": 1, "sourceRepository": "dx-corp/mono", - "sourceSha": "a38427baafa6f23d02126d70a1df17f738b06ad2", + "sourceSha": "6f320c6bc2d80a310dfa0188e2fb7eb9d7f657b5", "destinationRepository": "dx-corp/code", - "priorProjectedBase": "dfc60c773d92e1d8313d1e91c4b37bc22a9b4324", + "priorProjectedBase": "99e3ef534d21f41e4b650fb3a8a251fe1b99273c", "definitionDigest": "cb9d429542ebb0a2de9b42a7aad60d9d8696a648ceba47c30f05c0b285ca0db7", "toolDigest": "f6d8b2a0b6f867d1db845a086635357eed196054b6a47f0695e49fdde616d6e6", - "contentDigest": "68bde457e4331061a93756704c2c5903b3a81f43446ec6b9faf372849aef47a4", + "contentDigest": "ff8ba42a56e83f0fa71b52cb6c3ee4e97f2e035ebc738d3d209204a76455962b", "publicationEligible": true } diff --git a/packages/dex-host-rs/src/host_turn/tests.rs b/packages/dex-host-rs/src/host_turn/tests.rs index 175332c27..3f7414ef5 100644 --- a/packages/dex-host-rs/src/host_turn/tests.rs +++ b/packages/dex-host-rs/src/host_turn/tests.rs @@ -45,8 +45,12 @@ fn tools(workspace: &Path, mode: NativeApprovalMode) -> HostTools { cwd: workspace.to_string_lossy().into_owned(), ..NativeAgentConfig::default() }; - let host = - dex_loop_execution_host(&config, CredentialVault::new()).expect("compose native host"); + let host = dex_loop_execution_host( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + &config, + CredentialVault::new(), + ) + .expect("compose native host"); HostTools::new(host, mode) } diff --git a/packages/dex-host-rs/src/host_turn/tests/compaction.rs b/packages/dex-host-rs/src/host_turn/tests/compaction.rs index 36e7dcbf3..82350b68d 100644 --- a/packages/dex-host-rs/src/host_turn/tests/compaction.rs +++ b/packages/dex-host-rs/src/host_turn/tests/compaction.rs @@ -69,7 +69,12 @@ fn tools(workspace: &Path) -> HostTools { cwd: workspace.to_string_lossy().into_owned(), ..NativeAgentConfig::default() }; - let host = dex_loop_execution_host(&config, CredentialVault::new()).unwrap(); + let host = dex_loop_execution_host( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + &config, + CredentialVault::new(), + ) + .unwrap(); HostTools::new(host, maestro_runtime::agent::ApprovalMode::Selective).with_client_tools([ ToolSpec { name: ToolName::new("fixture.read"), diff --git a/packages/dex-host-rs/tests/host_tools.rs b/packages/dex-host-rs/tests/host_tools.rs index 317d82b78..3ff48c970 100644 --- a/packages/dex-host-rs/tests/host_tools.rs +++ b/packages/dex-host-rs/tests/host_tools.rs @@ -147,7 +147,12 @@ fn host_tools(workspace: &Path, mode: ApprovalMode) -> HostTools { cwd: workspace.to_string_lossy().into_owned(), ..NativeAgentConfig::default() }; - let host = dex_loop_execution_host(&config, CredentialVault::new()).expect("compose host"); + let host = dex_loop_execution_host( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + &config, + CredentialVault::new(), + ) + .expect("compose host"); HostTools::new(host, mode) } diff --git a/packages/hosted-runner-rs/src/hosted_runner.rs b/packages/hosted-runner-rs/src/hosted_runner.rs index de04d9171..3e224410c 100644 --- a/packages/hosted-runner-rs/src/hosted_runner.rs +++ b/packages/hosted-runner-rs/src/hosted_runner.rs @@ -51,6 +51,8 @@ use crate::headless::{ }; use crate::headless_server::{GovernedGrantVerificationContext, verify_governed_tool_grant}; +mod capabilities; +use capabilities::*; mod config; mod env_aliases; mod handle; @@ -1602,66 +1604,6 @@ impl HostedRunnerHeadlessMessageExecutor for TransportOnlyHostedRunnerMessageExe } } -impl AgentSupervisorHostedRunnerMessageExecutor { - fn negotiated_server_capabilities(&self) -> Option { - let Ok(supervisor) = self.supervisor.lock() else { - return Some(native_server_capabilities_without_governed_grants()); - }; - supervisor.state().server_capabilities.clone().or_else(|| { - (supervisor.transport_generation() == 0) - .then(native_server_capabilities_without_governed_grants) - }) - } - - fn hosted_hello_ok_for_context( - &self, - context: &HostedRunnerHeadlessMessageContext, - ) -> FromAgentMessage { - FromAgentMessage::HelloOk { - protocol_version: HEADLESS_PROTOCOL_VERSION.to_string(), - controller_binding_version: None, - controller_binding_sha256: None, - connection_id: Some(context.connection_id.clone()), - client_protocol_version: context.client_protocol_version.clone(), - client_info: context.client_info.clone(), - capabilities: context.capabilities.clone(), - server_capabilities: self.negotiated_server_capabilities(), - opt_out_notifications: context.opt_out_notifications.clone(), - role: Some(context.role), - controller_connection_id: context.controller_connection_id.clone(), - lease_expires_at: Some(context.lease_expires_at.clone()), - } - } -} - -fn server_capabilities_for_executor( - message_executor: &dyn HostedRunnerHeadlessMessageExecutor, -) -> Option { - match message_executor.state() { - Ok(Some(agent_state)) => agent_state.server_capabilities, - Ok(None) | Err(_) => Some(native_server_capabilities_without_governed_grants()), - } -} - -fn native_server_capabilities_without_governed_grants() -> ServerCapabilities { - server_capabilities_without_governed_grants(native_server_capabilities()) -} - -fn server_capabilities_without_governed_grants( - mut capabilities: ServerCapabilities, -) -> ServerCapabilities { - capabilities.governed_tool_grant_algorithms.clear(); - capabilities -} - -fn governed_grant_algorithms_for_executor( - message_executor: &dyn HostedRunnerHeadlessMessageExecutor, -) -> Vec { - server_capabilities_for_executor(message_executor) - .map(|capabilities| capabilities.governed_tool_grant_algorithms) - .unwrap_or_default() -} - fn hosted_runner_error_from_async_transport(error: AsyncTransportError) -> HostedRunnerError { HostedRunnerError::runtime_not_ready(format!("agent supervisor is not ready: {error}")) } diff --git a/packages/hosted-runner-rs/src/hosted_runner/capabilities.rs b/packages/hosted-runner-rs/src/hosted_runner/capabilities.rs new file mode 100644 index 000000000..3c0176708 --- /dev/null +++ b/packages/hosted-runner-rs/src/hosted_runner/capabilities.rs @@ -0,0 +1,65 @@ +//! Capability negotiation for attached and transport-only hosted executors. + +use super::*; + +impl AgentSupervisorHostedRunnerMessageExecutor { + pub(super) fn negotiated_server_capabilities(&self) -> Option { + let Ok(supervisor) = self.supervisor.lock() else { + return Some(native_server_capabilities_without_governed_grants()); + }; + supervisor.state().server_capabilities.clone().or_else(|| { + (supervisor.transport_generation() == 0) + .then(native_server_capabilities_without_governed_grants) + }) + } + + pub(super) fn hosted_hello_ok_for_context( + &self, + context: &HostedRunnerHeadlessMessageContext, + ) -> FromAgentMessage { + FromAgentMessage::HelloOk { + protocol_version: HEADLESS_PROTOCOL_VERSION.to_string(), + controller_binding_version: None, + controller_binding_sha256: None, + connection_id: Some(context.connection_id.clone()), + client_protocol_version: context.client_protocol_version.clone(), + client_info: context.client_info.clone(), + capabilities: context.capabilities.clone(), + server_capabilities: self.negotiated_server_capabilities(), + opt_out_notifications: context.opt_out_notifications.clone(), + role: Some(context.role), + controller_connection_id: context.controller_connection_id.clone(), + lease_expires_at: Some(context.lease_expires_at.clone()), + } + } +} + +pub(super) fn server_capabilities_for_executor( + message_executor: &dyn HostedRunnerHeadlessMessageExecutor, +) -> Option { + match message_executor.state() { + Ok(Some(agent_state)) => agent_state.server_capabilities, + Ok(None) | Err(_) => Some(native_server_capabilities_without_governed_grants()), + } +} + +pub(super) fn native_server_capabilities_without_governed_grants() -> ServerCapabilities { + server_capabilities_without_governed_grants(native_server_capabilities( + &maestro_local_host::agent::tool_host::BuiltInToolHost, + )) +} + +pub(super) fn server_capabilities_without_governed_grants( + mut capabilities: ServerCapabilities, +) -> ServerCapabilities { + capabilities.governed_tool_grant_algorithms.clear(); + capabilities +} + +pub(super) fn governed_grant_algorithms_for_executor( + message_executor: &dyn HostedRunnerHeadlessMessageExecutor, +) -> Vec { + server_capabilities_for_executor(message_executor) + .map(|capabilities| capabilities.governed_tool_grant_algorithms) + .unwrap_or_default() +} diff --git a/packages/hosted-runner-rs/src/hosted_runner/tests.rs b/packages/hosted-runner-rs/src/hosted_runner/tests.rs index 1f7aa9a7f..db5b2677b 100644 --- a/packages/hosted-runner-rs/src/hosted_runner/tests.rs +++ b/packages/hosted-runner-rs/src/hosted_runner/tests.rs @@ -1,4 +1,5 @@ mod blocked_executor_cleanup; +mod capabilities; use blocked_executor_cleanup::{ BlockedPromptRuntimeExecutor, BlockedPromptRuntimeExecutorReleaseGuard, }; @@ -3762,76 +3763,6 @@ fn hosted_server_capabilities_follow_attached_agent_state() { assert_eq!(server_capabilities_for_executor(&executor), Some(attached)); } -#[test] -fn hosted_server_capabilities_fail_closed_without_executor_state() { - let executor = TransportOnlyHostedRunnerMessageExecutor; - - let capabilities = server_capabilities_for_executor(&executor) - .expect("transport-only executor preserves baseline server capabilities"); - assert!(!capabilities.server_requests.is_empty()); - assert!(!capabilities.native_tools.is_empty()); - assert!(capabilities.governed_tool_grant_algorithms.is_empty()); - assert!(governed_grant_algorithms_for_executor(&executor).is_empty()); - - let workspace = tempdir().expect("transport-only workspace"); - let shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); - let state = shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let snapshot_capabilities = shared - .public_snapshot(&state) - .state - .server_capabilities - .expect("snapshot preserves baseline server capabilities"); - drop(state); - assert!(!snapshot_capabilities.server_requests.is_empty()); - assert!(!snapshot_capabilities.native_tools.is_empty()); - assert!( - snapshot_capabilities - .governed_tool_grant_algorithms - .is_empty() - ); - - let mut formerly_capable = native_server_capabilities(); - formerly_capable.governed_tool_grant_algorithms = vec!["ed25519".to_string()]; - let restored_workspace = tempdir().expect("restored child workspace"); - let restored_shared = SharedRunner::new_with_message_executor_and_restore( - test_config(restored_workspace.path().to_path_buf()), - Arc::new(StatefulRuntimeExecutor::new(AgentState { - server_capabilities: Some(formerly_capable), - ..AgentState::default() - })), - None, - ); - let restored_snapshot = { - let state = restored_shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - restored_shared.public_snapshot(&state) - }; - let restored_snapshot_capabilities = { - let mut state = shared - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - state.restored_snapshot = Some(restored_snapshot); - shared - .public_snapshot(&state) - .state - .server_capabilities - .expect("restored snapshot preserves baseline server capabilities") - }; - assert!(!restored_snapshot_capabilities.server_requests.is_empty()); - assert!(!restored_snapshot_capabilities.native_tools.is_empty()); - assert!( - restored_snapshot_capabilities - .governed_tool_grant_algorithms - .is_empty() - ); -} - #[test] fn supervisor_hello_capabilities_prefer_attached_agent_state() { let attached = ServerCapabilities { @@ -12420,338 +12351,6 @@ fn connection_create_rejects_unsupported_protocol_version() { } } -#[test] -fn connection_and_subscription_advertise_only_configured_ed25519_verifier() { - use base64::Engine as _; - - const ENV: &str = "MAESTRO_PLATFORM_TOOL_GRANT_ED25519_PUBLIC_KEYS"; - let _guard = crate::headless_server::GOVERNED_GRANT_ENV_LOCK - .lock() - .unwrap(); - let previous = std::env::var_os(ENV); - let configured = serde_json::json!({ - "active": { - "algorithm": "ed25519", - "public_key": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", - "state": "active" - } - }) - .to_string(); - // SAFETY: this test holds the shared lock for this dedicated env key and - // restores its prior value before releasing the lock. - unsafe { std::env::set_var(ENV, configured) }; - - let workspace = tempdir().expect("workspace"); - let current_agent_state = AgentState { - server_capabilities: Some(native_server_capabilities()), - ..AgentState::default() - }; - let shared = SharedRunner::new_with_message_executor_and_restore( - test_config(workspace.path().to_path_buf()), - Arc::new(StatefulRuntimeExecutor::new(current_agent_state)), - None, - ); - let connection = handle_connection_create( - shared.clone(), - ConnectionCreateRequest { - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - session_id: Some("sess_test".to_string()), - connection_id: Some("conn_capable".to_string()), - connection_capability: None, - connection_capability_required: true, - _thinking_level: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("capable connection response"); - let ResponseBody::Json { - body: connection, .. - } = connection - else { - panic!("connection response must be JSON") - }; - assert_eq!( - connection["governed_tool_grant_algorithms"], - serde_json::json!(["ed25519"]) - ); - let capability = connection["connection_capability"] - .as_str() - .expect("connection capability") - .to_string(); - - let subscription = handle_subscribe( - shared, - "sess_test", - SubscribeRequest { - connection_id: Some("conn_capable".to_string()), - subscription_id: None, - connection_capability: Some(capability), - connection_capability_required: true, - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("capable subscription response"); - let ResponseBody::Json { - body: subscription, .. - } = subscription - else { - panic!("subscription response must be JSON") - }; - assert_eq!( - subscription["governed_tool_grant_algorithms"], - serde_json::json!(["ed25519"]) - ); - - // A newer hosted wrapper must report the exact attached child's verifier - // capability. An older child deserializes without the new field, so its - // empty default keeps both hosted response boundaries on v1 even though - // the wrapper environment itself contains a valid public key. - let legacy_capabilities: ServerCapabilities = serde_json::from_value(serde_json::json!({ - "server_requests": [], - "native_tools": [] - })) - .expect("older child capabilities remain wire compatible"); - let legacy_agent_state = AgentState { - server_capabilities: Some(legacy_capabilities), - ..AgentState::default() - }; - let workspace = tempdir().expect("older child workspace"); - let legacy_child_shared = SharedRunner::new_with_message_executor_and_restore( - test_config(workspace.path().to_path_buf()), - Arc::new(StatefulRuntimeExecutor::new(legacy_agent_state)), - None, - ); - let legacy_child_connection = handle_connection_create( - legacy_child_shared.clone(), - ConnectionCreateRequest { - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - session_id: Some("sess_test".to_string()), - connection_id: Some("conn_older_child".to_string()), - connection_capability: None, - connection_capability_required: true, - _thinking_level: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("older child connection response"); - let ResponseBody::Json { - body: legacy_child_connection, - .. - } = legacy_child_connection - else { - panic!("older child connection response must be JSON") - }; - assert_eq!( - legacy_child_connection["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - let legacy_child_capability = legacy_child_connection["connection_capability"] - .as_str() - .expect("older child connection capability") - .to_string(); - let legacy_child_subscription = handle_subscribe( - legacy_child_shared, - "sess_test", - SubscribeRequest { - connection_id: Some("conn_older_child".to_string()), - subscription_id: None, - connection_capability: Some(legacy_child_capability), - connection_capability_required: true, - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("older child subscription response"); - let ResponseBody::Json { - body: legacy_child_subscription, - .. - } = legacy_child_subscription - else { - panic!("older child subscription response must be JSON") - }; - assert_eq!( - legacy_child_subscription["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - - // A blank signing key id cannot be selected by any valid grant and must - // never advertise governed v2 on either response boundary. - unsafe { - std::env::set_var( - ENV, - serde_json::json!({ - " ": { - "algorithm": "ed25519", - "public_key": base64::engine::general_purpose::STANDARD.encode([8_u8; 32]), - "state": "active" - } - }) - .to_string(), - ); - }; - let workspace = tempdir().expect("legacy workspace"); - let legacy_shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); - let legacy = handle_connection_create( - legacy_shared.clone(), - ConnectionCreateRequest { - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - session_id: Some("sess_test".to_string()), - connection_id: Some("conn_legacy".to_string()), - connection_capability: None, - connection_capability_required: true, - _thinking_level: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("legacy-compatible response"); - let ResponseBody::Json { body: legacy, .. } = legacy else { - panic!("legacy response must be JSON") - }; - assert_eq!( - legacy["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - let legacy_capability = legacy["connection_capability"] - .as_str() - .expect("legacy connection capability") - .to_string(); - let legacy_subscription = handle_subscribe( - legacy_shared, - "sess_test", - SubscribeRequest { - connection_id: Some("conn_legacy".to_string()), - subscription_id: None, - connection_capability: Some(legacy_capability), - connection_capability_required: true, - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("legacy-compatible subscription response"); - let ResponseBody::Json { - body: legacy_subscription, - .. - } = legacy_subscription - else { - panic!("legacy subscription response must be JSON") - }; - assert_eq!( - legacy_subscription["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - - // Length and canonical point decoding are not enough: a weak small-order - // point must fail at load time and cannot be advertised by either response. - let mut weak_public_key = [0_u8; 32]; - weak_public_key[0] = 1; - unsafe { - std::env::set_var( - ENV, - serde_json::json!({ - "active": { - "algorithm": "ed25519", - "public_key": base64::engine::general_purpose::STANDARD.encode(weak_public_key), - "state": "active" - } - }) - .to_string(), - ); - }; - let workspace = tempdir().expect("weak point workspace"); - let weak_point_shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); - let weak_point = handle_connection_create( - weak_point_shared.clone(), - ConnectionCreateRequest { - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - session_id: Some("sess_test".to_string()), - connection_id: Some("conn_weak_point".to_string()), - connection_capability: None, - connection_capability_required: true, - _thinking_level: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("weak point remains legacy compatible"); - let ResponseBody::Json { - body: weak_point, .. - } = weak_point - else { - panic!("weak point response must be JSON") - }; - assert_eq!( - weak_point["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - let weak_point_capability = weak_point["connection_capability"] - .as_str() - .expect("weak point connection capability") - .to_string(); - let weak_point_subscription = handle_subscribe( - weak_point_shared, - "sess_test", - SubscribeRequest { - connection_id: Some("conn_weak_point".to_string()), - subscription_id: None, - connection_capability: Some(weak_point_capability), - connection_capability_required: true, - protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), - client_info: None, - capabilities: None, - opt_out_notifications: Vec::new(), - role: Some(ConnectionRole::Controller), - take_control: true, - }, - ) - .expect("weak point subscription remains legacy compatible"); - let ResponseBody::Json { - body: weak_point_subscription, - .. - } = weak_point_subscription - else { - panic!("weak point subscription response must be JSON") - }; - assert_eq!( - weak_point_subscription["governed_tool_grant_algorithms"], - serde_json::json!([]) - ); - - unsafe { - match previous { - Some(value) => std::env::set_var(ENV, value), - None => std::env::remove_var(ENV), - } - } -} - #[test] fn subscribe_rejects_unsupported_protocol_version() { let workspace = tempdir().expect("workspace"); diff --git a/packages/hosted-runner-rs/src/hosted_runner/tests/capabilities.rs b/packages/hosted-runner-rs/src/hosted_runner/tests/capabilities.rs new file mode 100644 index 000000000..e2c04ffca --- /dev/null +++ b/packages/hosted-runner-rs/src/hosted_runner/tests/capabilities.rs @@ -0,0 +1,406 @@ +use super::*; + +#[test] +fn hosted_server_capabilities_fail_closed_without_executor_state() { + let executor = TransportOnlyHostedRunnerMessageExecutor; + + let capabilities = server_capabilities_for_executor(&executor) + .expect("transport-only executor preserves baseline server capabilities"); + assert!(!capabilities.server_requests.is_empty()); + assert!(!capabilities.native_tools.is_empty()); + assert!(capabilities.governed_tool_grant_algorithms.is_empty()); + assert!(governed_grant_algorithms_for_executor(&executor).is_empty()); + + let workspace = tempdir().expect("transport-only workspace"); + let shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); + let state = shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let snapshot_capabilities = shared + .public_snapshot(&state) + .state + .server_capabilities + .expect("snapshot preserves baseline server capabilities"); + drop(state); + assert!(!snapshot_capabilities.server_requests.is_empty()); + assert!(!snapshot_capabilities.native_tools.is_empty()); + assert!( + snapshot_capabilities + .governed_tool_grant_algorithms + .is_empty() + ); + + let mut formerly_capable = + native_server_capabilities(&maestro_local_host::agent::tool_host::BuiltInToolHost); + formerly_capable.governed_tool_grant_algorithms = vec!["ed25519".to_string()]; + let restored_workspace = tempdir().expect("restored child workspace"); + let restored_shared = SharedRunner::new_with_message_executor_and_restore( + test_config(restored_workspace.path().to_path_buf()), + Arc::new(StatefulRuntimeExecutor::new(AgentState { + server_capabilities: Some(formerly_capable), + ..AgentState::default() + })), + None, + ); + let restored_snapshot = { + let state = restored_shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + restored_shared.public_snapshot(&state) + }; + let restored_snapshot_capabilities = { + let mut state = shared + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.restored_snapshot = Some(restored_snapshot); + shared + .public_snapshot(&state) + .state + .server_capabilities + .expect("restored snapshot preserves baseline server capabilities") + }; + assert!(!restored_snapshot_capabilities.server_requests.is_empty()); + assert!(!restored_snapshot_capabilities.native_tools.is_empty()); + assert!( + restored_snapshot_capabilities + .governed_tool_grant_algorithms + .is_empty() + ); +} + +#[test] +fn connection_and_subscription_advertise_only_configured_ed25519_verifier() { + use base64::Engine as _; + + const ENV: &str = "MAESTRO_PLATFORM_TOOL_GRANT_ED25519_PUBLIC_KEYS"; + let _guard = crate::headless_server::GOVERNED_GRANT_ENV_LOCK + .lock() + .unwrap(); + let previous = std::env::var_os(ENV); + let configured = serde_json::json!({ + "active": { + "algorithm": "ed25519", + "public_key": "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=", + "state": "active" + } + }) + .to_string(); + // SAFETY: this test holds the shared lock for this dedicated env key and + // restores its prior value before releasing the lock. + unsafe { std::env::set_var(ENV, configured) }; + + let workspace = tempdir().expect("workspace"); + let current_agent_state = AgentState { + server_capabilities: Some(native_server_capabilities( + &maestro_local_host::agent::tool_host::BuiltInToolHost, + )), + ..AgentState::default() + }; + let shared = SharedRunner::new_with_message_executor_and_restore( + test_config(workspace.path().to_path_buf()), + Arc::new(StatefulRuntimeExecutor::new(current_agent_state)), + None, + ); + let connection = handle_connection_create( + shared.clone(), + ConnectionCreateRequest { + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + session_id: Some("sess_test".to_string()), + connection_id: Some("conn_capable".to_string()), + connection_capability: None, + connection_capability_required: true, + _thinking_level: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("capable connection response"); + let ResponseBody::Json { + body: connection, .. + } = connection + else { + panic!("connection response must be JSON") + }; + assert_eq!( + connection["governed_tool_grant_algorithms"], + serde_json::json!(["ed25519"]) + ); + let capability = connection["connection_capability"] + .as_str() + .expect("connection capability") + .to_string(); + + let subscription = handle_subscribe( + shared, + "sess_test", + SubscribeRequest { + connection_id: Some("conn_capable".to_string()), + subscription_id: None, + connection_capability: Some(capability), + connection_capability_required: true, + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("capable subscription response"); + let ResponseBody::Json { + body: subscription, .. + } = subscription + else { + panic!("subscription response must be JSON") + }; + assert_eq!( + subscription["governed_tool_grant_algorithms"], + serde_json::json!(["ed25519"]) + ); + + // A newer hosted wrapper must report the exact attached child's verifier + // capability. An older child deserializes without the new field, so its + // empty default keeps both hosted response boundaries on v1 even though + // the wrapper environment itself contains a valid public key. + let legacy_capabilities: ServerCapabilities = serde_json::from_value(serde_json::json!({ + "server_requests": [], + "native_tools": [] + })) + .expect("older child capabilities remain wire compatible"); + let legacy_agent_state = AgentState { + server_capabilities: Some(legacy_capabilities), + ..AgentState::default() + }; + let workspace = tempdir().expect("older child workspace"); + let legacy_child_shared = SharedRunner::new_with_message_executor_and_restore( + test_config(workspace.path().to_path_buf()), + Arc::new(StatefulRuntimeExecutor::new(legacy_agent_state)), + None, + ); + let legacy_child_connection = handle_connection_create( + legacy_child_shared.clone(), + ConnectionCreateRequest { + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + session_id: Some("sess_test".to_string()), + connection_id: Some("conn_older_child".to_string()), + connection_capability: None, + connection_capability_required: true, + _thinking_level: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("older child connection response"); + let ResponseBody::Json { + body: legacy_child_connection, + .. + } = legacy_child_connection + else { + panic!("older child connection response must be JSON") + }; + assert_eq!( + legacy_child_connection["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + let legacy_child_capability = legacy_child_connection["connection_capability"] + .as_str() + .expect("older child connection capability") + .to_string(); + let legacy_child_subscription = handle_subscribe( + legacy_child_shared, + "sess_test", + SubscribeRequest { + connection_id: Some("conn_older_child".to_string()), + subscription_id: None, + connection_capability: Some(legacy_child_capability), + connection_capability_required: true, + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("older child subscription response"); + let ResponseBody::Json { + body: legacy_child_subscription, + .. + } = legacy_child_subscription + else { + panic!("older child subscription response must be JSON") + }; + assert_eq!( + legacy_child_subscription["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + + // A blank signing key id cannot be selected by any valid grant and must + // never advertise governed v2 on either response boundary. + unsafe { + std::env::set_var( + ENV, + serde_json::json!({ + " ": { + "algorithm": "ed25519", + "public_key": base64::engine::general_purpose::STANDARD.encode([8_u8; 32]), + "state": "active" + } + }) + .to_string(), + ); + }; + let workspace = tempdir().expect("legacy workspace"); + let legacy_shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); + let legacy = handle_connection_create( + legacy_shared.clone(), + ConnectionCreateRequest { + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + session_id: Some("sess_test".to_string()), + connection_id: Some("conn_legacy".to_string()), + connection_capability: None, + connection_capability_required: true, + _thinking_level: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("legacy-compatible response"); + let ResponseBody::Json { body: legacy, .. } = legacy else { + panic!("legacy response must be JSON") + }; + assert_eq!( + legacy["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + let legacy_capability = legacy["connection_capability"] + .as_str() + .expect("legacy connection capability") + .to_string(); + let legacy_subscription = handle_subscribe( + legacy_shared, + "sess_test", + SubscribeRequest { + connection_id: Some("conn_legacy".to_string()), + subscription_id: None, + connection_capability: Some(legacy_capability), + connection_capability_required: true, + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("legacy-compatible subscription response"); + let ResponseBody::Json { + body: legacy_subscription, + .. + } = legacy_subscription + else { + panic!("legacy subscription response must be JSON") + }; + assert_eq!( + legacy_subscription["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + + // Length and canonical point decoding are not enough: a weak small-order + // point must fail at load time and cannot be advertised by either response. + let mut weak_public_key = [0_u8; 32]; + weak_public_key[0] = 1; + unsafe { + std::env::set_var( + ENV, + serde_json::json!({ + "active": { + "algorithm": "ed25519", + "public_key": base64::engine::general_purpose::STANDARD.encode(weak_public_key), + "state": "active" + } + }) + .to_string(), + ); + }; + let workspace = tempdir().expect("weak point workspace"); + let weak_point_shared = SharedRunner::new(test_config(workspace.path().to_path_buf())); + let weak_point = handle_connection_create( + weak_point_shared.clone(), + ConnectionCreateRequest { + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + session_id: Some("sess_test".to_string()), + connection_id: Some("conn_weak_point".to_string()), + connection_capability: None, + connection_capability_required: true, + _thinking_level: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("weak point remains legacy compatible"); + let ResponseBody::Json { + body: weak_point, .. + } = weak_point + else { + panic!("weak point response must be JSON") + }; + assert_eq!( + weak_point["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + let weak_point_capability = weak_point["connection_capability"] + .as_str() + .expect("weak point connection capability") + .to_string(); + let weak_point_subscription = handle_subscribe( + weak_point_shared, + "sess_test", + SubscribeRequest { + connection_id: Some("conn_weak_point".to_string()), + subscription_id: None, + connection_capability: Some(weak_point_capability), + connection_capability_required: true, + protocol_version: Some(HEADLESS_PROTOCOL_VERSION.to_string()), + client_info: None, + capabilities: None, + opt_out_notifications: Vec::new(), + role: Some(ConnectionRole::Controller), + take_control: true, + }, + ) + .expect("weak point subscription remains legacy compatible"); + let ResponseBody::Json { + body: weak_point_subscription, + .. + } = weak_point_subscription + else { + panic!("weak point subscription response must be JSON") + }; + assert_eq!( + weak_point_subscription["governed_tool_grant_algorithms"], + serde_json::json!([]) + ); + + unsafe { + match previous { + Some(value) => std::env::set_var(ENV, value), + None => std::env::remove_var(ENV), + } + } +} diff --git a/packages/local-host-rs/examples/compaction_eval/main.rs b/packages/local-host-rs/examples/compaction_eval/main.rs index 92d78ad08..fe2d2d304 100644 --- a/packages/local-host-rs/examples/compaction_eval/main.rs +++ b/packages/local-host-rs/examples/compaction_eval/main.rs @@ -91,6 +91,7 @@ async fn main() -> Result<()> { }; // Use ordinary authenticated local composition, with an empty tool allowlist. let (agent, mut events) = NativeAgent::new_with_allowed_tools_and_credential_vault( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), config, &HashSet::new(), CredentialVault::new(), diff --git a/packages/local-host-rs/examples/compaction_eval/provider_tests.rs b/packages/local-host-rs/examples/compaction_eval/provider_tests.rs index 42694dd00..9b008dec0 100644 --- a/packages/local-host-rs/examples/compaction_eval/provider_tests.rs +++ b/packages/local-host-rs/examples/compaction_eval/provider_tests.rs @@ -118,13 +118,13 @@ async fn native_summary_is_applied_before_probe_and_grader_answer_never_enters_r OpenAiClient::with_base_url("fixture-key", format!("http://{address}/v1")).unwrap(), ); let (agent, mut events) = NativeAgent::start( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), config, vec![], CredentialVault::new(), Some(&HashSet::new()), Some(super::ClientOverride::UnverifiedTest(client)), - None, - None, + super::NativeAgentScope::default(), ) .unwrap(); assert!(agent.runtime_audit_snapshot().tools.is_empty()); diff --git a/packages/local-host-rs/examples/instruction_eval/main.rs b/packages/local-host-rs/examples/instruction_eval/main.rs index 3f065386d..83ff5fb92 100644 --- a/packages/local-host-rs/examples/instruction_eval/main.rs +++ b/packages/local-host-rs/examples/instruction_eval/main.rs @@ -130,6 +130,7 @@ async fn main() -> Result<()> { ..Default::default() }; let (agent, mut events) = NativeAgent::new_with_allowed_tools_and_credential_vault( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), config, &HashSet::new(), CredentialVault::new(), diff --git a/packages/local-host-rs/examples/instruction_eval/provider_tests.rs b/packages/local-host-rs/examples/instruction_eval/provider_tests.rs index 8031bb057..adcc1a876 100644 --- a/packages/local-host-rs/examples/instruction_eval/provider_tests.rs +++ b/packages/local-host-rs/examples/instruction_eval/provider_tests.rs @@ -70,13 +70,13 @@ async fn matched_native_requests_change_only_instruction_fragment_and_never_send OpenAiClient::with_base_url("fixture-key", format!("http://{address}/v1")).unwrap(), ); let (agent, mut events) = NativeAgent::start( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), config, vec![], CredentialVault::new(), Some(&HashSet::new()), Some(super::ClientOverride::UnverifiedTest(client)), - None, - None, + super::NativeAgentScope::default(), ) .unwrap(); assert!(agent.runtime_audit_snapshot().tools.is_empty()); diff --git a/packages/local-host-rs/src/agent/codemode_codex_tests.rs b/packages/local-host-rs/src/agent/codemode_codex_tests.rs index fc86846ad..e14fb00be 100644 --- a/packages/local-host-rs/src/agent/codemode_codex_tests.rs +++ b/packages/local-host-rs/src/agent/codemode_codex_tests.rs @@ -29,7 +29,7 @@ fn codemode_images_reach_app_server_and_semantic_checkpoint() { requires_approval: true, }).collect(); let replacement = vec![external[0].clone()]; - let (agent, mut events) = NativeAgent::new_with_tools(config, external).unwrap(); + let (agent, mut events) = NativeAgent::new_with_tools(std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), config, external).unwrap(); let mut snapshots = Vec::new(); let mut caller_executions = 0; for (index, prompt) in ["Emit the image", "Emit again with updated tools"].into_iter().enumerate() { diff --git a/packages/local-host-rs/src/agent/harness.rs b/packages/local-host-rs/src/agent/harness.rs index 1b6bbe804..2286e60ad 100644 --- a/packages/local-host-rs/src/agent/harness.rs +++ b/packages/local-host-rs/src/agent/harness.rs @@ -55,7 +55,11 @@ impl AgentHarness { "scripted-replay/maestro-replay-v1", responses, )); - let (agent, events) = NativeAgent::new_with_test_client(config, client)?; + let (agent, events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + client, + )?; // Point the runner's hook logger at our file without requiring the // temp workspace to be marked trusted in global config. agent.set_hook_log_file(hook_log.display().to_string())?; @@ -936,7 +940,12 @@ async fn bash_capture_survives_native_compaction_and_continuation_replay() { context_window: Some(4_096), ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new_with_test_client(config, client).expect("agent"); + let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("agent"); agent .prompt("Read the evidence file.".into(), vec![]) .await diff --git a/packages/local-host-rs/src/agent/mod.rs b/packages/local-host-rs/src/agent/mod.rs index 86a689cbc..0548fff01 100644 --- a/packages/local-host-rs/src/agent/mod.rs +++ b/packages/local-host-rs/src/agent/mod.rs @@ -232,6 +232,12 @@ impl Deref for NativeAgent { } } +#[derive(Default)] +struct NativeAgentScope { + subagent_parent_scope_id: Option, + mailbox_identity: Option, +} + impl NativeAgent { /// Transfer the one runtime actor to a host-owned lifecycle manager. /// The event relay continues to preserve the existing telemetry stream. @@ -240,27 +246,27 @@ impl NativeAgent { } fn start( + tool_host: Arc, config: NativeAgentConfig, external_tool_definitions: Vec, credential_vault: CredentialVault, allowed_tools: Option<&HashSet>, client_override: Option, - subagent_parent_scope_id: Option, - mailbox_identity: Option, + scope: NativeAgentScope, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { let (resolved, initial_identity_scope) = resolve_native_client(&config.model, client_override.clone())?; let telemetry_identity_scope = Arc::new(RwLock::new(initial_identity_scope)); let provider_name = resolved.provider_name.clone(); let telemetry_config = config.clone(); - let host = tool_host::tool_host().build_local_host(tool_host::LocalHostBuild { + let host = tool_host.build_local_host(tool_host::LocalHostBuild { config: &config, credential_vault: credential_vault.clone(), skip_identity_verification: client_override .as_ref() .is_some_and(ClientOverride::skips_identity_verification), - subagent_parent_scope_id: subagent_parent_scope_id.clone(), - mailbox_identity: mailbox_identity.clone(), + subagent_parent_scope_id: scope.subagent_parent_scope_id, + mailbox_identity: scope.mailbox_identity, telemetry_identity_scope: Arc::clone(&telemetry_identity_scope), })?; let runtime_config = config.into_runtime(); @@ -284,72 +290,90 @@ impl NativeAgent { } pub fn new( + tool_host: Arc, config: NativeAgentConfig, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { - Self::new_with_tools_and_credential_vault(config, Vec::new(), CredentialVault::new()) + Self::new_with_tools_and_credential_vault( + tool_host, + config, + Vec::new(), + CredentialVault::new(), + ) } pub fn new_with_credential_vault( + tool_host: Arc, config: NativeAgentConfig, credential_vault: CredentialVault, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { - Self::new_with_tools_and_credential_vault(config, Vec::new(), credential_vault) + Self::new_with_tools_and_credential_vault(tool_host, config, Vec::new(), credential_vault) } pub fn new_with_credential_vault_and_subagent_scope( + tool_host: Arc, config: NativeAgentConfig, credential_vault: CredentialVault, subagent_parent_scope_id: String, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), credential_vault, None, None, - Some(subagent_parent_scope_id), - None, + NativeAgentScope { + subagent_parent_scope_id: Some(subagent_parent_scope_id), + ..NativeAgentScope::default() + }, ) } pub fn new_with_allowed_tools_and_credential_vault( + tool_host: Arc, config: NativeAgentConfig, allowed_tools: &HashSet, credential_vault: CredentialVault, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), credential_vault, Some(allowed_tools), None, - None, - None, + NativeAgentScope::default(), ) } pub fn new_with_allowed_tools_and_credential_vault_runner( + tool_host: Arc, config: NativeAgentConfig, allowed_tools: &HashSet, credential_vault: CredentialVault, mailbox_identity: String, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), credential_vault, Some(allowed_tools), None, - None, - Some(mailbox_identity), + NativeAgentScope { + mailbox_identity: Some(mailbox_identity), + ..NativeAgentScope::default() + }, ) } pub fn new_with_tools( + tool_host: Arc, config: NativeAgentConfig, external_tool_definitions: Vec, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::new_with_tools_and_credential_vault( + tool_host, config, external_tool_definitions, CredentialVault::new(), @@ -357,18 +381,19 @@ impl NativeAgent { } pub fn new_with_tools_and_credential_vault( + tool_host: Arc, config: NativeAgentConfig, external_tool_definitions: Vec, credential_vault: CredentialVault, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, external_tool_definitions, credential_vault, None, None, - None, - None, + NativeAgentScope::default(), ) } @@ -379,82 +404,87 @@ impl NativeAgent { /// [`Self::new_with_tools`]. #[cfg(any(test, feature = "test-support"))] pub fn new_with_tools_and_test_client( + tool_host: Arc, config: NativeAgentConfig, external_tool_definitions: Vec, client: UnifiedClient, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, external_tool_definitions, CredentialVault::new(), None, Some(ClientOverride::UnverifiedTest(client)), - None, - None, + NativeAgentScope::default(), ) } pub fn new_with_governed_tools_and_credential_vault( + tool_host: Arc, config: NativeAgentConfig, allowed_tools: &HashSet, external_tool_definitions: Vec, credential_vault: CredentialVault, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, external_tool_definitions, credential_vault, Some(allowed_tools), None, - None, - None, + NativeAgentScope::default(), ) } pub fn new_with_client( + tool_host: Arc, config: NativeAgentConfig, client: UnifiedClient, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), CredentialVault::new(), None, Some(ClientOverride::Production(client)), - None, - None, + NativeAgentScope::default(), ) } pub fn new_with_client_and_allowed_tools( + tool_host: Arc, config: NativeAgentConfig, allowed_tools: &HashSet, client: UnifiedClient, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), CredentialVault::new(), Some(allowed_tools), Some(ClientOverride::Production(client)), - None, - None, + NativeAgentScope::default(), ) } #[cfg(any(test, feature = "test-support"))] pub fn new_with_test_client( + tool_host: Arc, config: NativeAgentConfig, client: UnifiedClient, ) -> Result<(Self, tokio::sync::mpsc::UnboundedReceiver)> { Self::start( + tool_host, config, Vec::new(), CredentialVault::new(), None, Some(ClientOverride::UnverifiedTest(client)), - None, - None, + NativeAgentScope::default(), ) } @@ -619,10 +649,11 @@ fn resolve_native_client( /// serve both loops; resolve the turn's model client with /// [`NativeExecutionHostHandle::resolve_model`]. pub fn dex_loop_execution_host( + tool_host: Arc, config: &NativeAgentConfig, credential_vault: CredentialVault, ) -> Result { - tool_host::tool_host().build_local_host(tool_host::LocalHostBuild { + tool_host.build_local_host(tool_host::LocalHostBuild { config, credential_vault, skip_identity_verification: false, @@ -633,6 +664,7 @@ pub fn dex_loop_execution_host( } fn build_local_host( + tool_host: Arc, config: &NativeAgentConfig, credential_vault: CredentialVault, skip_identity_verification: bool, @@ -641,6 +673,7 @@ fn build_local_host( telemetry_identity_scope: Arc>>, ) -> Result { let mut executor = ToolExecutor::with_credential_vault(&config.cwd, credential_vault) + .with_tool_host(tool_host) .with_code_authority() .with_managed_mcp_policy(config.managed_mcp_policy.clone()) .with_background_task_access(config.background_task_access.clone()); diff --git a/packages/local-host-rs/src/agent/native_admission_tests.rs b/packages/local-host-rs/src/agent/native_admission_tests.rs index 9cea31356..d4195a0f6 100644 --- a/packages/local-host-rs/src/agent/native_admission_tests.rs +++ b/packages/local-host-rs/src/agent/native_admission_tests.rs @@ -60,6 +60,7 @@ fn identity_env_names(include_base_url: bool, include_authority: bool) -> Vec<&' fn tui_host(config: NativeAgentConfig) -> NativeExecutionHostHandle { super::build_local_host( + Arc::new(super::tool_host::BuiltInToolHost), &config, CredentialVault::new(), false, @@ -271,6 +272,7 @@ fn injected_network_client_requires_evalops_identity() { .expect("test client"), ); let error = match NativeAgent::new_with_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "openai/gpt-5.5".to_owned(), ..NativeAgentConfig::default() @@ -306,6 +308,7 @@ fn injected_scripted_client_requires_evalops_identity() { vec![ScriptedResponse::text("replay")], )); let error = match NativeAgent::new_with_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "scripted-replay/maestro-replay-v1".to_owned(), ..NativeAgentConfig::default() @@ -343,6 +346,7 @@ async fn injected_scripted_client_admits_after_verified_identity() { vec![ScriptedResponse::text("replay")], )); NativeAgent::new_with_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "scripted-replay/maestro-replay-v1".to_owned(), ..NativeAgentConfig::default() @@ -376,6 +380,7 @@ fn injected_network_client_rejects_caller_selected_identity_authority() { .expect("test client"), ); let error = match NativeAgent::new_with_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "openai/gpt-5.5".to_owned(), ..NativeAgentConfig::default() @@ -412,6 +417,7 @@ fn injected_allowed_tools_client_requires_evalops_identity() { .expect("test client"), ); let error = match NativeAgent::new_with_client_and_allowed_tools( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "openai/gpt-5.5".to_owned(), ..NativeAgentConfig::default() diff --git a/packages/local-host-rs/src/agent/native_codex_tests.rs b/packages/local-host-rs/src/agent/native_codex_tests.rs index 515edeea0..2df6c2b0d 100644 --- a/packages/local-host-rs/src/agent/native_codex_tests.rs +++ b/packages/local-host-rs/src/agent/native_codex_tests.rs @@ -457,8 +457,12 @@ async fn hosted_hard_kill_at_terminal_restores_prior_user_and_assistant_turn() { crate::ai::OpenAiClient::with_base_url("test-key", base_url.clone()) .expect("owner-1 client"), ); - let (source, mut source_events) = - NativeAgent::new_with_test_client(config.clone(), source_client).expect("owner-1"); + let (source, mut source_events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config.clone(), + source_client, + ) + .expect("owner-1"); source .prompt("owner-1-user-sentinel".to_owned(), vec![]) .await @@ -517,8 +521,12 @@ async fn hosted_hard_kill_at_terminal_restores_prior_user_and_assistant_turn() { let restored_client = UnifiedClient::OpenAI( crate::ai::OpenAiClient::with_base_url("test-key", base_url).expect("owner-2 client"), ); - let (restored, mut restored_events) = - NativeAgent::new_with_test_client(config, restored_client).expect("owner-2"); + let (restored, mut restored_events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + restored_client, + ) + .expect("owner-2"); restored.replace_history(restored_history); restored .prompt("owner-2-user-sentinel".to_owned(), vec![]) @@ -562,8 +570,12 @@ async fn hosted_fast_tool_search_cannot_reactivate_rlm_mutations() { let client = UnifiedClient::OpenAI( crate::ai::OpenAiClient::with_base_url("test-key", base_url).expect("scripted client"), ); - let (agent, mut events) = - NativeAgent::new_with_test_client(config, client).expect("hosted agent"); + let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("hosted agent"); agent .prompt( @@ -649,7 +661,12 @@ async fn injected_user_note_is_consumed_only_after_a_successful_turn() { crate::ai::OpenAiClient::with_base_url("test-key", format!("http://{address}/v1")) .expect("test client"), ); - let (agent, _events) = NativeAgent::new_with_test_client(config, client).expect("native agent"); + let (agent, _events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("native agent"); let (applied, consumed) = agent .inject_user_note("Subagent child-1 completed.") .expect("queue user note"); @@ -686,8 +703,12 @@ async fn native_provider_checkpoint_survives_process_death_and_restores_tool_con crate::ai::OpenAiClient::with_base_url("test-key", base_url.clone()) .expect("source client"), ); - let (source, mut source_events) = - NativeAgent::new_with_test_client(config.clone(), source_client).expect("source agent"); + let (source, mut source_events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config.clone(), + source_client, + ) + .expect("source agent"); source .prompt("first turn".to_owned(), vec![]) .await @@ -732,8 +753,12 @@ async fn native_provider_checkpoint_survives_process_death_and_restores_tool_con let restored_client = UnifiedClient::OpenAI( crate::ai::OpenAiClient::with_base_url("test-key", base_url).expect("restored client"), ); - let (restored, mut restored_events) = - NativeAgent::new_with_test_client(config, restored_client).expect("restored agent"); + let (restored, mut restored_events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + restored_client, + ) + .expect("restored agent"); restored.replace_history(restored_history); restored .prompt("second turn".to_owned(), vec![]) @@ -776,7 +801,11 @@ async fn codex_process_continuation_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex fixture agent"); if role == "restore" { let history = serde_json::from_slice(&std::fs::read(&checkpoint).expect("hydrated checkpoint")) @@ -854,7 +883,11 @@ async fn codex_usage_notification_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex usage fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex usage fixture agent"); agent .prompt("usage notification prompt".to_owned(), vec![]) .await @@ -903,7 +936,11 @@ async fn codex_cancel_lifecycle_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex cancel fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex cancel fixture agent"); agent .prompt("cancel lifecycle prompt".to_owned(), vec![]) .await @@ -983,7 +1020,11 @@ async fn codex_terminal_response_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex terminal fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex terminal fixture agent"); agent .prompt("terminal response prompt".to_owned(), vec![]) .await @@ -1032,7 +1073,11 @@ async fn codex_file_change_completion_before_approval_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex file-change fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex file-change fixture agent"); agent .prompt( "use the file-change tool and then finish".to_owned(), @@ -1791,7 +1836,11 @@ async fn codex_pre_turn_failure_fixture() { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex fixture agent"); agent.replace_history(history); let prompts: &[&str] = match scenario.as_str() { @@ -2430,8 +2479,12 @@ async fn set_model_normalizes_nonreasoning_target_before_next_request() { "fixture", vec![crate::ai::ScriptedResponse::text("unused")], )); - let (agent, mut events) = - NativeAgent::new_with_test_client(config, client).expect("fixture agent"); + let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("fixture agent"); agent .set_model("openrouter/openai/gpt-4o") .expect("queue model switch"); @@ -2566,7 +2619,11 @@ setTimeout(() => { approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new(config).expect("Codex fixture agent"); + let (agent, mut events) = NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + ) + .expect("Codex fixture agent"); agent .prompt("Start the preserved session.".to_owned(), vec![]) .await diff --git a/packages/local-host-rs/src/agent/native_lifecycle_tests.rs b/packages/local-host-rs/src/agent/native_lifecycle_tests.rs index 8c1e020ff..75a322023 100644 --- a/packages/local-host-rs/src/agent/native_lifecycle_tests.rs +++ b/packages/local-host-rs/src/agent/native_lifecycle_tests.rs @@ -64,7 +64,11 @@ fn scripted_agent( "scripted-replay/maestro-replay-v1", responses, )); - NativeAgent::new_with_test_client(scripted_config(cwd), client) + NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + scripted_config(cwd), + client, + ) } fn scripted_agent_with_tui_host( diff --git a/packages/local-host-rs/src/agent/tool_host.rs b/packages/local-host-rs/src/agent/tool_host.rs index 1611e3377..4680e7775 100644 --- a/packages/local-host-rs/src/agent/tool_host.rs +++ b/packages/local-host-rs/src/agent/tool_host.rs @@ -3,11 +3,12 @@ //! The host asks this trait for the three things it needs from the tool layer: //! composing the native execution host, and listing tool definitions for //! capability and subagent policy decisions. [`BuiltInToolHost`] is the -//! in-tree implementation; a different crate can supply its own through -//! [`install_tool_host`]. +//! in-tree implementation. Each agent and dex-loop execution host requires +//! an explicit `Arc` at construction; host selection is never +//! shared process state. use std::path::Path; -use std::sync::{Arc, OnceLock, RwLock}; +use std::sync::{Arc, RwLock}; use anyhow::Result; use maestro_runtime::agent::{NativeExecutionHostHandle, ToolDefinition}; @@ -28,7 +29,11 @@ pub struct LocalHostBuild<'a> { pub trait ToolHost: Send + Sync { /// Compose the tool executor, hooks, firewall and model resolver for one actor. - fn build_local_host(&self, build: LocalHostBuild<'_>) -> Result; + /// The owned receiver lets composition pass this same host to child agents. + fn build_local_host( + self: Arc, + build: LocalHostBuild<'_>, + ) -> Result; /// Definitions of the built-in tool registry. fn registry_tool_definitions(&self) -> Vec; @@ -41,8 +46,12 @@ pub trait ToolHost: Send + Sync { pub struct BuiltInToolHost; impl ToolHost for BuiltInToolHost { - fn build_local_host(&self, build: LocalHostBuild<'_>) -> Result { + fn build_local_host( + self: Arc, + build: LocalHostBuild<'_>, + ) -> Result { build_local_host( + self, build.config, build.credential_vault, build.skip_identity_verification, @@ -64,18 +73,5 @@ impl ToolHost for BuiltInToolHost { } } -static INSTALLED: OnceLock> = OnceLock::new(); -static BUILT_IN: BuiltInToolHost = BuiltInToolHost; - -/// Replace the in-tree tool implementation. Returns the rejected host when one -/// was already installed. -pub fn install_tool_host(host: Arc) -> Result<(), Arc> { - INSTALLED.set(host) -} - -pub(crate) fn tool_host() -> &'static dyn ToolHost { - match INSTALLED.get() { - Some(host) => host.as_ref(), - None => &BUILT_IN, - } -} +#[cfg(test)] +mod tests; diff --git a/packages/local-host-rs/src/agent/tool_host/tests.rs b/packages/local-host-rs/src/agent/tool_host/tests.rs new file mode 100644 index 000000000..462ace110 --- /dev/null +++ b/packages/local-host-rs/src/agent/tool_host/tests.rs @@ -0,0 +1,159 @@ +use super::*; +use crate::agent::NativeAgent; +use crate::ai::{ScriptedBlock, ScriptedClient, ScriptedResponse, StopReason, UnifiedClient}; +use crate::state::ApprovalMode; +use maestro_runtime::agent::FromAgent; +use serde_json::json; +use std::sync::Mutex; +use std::time::Duration; + +/// Give each host its own executor root while retaining the real admission, +/// hooks and receipt implementation. +struct RootedToolHost { + cwd: String, + identity_verification_skipped: Mutex>, +} + +impl ToolHost for RootedToolHost { + fn build_local_host( + self: Arc, + build: LocalHostBuild<'_>, + ) -> Result { + self.identity_verification_skipped + .lock() + .unwrap() + .push(build.skip_identity_verification); + let config = NativeAgentConfig { + cwd: self.cwd.clone(), + ..build.config.clone() + }; + super::super::build_local_host( + self, + &config, + build.credential_vault, + build.skip_identity_verification, + build.subagent_parent_scope_id, + build.mailbox_identity, + build.telemetry_identity_scope, + ) + } + + fn registry_tool_definitions(&self) -> Vec { + BuiltInToolHost.registry_tool_definitions() + } + + fn executor_tool_definitions(&self, cwd: &Path) -> Vec { + BuiltInToolHost.executor_tool_definitions(cwd) + } +} + +fn scripted_client(model: &str) -> UnifiedClient { + UnifiedClient::Scripted(ScriptedClient::new( + model, + vec![ + ScriptedResponse { + blocks: vec![ScriptedBlock::ToolUse { + id: "read-marker".to_owned(), + name: "read".to_owned(), + input: json!({"path": "marker.txt"}), + }], + stop_reason: StopReason::ToolUse, + error: None, + }, + ScriptedResponse::text("done"), + ], + )) +} + +async fn read_marker( + agent: &NativeAgent, + events: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> String { + agent + .prompt("read the marker".to_owned(), vec![]) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + let mut output = None; + loop { + match events.recv().await.expect("agent event") { + FromAgent::ToolEnd { + success, + result: Some(result), + .. + } => { + assert!(success, "{result:?}"); + output = Some(result.output); + } + FromAgent::TurnCompleted { .. } => return output.expect("tool result"), + FromAgent::Error { message, .. } | FromAgent::ProviderError { message, .. } => { + panic!("{message}") + } + _ => {} + } + } + }) + .await + .expect("marker turn") +} + +#[tokio::test] +async fn agents_use_independent_tool_hosts_in_one_process() { + let _env = crate::config::test_process_env_lock_async().await; + let first_root = tempfile::tempdir().unwrap(); + let second_root = tempfile::tempdir().unwrap(); + std::fs::write(first_root.path().join("marker.txt"), "first-host-marker").unwrap(); + std::fs::write(second_root.path().join("marker.txt"), "second-host-marker").unwrap(); + let first_host = Arc::new(RootedToolHost { + cwd: first_root.path().display().to_string(), + identity_verification_skipped: Mutex::new(Vec::new()), + }); + let second_host = Arc::new(RootedToolHost { + cwd: second_root.path().display().to_string(), + identity_verification_skipped: Mutex::new(Vec::new()), + }); + let config = NativeAgentConfig { + model: "scripted-replay/maestro-replay-v1".to_owned(), + cwd: first_root.path().display().to_string(), + approval_mode: ApprovalMode::Yolo, + ..NativeAgentConfig::default() + }; + let (first, mut first_events) = NativeAgent::new_with_test_client( + first_host.clone(), + config.clone(), + scripted_client(&config.model), + ) + .unwrap(); + let (second, mut second_events) = NativeAgent::new_with_test_client( + second_host.clone(), + config.clone(), + scripted_client(&config.model), + ) + .unwrap(); + let (first_output, second_output) = tokio::join!( + read_marker(&first, &mut first_events), + read_marker(&second, &mut second_events), + ); + first.shutdown().await; + second.shutdown().await; + assert!(first_output.contains("first-host-marker")); + assert!(!first_output.contains("second-host-marker")); + assert!(second_output.contains("second-host-marker")); + assert!(!second_output.contains("first-host-marker")); + assert_eq!( + *first_host.identity_verification_skipped.lock().unwrap(), + [true] + ); + assert_eq!( + *second_host.identity_verification_skipped.lock().unwrap(), + [true] + ); + + // The dex-loop construction path keeps Identity verification enabled. + crate::agent::dex_loop_execution_host(first_host.clone(), &config, CredentialVault::new()) + .unwrap(); + assert_eq!( + *first_host.identity_verification_skipped.lock().unwrap(), + [true, false] + ); +} diff --git a/packages/local-host-rs/src/codex_cli.rs b/packages/local-host-rs/src/codex_cli.rs index e2723b88d..86b79a8d3 100644 --- a/packages/local-host-rs/src/codex_cli.rs +++ b/packages/local-host-rs/src/codex_cli.rs @@ -972,7 +972,8 @@ async fn handle_doctor(params: &[String]) -> Result { } let cwd = std::env::current_dir()?; - let dynamic_tools = runtime_codex_dynamic_tools(&cwd); + let dynamic_tools = + runtime_codex_dynamic_tools(&crate::agent::tool_host::BuiltInToolHost, &cwd); let diagnostics = codex_dynamic_tool_schema_diagnostics(&dynamic_tools); println!( "{}", @@ -1297,12 +1298,15 @@ pub fn codex_schema_diagnostics() -> Result> { fn codex_schema_diagnostics_for_cwd(cwd: &Path) -> Result> { Ok(codex_dynamic_tool_schema_diagnostics( - &runtime_codex_dynamic_tools(cwd), + &runtime_codex_dynamic_tools(&crate::agent::tool_host::BuiltInToolHost, cwd), )) } -fn runtime_codex_dynamic_tools(cwd: &Path) -> Vec { - let tools = crate::agent::tool_host::tool_host() +fn runtime_codex_dynamic_tools( + tool_host: &dyn crate::agent::tool_host::ToolHost, + cwd: &Path, +) -> Vec { + let tools = tool_host .executor_tool_definitions(cwd) .into_iter() .map(|definition| (definition.tool.name.clone(), definition)) diff --git a/packages/local-host-rs/src/embedding.rs b/packages/local-host-rs/src/embedding.rs index a31bfe28b..084e6d966 100644 --- a/packages/local-host-rs/src/embedding.rs +++ b/packages/local-host-rs/src/embedding.rs @@ -348,7 +348,11 @@ impl EmbeddedAgentBuilder { /// Compose the local host and start its native actor. pub fn start(self) -> Result { self.validate()?; - let (agent, events) = NativeAgent::new_with_tools(self.config, self.external_tools)?; + let (agent, events) = NativeAgent::new_with_tools( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + self.config, + self.external_tools, + )?; Ok(EmbeddedAgentSession { agent: EmbeddedAgent { inner: agent }, events, @@ -364,7 +368,11 @@ impl EmbeddedAgentBuilder { pub fn start_runner(self) -> Result { self.validate_runner_limits()?; let external_tools = external_tool_names(&self.external_tools); - let (agent, events) = NativeAgent::new_with_tools(self.config, self.external_tools)?; + let (agent, events) = NativeAgent::new_with_tools( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + self.config, + self.external_tools, + )?; Ok(EmbeddedAgentRunner { agent: Some(agent), events, @@ -406,8 +414,12 @@ impl EmbeddedAgentBuilder { client: crate::ai::UnifiedClient, ) -> Result { self.validate()?; - let (agent, events) = - NativeAgent::new_with_tools_and_test_client(self.config, self.external_tools, client)?; + let (agent, events) = NativeAgent::new_with_tools_and_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + self.config, + self.external_tools, + client, + )?; Ok(EmbeddedAgentSession { agent: EmbeddedAgent { inner: agent }, events, @@ -421,8 +433,12 @@ impl EmbeddedAgentBuilder { ) -> Result { self.validate_runner_limits()?; let external_tools = external_tool_names(&self.external_tools); - let (agent, events) = - NativeAgent::new_with_tools_and_test_client(self.config, self.external_tools, client)?; + let (agent, events) = NativeAgent::new_with_tools_and_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + self.config, + self.external_tools, + client, + )?; Ok(EmbeddedAgentRunner { agent: Some(agent), events, diff --git a/packages/local-host-rs/src/headless/mod.rs b/packages/local-host-rs/src/headless/mod.rs index 76b3b15ac..38bdb1a8f 100644 --- a/packages/local-host-rs/src/headless/mod.rs +++ b/packages/local-host-rs/src/headless/mod.rs @@ -236,8 +236,10 @@ fn local_controller_capabilities() -> messages::ClientCapabilities { /// approval metadata. A client must not recreate this list or infer a /// per-call decision from it; the native tool event remains authoritative for /// argument-aware approval and action-firewall outcomes. -pub fn native_server_capabilities() -> messages::ServerCapabilities { - let mut native_tools = crate::agent::tool_host::tool_host() +pub fn native_server_capabilities( + tool_host: &dyn crate::agent::tool_host::ToolHost, +) -> messages::ServerCapabilities { + let mut native_tools = tool_host .registry_tool_definitions() .into_iter() .map(|definition| { diff --git a/packages/local-host-rs/src/headless_server.rs b/packages/local-host-rs/src/headless_server.rs index ce47625e9..c63740ea3 100644 --- a/packages/local-host-rs/src/headless_server.rs +++ b/packages/local-host-rs/src/headless_server.rs @@ -70,6 +70,7 @@ use crate::semantic_text::{ mod agent_config; mod managed_authorization; mod semantic_stream; +mod state_init; /// Test-only provider override for the local SDK conformance fixture. /// @@ -226,6 +227,7 @@ impl RuntimeMeta { } struct HeadlessState { + tool_host: Arc, model: String, model_capabilities: maestro_runtime::agent::NativeModelCapabilities, cwd: String, @@ -269,71 +271,6 @@ struct UtilityCommandOptions { } impl HeadlessState { - fn new(model_override: Option) -> Self { - let model = resolve_headless_model(model_override, &std::env::vars().collect()); - let cwd = std::env::current_dir() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| ".".to_string()); - let system_prompt = format!( - "You are Deixic Code, an AI coding assistant. Working directory: {cwd}. Be concise and use tools when helpful." - ); - let session_id = env_session_id(); - Self { - model_capabilities: crate::agent::catalog_model_capabilities(&model), - model, - cwd, - system_prompt: system_prompt.clone(), - thinking_enabled: false, - thinking_budget: 10_000, - credential_vault: CredentialVault::new(), - history: None, - init_applied: false, - governed_grant: None, - controller_binding_sha256: None, - controller_binding: None, - workspace_capabilities: - crate::headless::workspace_capabilities::WorkspaceCapabilityActivation::new( - system_prompt.clone(), - ), - next_prompt_queue_id: 1, - ready_emitted: false, - meta: Arc::new(Mutex::new(RuntimeMeta { - session_id, - approval_mode: None, - tool_execution_ids: HashMap::new(), - decided_tool_execution_ids: HashSet::new(), - pending_tool_calls: HashSet::new(), - client_tool_bindings: HashMap::new(), - pending_client_tools: HashMap::new(), - emitted_client_tool_terminals: HashSet::new(), - queued_workspace_receipts: HashMap::new(), - conversation_snapshot: None, - process_budget: None, - turn_active: false, - transcript_grade: crate::transcript::TranscriptGrade::Delta, - response_chunks: Vec::new(), - semantic_text: SemanticTextRelease::default(), - semantic_response_id: None, - semantic_started_at: None, - semantic_raw_first_ms: None, - semantic_first_released_ms: None, - semantic_published_chunks: 0, - semantic_standalone_releases: 0, - semantic_forced_flushes: 0, - semantic_peak_held_bytes: 0, - semantic_total_hold_ms: 0, - managed_gateway_receipt: None, - prompt_experiment: None, - receipt_tasks: Arc::new(Mutex::new(Vec::new())), - })), - agent: None, - tool_tx: None, - event_task: None, - utility_commands: HashMap::new(), - file_watches: HashMap::new(), - } - } - fn session_id(&self) -> Option { self.meta .lock() @@ -466,6 +403,7 @@ impl HeadlessState { let (agent, mut event_rx) = if let Some(grant) = self.governed_grant.as_ref() { let (allowed_tools, external_tools, bindings) = governed_agent_inputs(grant)?; let created = NativeAgent::new_with_governed_tools_and_credential_vault( + Arc::clone(&self.tool_host), config, &allowed_tools, external_tools, @@ -485,16 +423,24 @@ impl HeadlessState { .clear(); #[cfg(feature = "test-support")] if let Some(client) = LOCAL_SDK_CONFORMANCE_CLIENT.get().cloned() { - NativeAgent::new_with_test_client(config, client).context( + NativeAgent::new_with_test_client(Arc::clone(&self.tool_host), config, client).context( "Failed to create scripted native agent for local SDK conformance", )? } else { - NativeAgent::new_with_credential_vault(config, self.credential_vault.clone()) - .context("Failed to create native agent for headless server")? + NativeAgent::new_with_credential_vault( + Arc::clone(&self.tool_host), + config, + self.credential_vault.clone(), + ) + .context("Failed to create native agent for headless server")? } #[cfg(not(feature = "test-support"))] - NativeAgent::new_with_credential_vault(config, self.credential_vault.clone()) - .context("Failed to create native agent for headless server")? + NativeAgent::new_with_credential_vault( + Arc::clone(&self.tool_host), + config, + self.credential_vault.clone(), + ) + .context("Failed to create native agent for headless server")? }; // Apply any seeded multi-turn history before the first prompt. let conversation_snapshot = self @@ -1616,7 +1562,10 @@ fn headless_startup_error_message(error: &anyhow::Error) -> String { pub async fn run_headless_server(model_override: Option) -> Result { crate::safety::disconnected_policy().map_err(anyhow::Error::msg)?; let _telemetry = init_headless_tracing(); - let mut state = HeadlessState::new(model_override); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + model_override, + ); prepare_headless_local_model_with( &state.model, crate::local_models::discover_local_model(&state.model), @@ -1760,7 +1709,7 @@ pub async fn run_headless_server(model_override: Option) -> Result client_protocol_version: protocol_version, client_info, capabilities: client_capabilities, - server_capabilities: Some(native_server_capabilities()), + server_capabilities: Some(native_server_capabilities(state.tool_host.as_ref())), opt_out_notifications, role, controller_connection_id: None, @@ -5690,7 +5639,10 @@ else if(x.method==='turn/interrupt'){send({id:x.id,result:{}});send({method:'tur let log = std::path::PathBuf::from( std::env::var("MAESTRO_STAGED_ROTATION_LOG").expect("fixture provider log"), ); - let mut state = HeadlessState::new(Some("openai-codex/gpt-5.5".to_string())); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + Some("openai-codex/gpt-5.5".to_string()), + ); state.cwd = workspace.to_string_lossy().into_owned(); state.system_prompt = "base prompt".to_string(); state @@ -7087,7 +7039,10 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: #[test] fn candidate_prompt_experiment_reaches_provider_bound_system_prompt() { let artifact_content = "When debugging, state the causal path before proposing a fix."; - let mut state = HeadlessState::new(Some("gpt-test".to_string())); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + Some("gpt-test".to_string()), + ); apply_init_settings( &mut state, Some("base prompt".to_string()), @@ -7133,7 +7088,10 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: #[test] fn control_prompt_experiment_leaves_provider_bound_system_prompt_unchanged() { let artifact_content = "When debugging, state the causal path before proposing a fix."; - let mut state = HeadlessState::new(Some("gpt-test".to_string())); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + Some("gpt-test".to_string()), + ); apply_init_settings( &mut state, Some("base prompt".to_string()), @@ -7209,6 +7167,7 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: )], ); let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "scripted/process-cost-wire".into(), cwd: workspace.path().display().to_string(), @@ -7225,7 +7184,10 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: cost_micros_per_token: 1, }; let budget = agent.install_process_budget(limits, None).await.unwrap(); - let mut state = HeadlessState::new(Some("scripted/process-cost-wire".into())); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + Some("scripted/process-cost-wire".into()), + ); { let mut meta = state.meta.lock().unwrap(); meta.process_budget = Some(Arc::clone(&budget)); @@ -7364,6 +7326,7 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: vec![crate::ai::ScriptedResponse::text("ordinary reply")], ); let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "scripted/process-rotation".into(), cwd: workspace.path().display().to_string(), @@ -7399,7 +7362,10 @@ else if(x.method==="turn/start"){const turnId="turn-"+x.id;send({id:x.id,result: spent.admit_request().unwrap(); spent.observe_usage(5, 0, None).unwrap(); } - let mut state = HeadlessState::new(Some("scripted/process-rotation".into())); + let mut state = HeadlessState::new( + Arc::new(crate::agent::tool_host::BuiltInToolHost), + Some("scripted/process-rotation".into()), + ); state.agent = Some(agent); state.governed_grant = Some(process.clone()); state.system_prompt = "PROCESS ONLY".into(); diff --git a/packages/local-host-rs/src/headless_server/state_init.rs b/packages/local-host-rs/src/headless_server/state_init.rs new file mode 100644 index 000000000..56710c5f2 --- /dev/null +++ b/packages/local-host-rs/src/headless_server/state_init.rs @@ -0,0 +1,74 @@ +//! Initial state for the selected headless execution host. + +use super::*; + +impl HeadlessState { + pub(super) fn new( + tool_host: Arc, + model_override: Option, + ) -> Self { + let model = resolve_headless_model(model_override, &std::env::vars().collect()); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| ".".to_string()); + let system_prompt = format!( + "You are Deixic Code, an AI coding assistant. Working directory: {cwd}. Be concise and use tools when helpful." + ); + let session_id = env_session_id(); + Self { + tool_host, + model_capabilities: crate::agent::catalog_model_capabilities(&model), + model, + cwd, + system_prompt: system_prompt.clone(), + thinking_enabled: false, + thinking_budget: 10_000, + credential_vault: CredentialVault::new(), + history: None, + init_applied: false, + governed_grant: None, + controller_binding_sha256: None, + controller_binding: None, + workspace_capabilities: + crate::headless::workspace_capabilities::WorkspaceCapabilityActivation::new( + system_prompt.clone(), + ), + next_prompt_queue_id: 1, + ready_emitted: false, + meta: Arc::new(Mutex::new(RuntimeMeta { + session_id, + approval_mode: None, + tool_execution_ids: HashMap::new(), + decided_tool_execution_ids: HashSet::new(), + pending_tool_calls: HashSet::new(), + client_tool_bindings: HashMap::new(), + pending_client_tools: HashMap::new(), + emitted_client_tool_terminals: HashSet::new(), + queued_workspace_receipts: HashMap::new(), + conversation_snapshot: None, + process_budget: None, + turn_active: false, + transcript_grade: crate::transcript::TranscriptGrade::Delta, + response_chunks: Vec::new(), + semantic_text: SemanticTextRelease::default(), + semantic_response_id: None, + semantic_started_at: None, + semantic_raw_first_ms: None, + semantic_first_released_ms: None, + semantic_published_chunks: 0, + semantic_standalone_releases: 0, + semantic_forced_flushes: 0, + semantic_peak_held_bytes: 0, + semantic_total_hold_ms: 0, + managed_gateway_receipt: None, + prompt_experiment: None, + receipt_tasks: Arc::new(Mutex::new(Vec::new())), + })), + agent: None, + tool_tx: None, + event_task: None, + utility_commands: HashMap::new(), + file_watches: HashMap::new(), + } + } +} diff --git a/packages/local-host-rs/src/headless_server/workspace_observation_tests.rs b/packages/local-host-rs/src/headless_server/workspace_observation_tests.rs index 2a7aab68c..dfcf96319 100644 --- a/packages/local-host-rs/src/headless_server/workspace_observation_tests.rs +++ b/packages/local-host-rs/src/headless_server/workspace_observation_tests.rs @@ -2,7 +2,8 @@ use super::*; #[test] fn native_server_capabilities_match_the_registry_and_request_surface() { - let capabilities = crate::headless::native_server_capabilities(); + let capabilities = + crate::headless::native_server_capabilities(&crate::agent::tool_host::BuiltInToolHost); assert_eq!( capabilities.utility_operations, vec![ diff --git a/packages/local-host-rs/src/safety/policy.rs b/packages/local-host-rs/src/safety/policy.rs index 31d666221..33ecae655 100644 --- a/packages/local-host-rs/src/safety/policy.rs +++ b/packages/local-host-rs/src/safety/policy.rs @@ -2144,6 +2144,7 @@ fn is_private_ip(ip: &IpAddr) -> bool { #[cfg(test)] mod tests { mod deployment_tests; + mod native_admission; use crate::safety::disconnected_deployment_contract; use super::*; @@ -2413,44 +2414,6 @@ mod tests { ); } - #[tokio::test(flavor = "current_thread")] - async fn disconnected_native_and_subagent_admission_preserve_policy() { - let _lock = test_env_guard(); - let fixture = MachineFixture::new(); - fixture.install(private_profile()); - let config = crate::agent::NativeAgentConfig { - model: "ollama/customer-model".into(), - cwd: fixture.directory.path().to_string_lossy().into_owned(), - ..Default::default() - }; - let (agent, _) = - crate::agent::NativeAgent::new(config.clone()).expect("private native agent"); - let (child, _) = crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( - config.clone(), - crate::agent::CredentialVault::new(), - "parent-scope".into(), - ) - .expect("private child agent"); - drop(child); - drop(agent); - let forbidden = crate::agent::NativeAgentConfig { - model: "evalops/default".into(), - ..config.clone() - }; - assert!(crate::agent::NativeAgent::new(forbidden.clone()).is_err()); - assert!( - crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( - forbidden, - crate::agent::CredentialVault::new(), - "parent-scope".into() - ) - .is_err() - ); - let injected = - crate::ai::UnifiedClient::OpenAI(crate::ai::OpenAiClient::new("fixture").unwrap()); - assert!(crate::agent::NativeAgent::new_with_client(config, injected).is_err()); - } - #[test] fn disconnected_managed_setup_never_fetches_or_reopens_enrolled_mcp() { let _lock = test_env_guard(); diff --git a/packages/local-host-rs/src/safety/policy/tests/native_admission.rs b/packages/local-host-rs/src/safety/policy/tests/native_admission.rs new file mode 100644 index 000000000..76995a6e6 --- /dev/null +++ b/packages/local-host-rs/src/safety/policy/tests/native_admission.rs @@ -0,0 +1,57 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn disconnected_native_and_subagent_admission_preserve_policy() { + let _lock = test_env_guard(); + let fixture = MachineFixture::new(); + fixture.install(private_profile()); + let config = crate::agent::NativeAgentConfig { + model: "ollama/customer-model".into(), + cwd: fixture.directory.path().to_string_lossy().into_owned(), + ..Default::default() + }; + let (agent, _) = crate::agent::NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config.clone(), + ) + .expect("private native agent"); + let (child, _) = crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config.clone(), + crate::agent::CredentialVault::new(), + "parent-scope".into(), + ) + .expect("private child agent"); + drop(child); + drop(agent); + let forbidden = crate::agent::NativeAgentConfig { + model: "evalops/default".into(), + ..config.clone() + }; + assert!( + crate::agent::NativeAgent::new( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + forbidden.clone() + ) + .is_err() + ); + assert!( + crate::agent::NativeAgent::new_with_credential_vault_and_subagent_scope( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + forbidden, + crate::agent::CredentialVault::new(), + "parent-scope".into() + ) + .is_err() + ); + let injected = + crate::ai::UnifiedClient::OpenAI(crate::ai::OpenAiClient::new("fixture").unwrap()); + assert!( + crate::agent::NativeAgent::new_with_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), + config, + injected + ) + .is_err() + ); +} diff --git a/packages/local-host-rs/src/subagents/factory.rs b/packages/local-host-rs/src/subagents/factory.rs index 770ec5721..a209c676d 100644 --- a/packages/local-host-rs/src/subagents/factory.rs +++ b/packages/local-host-rs/src/subagents/factory.rs @@ -1,8 +1,10 @@ //! Native child construction, independent of mailbox and journal management. +use crate::agent::tool_host::ToolHost; use crate::agent::{CredentialVault, FromAgent, NativeAgentConfig}; use maestro_runtime::agent::NativeAgent; use std::collections::HashSet; +use std::sync::Arc; use tokio::sync::mpsc; /// Already-resolved local child configuration. The lifecycle owner retains @@ -24,7 +26,15 @@ pub trait ChildAgentFactory: Send + Sync { } /// Standard local tools, hooks, credentials, and policy for native children. -pub struct LocalChildAgentFactory; +pub struct LocalChildAgentFactory { + tool_host: Arc, +} + +impl LocalChildAgentFactory { + pub fn new(tool_host: Arc) -> Self { + Self { tool_host } + } +} impl ChildAgentFactory for LocalChildAgentFactory { fn spawn( @@ -32,6 +42,7 @@ impl ChildAgentFactory for LocalChildAgentFactory { request: ChildLaunchRequest, ) -> anyhow::Result<(NativeAgent, mpsc::UnboundedReceiver)> { crate::agent::NativeAgent::new_with_allowed_tools_and_credential_vault_runner( + Arc::clone(&self.tool_host), request.config, &request.allowed_tools, request.credential_vault, diff --git a/packages/local-host-rs/src/subagents/lifecycle.rs b/packages/local-host-rs/src/subagents/lifecycle.rs index d3210eff7..5555e0e06 100644 --- a/packages/local-host-rs/src/subagents/lifecycle.rs +++ b/packages/local-host-rs/src/subagents/lifecycle.rs @@ -174,8 +174,11 @@ impl SubagentManager { retry_config: crate::agent::retry::RetryConfig::default(), external_tool_schema_policy: crate::agent::ExternalToolSchemaPolicy::Eager, }; - let allowed_tools = - child_allowed_tools_for_role(record.role, record.profile_tools.as_deref()); + let allowed_tools = child_allowed_tools_for_role( + self.tool_host.as_ref(), + record.role, + record.profile_tools.as_deref(), + ); let (agent, mut events) = match self.child_factory.spawn(ChildLaunchRequest { config, allowed_tools, diff --git a/packages/local-host-rs/src/subagents/lifetime_tests.rs b/packages/local-host-rs/src/subagents/lifetime_tests.rs index 8fbf5d158..ccaecd26c 100644 --- a/packages/local-host-rs/src/subagents/lifetime_tests.rs +++ b/packages/local-host-rs/src/subagents/lifetime_tests.rs @@ -34,6 +34,7 @@ impl ChildAgentFactory for LifetimeFactory { ); let hook_log = PathBuf::from(&request.config.cwd).join("child-hook-events.log"); let (agent, mut source) = crate::agent::NativeAgent::new_with_test_client( + std::sync::Arc::new(crate::agent::tool_host::BuiltInToolHost), request.config, UnifiedClient::Scripted(self.client.clone()), )?; diff --git a/packages/local-host-rs/src/subagents/mod.rs b/packages/local-host-rs/src/subagents/mod.rs index 27f532f70..a95476306 100644 --- a/packages/local-host-rs/src/subagents/mod.rs +++ b/packages/local-host-rs/src/subagents/mod.rs @@ -13,6 +13,8 @@ use maestro_runtime_contracts::tool_wire; pub mod factory; mod handoff; mod lifecycle; +mod tool_host; +use tool_host::child_allowed_tools_for_role; #[cfg(test)] mod lifetime_tests; @@ -923,6 +925,7 @@ pub(crate) struct SubagentManager { parent_requests: Arc>>, runtime: Arc, child_factory: Arc, + tool_host: Arc, mailbox_path: PathBuf, last_lifecycle_poll: Arc>, last_lifecycle_reconciliation: Arc>, @@ -1035,7 +1038,10 @@ impl SubagentManager { parent_model: Arc::new(Mutex::new(None)), parent_requests: Arc::new(Mutex::new(Vec::new())), runtime: runtime_registry(), - child_factory: Arc::new(LocalChildAgentFactory), + child_factory: Arc::new(LocalChildAgentFactory::new(Arc::new( + crate::agent::tool_host::BuiltInToolHost, + ))), + tool_host: Arc::new(crate::agent::tool_host::BuiltInToolHost), mailbox_path, last_lifecycle_poll: Arc::new(Mutex::new( Instant::now() @@ -4865,61 +4871,6 @@ fn parse_wait_timeout(args: &serde_json::Value) -> Result { .ok_or_else(|| "timeout_ms must be a non-negative integer".to_string()) } -const READ_ONLY_CHILD_TOOLS: [&str; 21] = [ - "read", - "glob", - "grep", - "list", - "diff", - "status", - "parallel_ripgrep", - "search", - "web_fetch", - "websearch", - "codesearch", - "read_image", - "screenshot", - "vscode_get_diagnostics", - "jetbrains_get_diagnostics", - "vscode_get_definition", - "jetbrains_get_definition", - "vscode_find_references", - "jetbrains_find_references", - "mcp_list_resources", - "mcp_read_resource", -]; - -fn child_allowed_tools_for_role( - role: SubagentRole, - profile_tools: Option<&[String]>, -) -> HashSet { - let mut tools = crate::agent::tool_host::tool_host() - .registry_tool_definitions() - .into_iter() - .filter(|definition| { - let name = definition.tool.name.to_ascii_lowercase(); - let globally_allowed = !SUBAGENT_TOOL_NAMES.contains(&name.as_str()) - && !matches!( - name.as_str(), - "get_goal" | "update_goal" | "todo" | "background_tasks" | "coding_task" - ); - let role_allowed = matches!(role, SubagentRole::Code) - || READ_ONLY_CHILD_TOOLS.contains(&name.as_str()); - globally_allowed && role_allowed - }) - .map(|definition| definition.tool.name.to_ascii_lowercase()) - .collect::>(); - - if let Some(profile_tools) = profile_tools { - let requested = profile_tools - .iter() - .map(|name| name.to_ascii_lowercase()) - .collect::>(); - tools.retain(|name| requested.contains(name)); - } - tools -} - fn git_worktree_command( cwd: &Path, args: [&str; N], @@ -9464,7 +9415,11 @@ mod tests { #[test] fn child_tool_set_excludes_parent_global_tools() { - let tools = child_allowed_tools_for_role(SubagentRole::Code, None); + let tools = child_allowed_tools_for_role( + &crate::agent::tool_host::BuiltInToolHost, + SubagentRole::Code, + None, + ); for name in SUBAGENT_TOOL_NAMES { assert!(!tools.contains(name), "child advertised {name}"); } @@ -9482,7 +9437,8 @@ mod tests { SubagentRole::Plan, SubagentRole::Review, ] { - let tools = child_allowed_tools_for_role(role, None); + let tools = + child_allowed_tools_for_role(&crate::agent::tool_host::BuiltInToolHost, role, None); assert!(tools.contains("read"), "{role:?} should read files"); assert!(tools.contains("grep"), "{role:?} should search files"); assert!( @@ -9493,7 +9449,11 @@ mod tests { assert!(!tools.contains("edit"), "{role:?} must not edit files"); } - let code_tools = child_allowed_tools_for_role(SubagentRole::Code, None); + let code_tools = child_allowed_tools_for_role( + &crate::agent::tool_host::BuiltInToolHost, + SubagentRole::Code, + None, + ); assert!(code_tools.contains("bash")); assert!(code_tools.contains("write")); assert!(code_tools.contains("edit")); @@ -9518,7 +9478,11 @@ mod tests { resolve_spawn_profile_with_trust(&mut request, root.path(), true).unwrap(); assert_eq!(request.model.as_deref(), Some("small-model")); assert_eq!( - child_allowed_tools_for_role(request.role, request.profile_tools.as_deref()), + child_allowed_tools_for_role( + &crate::agent::tool_host::BuiltInToolHost, + request.role, + request.profile_tools.as_deref() + ), HashSet::from(["read".to_string()]) ); let mut explicit = parse_spawn_request(&serde_json::json!({ @@ -9532,10 +9496,18 @@ mod tests { #[test] fn profile_tools_only_narrow_a_role_policy() { let requested = ["read".to_string(), "write".to_string(), "bash".to_string()]; - let explore_tools = child_allowed_tools_for_role(SubagentRole::Explore, Some(&requested)); + let explore_tools = child_allowed_tools_for_role( + &crate::agent::tool_host::BuiltInToolHost, + SubagentRole::Explore, + Some(&requested), + ); assert_eq!(explore_tools, HashSet::from(["read".to_string()])); - let code_tools = child_allowed_tools_for_role(SubagentRole::Code, Some(&requested)); + let code_tools = child_allowed_tools_for_role( + &crate::agent::tool_host::BuiltInToolHost, + SubagentRole::Code, + Some(&requested), + ); assert_eq!( code_tools, HashSet::from(["read".to_string(), "write".to_string(), "bash".to_string()]) diff --git a/packages/local-host-rs/src/subagents/tool_host.rs b/packages/local-host-rs/src/subagents/tool_host.rs new file mode 100644 index 000000000..b244f64bf --- /dev/null +++ b/packages/local-host-rs/src/subagents/tool_host.rs @@ -0,0 +1,74 @@ +//! Child host inheritance and role-specific narrowing of its tool catalog. + +use super::*; + +impl SubagentManager { + pub(crate) fn with_tool_host( + mut self, + tool_host: Arc, + ) -> Self { + self.child_factory = Arc::new(LocalChildAgentFactory::new(Arc::clone(&tool_host))); + self.tool_host = tool_host; + self + } + + pub(crate) fn tool_host(&self) -> Arc { + Arc::clone(&self.tool_host) + } +} + +const READ_ONLY_CHILD_TOOLS: [&str; 21] = [ + "read", + "glob", + "grep", + "list", + "diff", + "status", + "parallel_ripgrep", + "search", + "web_fetch", + "websearch", + "codesearch", + "read_image", + "screenshot", + "vscode_get_diagnostics", + "jetbrains_get_diagnostics", + "vscode_get_definition", + "jetbrains_get_definition", + "vscode_find_references", + "jetbrains_find_references", + "mcp_list_resources", + "mcp_read_resource", +]; + +pub(super) fn child_allowed_tools_for_role( + tool_host: &dyn crate::agent::tool_host::ToolHost, + role: SubagentRole, + profile_tools: Option<&[String]>, +) -> HashSet { + let mut tools = tool_host + .registry_tool_definitions() + .into_iter() + .filter(|definition| { + let name = definition.tool.name.to_ascii_lowercase(); + let globally_allowed = !SUBAGENT_TOOL_NAMES.contains(&name.as_str()) + && !matches!( + name.as_str(), + "get_goal" | "update_goal" | "todo" | "background_tasks" | "coding_task" + ); + let role_allowed = matches!(role, SubagentRole::Code) + || READ_ONLY_CHILD_TOOLS.contains(&name.as_str()); + globally_allowed && role_allowed + }) + .map(|definition| definition.tool.name.to_ascii_lowercase()) + .collect::>(); + + if let Some(profile_tools) = profile_tools { + let requested = profile_tools + .iter() + .map(|name| name.to_ascii_lowercase()) + .collect::>(); + tools.retain(|name| requested.contains(name)); + } + tools +} diff --git a/packages/local-host-rs/src/tools/registry.rs b/packages/local-host-rs/src/tools/registry.rs index 81fc0bcc8..3ea288f40 100644 --- a/packages/local-host-rs/src/tools/registry.rs +++ b/packages/local-host-rs/src/tools/registry.rs @@ -1354,24 +1354,6 @@ impl ToolExecutor { self.credential_vault.generation() } - /// Supply child construction while retaining the shared lifecycle owner. - pub fn with_child_agent_factory( - mut self, - factory: Arc, - ) -> Self { - self.subagents = Arc::new((*self.subagents).clone().with_child_agent_factory(factory)); - self - } - - pub(crate) fn with_subagent_parent_scope(mut self, parent_scope_id: String) -> Self { - let factory = self.subagents.child_agent_factory(); - self.subagents = Arc::new( - SubagentManager::with_parent_scope(PathBuf::from(&self.cwd), parent_scope_id) - .with_child_agent_factory(factory), - ); - self - } - pub(crate) fn with_mailbox_identity(mut self, identity: impl Into) -> Self { self.mailbox_identity = identity.into(); self @@ -2987,6 +2969,7 @@ mod background; mod coding_task; mod execute; mod mcp_output; +mod subagent_host; mod tool_registry; pub use tool_registry::ToolRegistry; #[cfg(test)] diff --git a/packages/local-host-rs/src/tools/registry/subagent_host.rs b/packages/local-host-rs/src/tools/registry/subagent_host.rs new file mode 100644 index 000000000..56b5cf6a7 --- /dev/null +++ b/packages/local-host-rs/src/tools/registry/subagent_host.rs @@ -0,0 +1,34 @@ +//! Preserve selected child-host dependencies when executor scope changes. + +use super::*; + +impl ToolExecutor { + /// Keep child construction and role filtering on the parent agent's host. + pub(crate) fn with_tool_host( + mut self, + tool_host: Arc, + ) -> Self { + self.subagents = Arc::new((*self.subagents).clone().with_tool_host(tool_host)); + self + } + + /// Supply child construction while retaining the shared lifecycle owner. + pub fn with_child_agent_factory( + mut self, + factory: Arc, + ) -> Self { + self.subagents = Arc::new((*self.subagents).clone().with_child_agent_factory(factory)); + self + } + + pub(crate) fn with_subagent_parent_scope(mut self, parent_scope_id: String) -> Self { + let factory = self.subagents.child_agent_factory(); + let tool_host = self.subagents.tool_host(); + self.subagents = Arc::new( + SubagentManager::with_parent_scope(PathBuf::from(&self.cwd), parent_scope_id) + .with_tool_host(tool_host) + .with_child_agent_factory(factory), + ); + self + } +} diff --git a/packages/local-host-rs/src/tools/registry/tests.rs b/packages/local-host-rs/src/tools/registry/tests.rs index b34f97128..ba3499282 100644 --- a/packages/local-host-rs/src/tools/registry/tests.rs +++ b/packages/local-host-rs/src/tools/registry/tests.rs @@ -1,3 +1,4 @@ +mod subagent_host; use super::*; mod explore; @@ -406,49 +407,6 @@ fn repository_symbols_requires_explicit_schema_activation() { ); } -#[test] -fn test_registry_exposes_subagent_lifecycle_tools() { - let registry = ToolRegistry::new(); - - for tool in crate::tools::subagents::SUBAGENT_TOOL_NAMES { - assert!(registry.get(tool).is_some(), "missing tool {tool}"); - } - - assert!(registry.requires_approval( - "spawn_subagent", - &serde_json::json!({"task": "inspect the parser"}) - )); - assert!(registry.requires_approval( - "resume_subagent", - &serde_json::json!({"subagent_id": "child-1", "task": "continue"}) - )); - assert!(registry.requires_approval( - "cleanup_subagent", - &serde_json::json!({"subagent_id": "child-1"}) - )); - assert!( - registry - .missing_required( - "resume_subagent", - &serde_json::json!({ - "subagent_id": "child-1", - "follow_up": "continue" - }) - ) - .is_empty() - ); - for tool in [ - "list_subagents", - "get_subagent", - "wait_subagent", - "cancel_subagent", - "control_subagent", - "inspect_subagent", - ] { - assert!(!registry.requires_approval(tool, &serde_json::json!({"subagent_id": "child-1"}))); - } -} - #[test] fn test_registry_exposes_prime_agent_context_tools() { let registry = ToolRegistry::new(); diff --git a/packages/local-host-rs/src/tools/registry/tests/subagent_host.rs b/packages/local-host-rs/src/tools/registry/tests/subagent_host.rs new file mode 100644 index 000000000..a88539209 --- /dev/null +++ b/packages/local-host-rs/src/tools/registry/tests/subagent_host.rs @@ -0,0 +1,108 @@ +use super::*; + +#[tokio::test] +async fn selected_tool_host_survives_child_scope_rotation_and_factory_launch() { + use crate::agent::tool_host::{LocalHostBuild, ToolHost}; + use crate::agent::{NativeAgentConfig, NativeExecutionHostHandle, ToolDefinition}; + use crate::subagents::factory::ChildLaunchRequest; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct ChildHostProbe(AtomicUsize); + + impl ToolHost for ChildHostProbe { + fn build_local_host( + self: Arc, + build: LocalHostBuild<'_>, + ) -> anyhow::Result { + assert!(!build.skip_identity_verification); + assert_eq!(build.mailbox_identity.as_deref(), Some("child-mailbox")); + self.0.fetch_add(1, Ordering::SeqCst); + anyhow::bail!("selected child tool host") + } + + fn registry_tool_definitions(&self) -> Vec { + Vec::new() + } + + fn executor_tool_definitions(&self, _cwd: &Path) -> Vec { + Vec::new() + } + } + + let _guard = crate::config::test_process_env_lock_async().await; + let root = isolated_tempdir(); + let probe = Arc::new(ChildHostProbe(AtomicUsize::new(0))); + let host: Arc = probe.clone(); + let executor = ToolExecutor::new(root.path().display().to_string()) + .with_tool_host(Arc::clone(&host)) + .with_subagent_parent_scope("new-parent-scope".to_owned()); + assert!(Arc::ptr_eq(&host, &executor.subagents.tool_host())); + assert_eq!(executor.subagent_parent_scope_id(), "new-parent-scope"); + let result = executor + .subagents + .child_agent_factory() + .spawn(ChildLaunchRequest { + config: NativeAgentConfig { + // An explicit local route constructs a client without contacting a + // provider. The production child path still enables verification. + model: "ollama/tool-host-child-test".to_owned(), + cwd: root.path().display().to_string(), + ..NativeAgentConfig::default() + }, + allowed_tools: std::collections::HashSet::new(), + credential_vault: CredentialVault::new(), + mailbox_identity: "child-mailbox".to_owned(), + }); + let error = match result { + Ok(_) => panic!("probe host must stop before starting a child actor"), + Err(error) => error, + }; + assert!( + error.to_string().contains("selected child tool host"), + "{error:#}" + ); + assert_eq!(probe.0.load(Ordering::SeqCst), 1); +} + +#[test] +fn test_registry_exposes_subagent_lifecycle_tools() { + let registry = ToolRegistry::new(); + + for tool in crate::tools::subagents::SUBAGENT_TOOL_NAMES { + assert!(registry.get(tool).is_some(), "missing tool {tool}"); + } + + assert!(registry.requires_approval( + "spawn_subagent", + &serde_json::json!({"task": "inspect the parser"}) + )); + assert!(registry.requires_approval( + "resume_subagent", + &serde_json::json!({"subagent_id": "child-1", "task": "continue"}) + )); + assert!(registry.requires_approval( + "cleanup_subagent", + &serde_json::json!({"subagent_id": "child-1"}) + )); + assert!( + registry + .missing_required( + "resume_subagent", + &serde_json::json!({ + "subagent_id": "child-1", + "follow_up": "continue" + }) + ) + .is_empty() + ); + for tool in [ + "list_subagents", + "get_subagent", + "wait_subagent", + "cancel_subagent", + "control_subagent", + "inspect_subagent", + ] { + assert!(!registry.requires_approval(tool, &serde_json::json!({"subagent_id": "child-1"}))); + } +} diff --git a/packages/runtime-gateway-rs/src/dex_chat.rs b/packages/runtime-gateway-rs/src/dex_chat.rs index ef5913718..298fb8c0e 100644 --- a/packages/runtime-gateway-rs/src/dex_chat.rs +++ b/packages/runtime-gateway-rs/src/dex_chat.rs @@ -99,8 +99,12 @@ async fn compose_for_mode( background_task_access: request.background_task_access, ..NativeAgentConfig::default() }; - let host = dex_loop_execution_host(&config, CredentialVault::new()) - .map_err(|error| error.to_string())?; + let host = dex_loop_execution_host( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + &config, + CredentialVault::new(), + ) + .map_err(|error| error.to_string())?; // Hooks can run commands independently of model tools. Discuss admits // neither boundary; this fresh host is private to this turn. if interaction_mode == crate::chat::InteractionMode::Discuss { @@ -1209,7 +1213,12 @@ mod tests { cwd: workspace.path().to_string_lossy().into_owned(), ..NativeAgentConfig::default() }; - let host = dex_loop_execution_host(&config, CredentialVault::new()).unwrap(); + let host = dex_loop_execution_host( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + &config, + CredentialVault::new(), + ) + .unwrap(); let client_tools = vec![ToolDefinition { tool: Tool::new("client_write", "write the source"), requires_approval: false, diff --git a/packages/tui-rs/benches/agent_loop_bench.rs b/packages/tui-rs/benches/agent_loop_bench.rs index 1a824a7b6..894b307c0 100644 --- a/packages/tui-rs/benches/agent_loop_bench.rs +++ b/packages/tui-rs/benches/agent_loop_bench.rs @@ -24,7 +24,12 @@ async fn run_scripted_session( approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new_with_test_client(config, client).expect("agent"); + let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("agent"); for prompt in prompts { agent.prompt(prompt, vec![]).await.expect("prompt"); diff --git a/packages/tui-rs/src/app.rs b/packages/tui-rs/src/app.rs index 978a9e31c..f524849a1 100644 --- a/packages/tui-rs/src/app.rs +++ b/packages/tui-rs/src/app.rs @@ -2953,137 +2953,6 @@ Always use tools when they would be helpful. Be concise and direct in your respo } } - /// Spawn the native Rust agent - async fn spawn_agent(&mut self) -> Result<()> { - let cwd_path = std::env::current_dir().unwrap_or_default(); - let cwd = cwd_path.to_string_lossy().to_string(); - - // Detect git branch - let git_branch = git::current_branch(&cwd_path); - self.current_git_branch = git_branch.clone(); - - // Prefer a model the user just picked in `/setup` or `/model`. - // Otherwise: MAESTRO_MODEL, then ~/.maestro (and project) model - // settings, then Codex ChatGPT login, then the platform default. - let model = if self.current_model_user_set && !self.current_model.trim().is_empty() { - self.current_model.clone() - } else { - crate::codex_auth::resolve_default_model() - }; - - let (history, session_id, thinking_level) = self.agent_context_for_spawn()?; - let thinking_level = crate::model_dynamics::normalize_thinking(&model, thinking_level); - self.current_thinking_level = thinking_level; - self.state.thinking_level = thinking_level; - let (thinking_enabled, thinking_budget) = thinking_level.to_config(); - let config = self.native_agent_config(&model, &cwd, thinking_enabled, thinking_budget); - - let policy_model = policy_model_id(&model); - if let Some(reason) = check_model_allowed(&policy_model) { - self.state.error = Some(reason); - return Ok(()); - } - - self.current_model = model.clone(); - self.state.thinking_level = thinking_level; - self.usage_tracker.set_model(model.clone()); - - self.state.status = Some(self.state.locale.format( - "Initializing agent ({0})...", - std::slice::from_ref(&(model)), - )); - - let subagent_parent_scope_id = self.tool_executor.subagent_parent_scope_id(); - match NativeAgent::new_with_credential_vault_and_subagent_scope( - config, - self.credential_vault.clone(), - subagent_parent_scope_id, - ) { - Ok((agent, event_rx)) => { - self.restore_request_cache(&agent); - let tool_tx = agent.tool_response_sender(); - self.native_agent = Some(agent); - self.native_event_rx = Some(crate::loop_wake::forward_unbounded( - event_rx, - self.loop_wake.clone(), - )); - self.tool_response_tx = Some(tool_tx); - - // A session restored at startup exists before the runner does, - // so it never passed through any of the transitions that adopt - // a session context. Capture it here and adopt it below. - let restored_session_id = session_id.clone(); - - // Send ready event - if let Some(agent) = &self.native_agent { - agent.set_goal_tools_visible(self.goal_store.tools_visible()); - // Startup session restore runs before the agent exists. Queue - // the copied conversation before any initial prompt so a - // fork continues with the same model context. - let continuation = match self.session_manager.current_session_path() { - Some(path) => crate::session::SessionReader::read_file(&path)? - .compactions - .last() - .and_then(|entry| entry.continuation.clone()), - None => None, - }; - agent.replace_history_with_continuation(history, continuation); - agent.send_ready(); - // Send session info with git branch - agent.send_session_info(&cwd, session_id, git_branch); - let _ = agent.set_steering_mode(self.state.steering_mode); - let _ = agent.set_follow_up_mode(self.state.follow_up_mode); - } - - // `send_session_info` is an outbound event to the UI and sets - // nothing on the runner. Without this the runner kept the tool - // executor's initial random subagent scope and a hook - // `session_id` of `None`, so completions parked for this - // session's own children were never drained and every hook - // payload published a null session. - if let Some(restored_session_id) = restored_session_id { - self.adopt_session_context(Some(&restored_session_id), "restore"); - } - - // Ensure busy is false so user can type - self.state.busy = false; - self.state.model = Some(model.clone()); - self.state.status = Some( - self.state - .locale - .format("Ready: {0}", std::slice::from_ref(&(model))), - ); - self.flush_pending_agent_tool_notes(); - } - Err(e) => { - if should_open_first_run_setup(false, default_model_credentials_ready()) { - self.state.status = Some( - self.state - .locale - .translate("Complete setup to start.") - .to_string(), - ); - } else { - let mut message = self - .state - .locale - .format("Failed to create agent: {0}", &[format!("{e:#}")]); - if crate::codex_auth::read_codex_auth().is_none() - && std::env::var_os("OPENAI_API_KEY").is_none() - && std::env::var_os("OPENAI_CODEX_TOKEN").is_none() - { - message.push_str( - self.state.locale.translate(" — run `deixic-code evalops login` (required), then configure a provider if needed."), - ); - } - self.state.error = Some(message); - } - } - } - - Ok(()) - } - fn agent_context_for_spawn( &mut self, ) -> Result<(Vec, Option, ThinkingLevel)> { @@ -5869,6 +5738,7 @@ fn short_codex_status_id(value: &str) -> String { mod a2a_handoff; mod agent_config; +mod agent_launch; mod bug_reports; mod checkpoints; mod command_handlers; diff --git a/packages/tui-rs/src/app/agent_launch.rs b/packages/tui-rs/src/app/agent_launch.rs new file mode 100644 index 000000000..30ba1be7c --- /dev/null +++ b/packages/tui-rs/src/app/agent_launch.rs @@ -0,0 +1,137 @@ +//! Construct the native actor and restore the current TUI session. + +use super::*; + +impl App { + /// Spawn the native Rust agent + pub(super) async fn spawn_agent(&mut self) -> Result<()> { + let cwd_path = std::env::current_dir().unwrap_or_default(); + let cwd = cwd_path.to_string_lossy().to_string(); + + // Detect git branch + let git_branch = git::current_branch(&cwd_path); + self.current_git_branch = git_branch.clone(); + + // Prefer a model the user just picked in `/setup` or `/model`. + // Otherwise: MAESTRO_MODEL, then ~/.maestro (and project) model + // settings, then Codex ChatGPT login, then the platform default. + let model = if self.current_model_user_set && !self.current_model.trim().is_empty() { + self.current_model.clone() + } else { + crate::codex_auth::resolve_default_model() + }; + + let (history, session_id, thinking_level) = self.agent_context_for_spawn()?; + let thinking_level = crate::model_dynamics::normalize_thinking(&model, thinking_level); + self.current_thinking_level = thinking_level; + self.state.thinking_level = thinking_level; + let (thinking_enabled, thinking_budget) = thinking_level.to_config(); + let config = self.native_agent_config(&model, &cwd, thinking_enabled, thinking_budget); + + let policy_model = policy_model_id(&model); + if let Some(reason) = check_model_allowed(&policy_model) { + self.state.error = Some(reason); + return Ok(()); + } + + self.current_model = model.clone(); + self.state.thinking_level = thinking_level; + self.usage_tracker.set_model(model.clone()); + + self.state.status = Some(self.state.locale.format( + "Initializing agent ({0})...", + std::slice::from_ref(&(model)), + )); + + let subagent_parent_scope_id = self.tool_executor.subagent_parent_scope_id(); + match NativeAgent::new_with_credential_vault_and_subagent_scope( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + self.credential_vault.clone(), + subagent_parent_scope_id, + ) { + Ok((agent, event_rx)) => { + self.restore_request_cache(&agent); + let tool_tx = agent.tool_response_sender(); + self.native_agent = Some(agent); + self.native_event_rx = Some(crate::loop_wake::forward_unbounded( + event_rx, + self.loop_wake.clone(), + )); + self.tool_response_tx = Some(tool_tx); + + // A session restored at startup exists before the runner does, + // so it never passed through any of the transitions that adopt + // a session context. Capture it here and adopt it below. + let restored_session_id = session_id.clone(); + + // Send ready event + if let Some(agent) = &self.native_agent { + agent.set_goal_tools_visible(self.goal_store.tools_visible()); + // Startup session restore runs before the agent exists. Queue + // the copied conversation before any initial prompt so a + // fork continues with the same model context. + let continuation = match self.session_manager.current_session_path() { + Some(path) => crate::session::SessionReader::read_file(&path)? + .compactions + .last() + .and_then(|entry| entry.continuation.clone()), + None => None, + }; + agent.replace_history_with_continuation(history, continuation); + agent.send_ready(); + // Send session info with git branch + agent.send_session_info(&cwd, session_id, git_branch); + let _ = agent.set_steering_mode(self.state.steering_mode); + let _ = agent.set_follow_up_mode(self.state.follow_up_mode); + } + + // `send_session_info` is an outbound event to the UI and sets + // nothing on the runner. Without this the runner kept the tool + // executor's initial random subagent scope and a hook + // `session_id` of `None`, so completions parked for this + // session's own children were never drained and every hook + // payload published a null session. + if let Some(restored_session_id) = restored_session_id { + self.adopt_session_context(Some(&restored_session_id), "restore"); + } + + // Ensure busy is false so user can type + self.state.busy = false; + self.state.model = Some(model.clone()); + self.state.status = Some( + self.state + .locale + .format("Ready: {0}", std::slice::from_ref(&(model))), + ); + self.flush_pending_agent_tool_notes(); + } + Err(e) => { + if should_open_first_run_setup(false, default_model_credentials_ready()) { + self.state.status = Some( + self.state + .locale + .translate("Complete setup to start.") + .to_string(), + ); + } else { + let mut message = self + .state + .locale + .format("Failed to create agent: {0}", &[format!("{e:#}")]); + if crate::codex_auth::read_codex_auth().is_none() + && std::env::var_os("OPENAI_API_KEY").is_none() + && std::env::var_os("OPENAI_CODEX_TOKEN").is_none() + { + message.push_str( + self.state.locale.translate(" — run `deixic-code evalops login` (required), then configure a provider if needed."), + ); + } + self.state.error = Some(message); + } + } + } + + Ok(()) + } +} diff --git a/packages/tui-rs/src/app/tests.rs b/packages/tui-rs/src/app/tests.rs index 34d08ff90..e648bd560 100644 --- a/packages/tui-rs/src/app/tests.rs +++ b/packages/tui-rs/src/app/tests.rs @@ -1,3 +1,4 @@ +mod agent_launch; use super::*; use crate::keybindings::{ @@ -2936,52 +2937,6 @@ fn returning_user_without_an_agent_keeps_the_composer_and_pending_prompt() { assert!(app.native_agent.is_none()); } -#[tokio::test] -async fn test_handle_config_event_forces_mcp_badge_refresh() { - let temp = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(temp.path().join(".composer")).expect("create config dir"); - - let mut app = new_test_app(); - let (agent, _events) = crate::agent::NativeAgent::new_with_test_client( - crate::agent::NativeAgentConfig { - model: "openai/gpt-4o".into(), - cwd: temp.path().display().to_string(), - ..Default::default() - }, - crate::ai::UnifiedClient::OpenAI( - crate::ai::OpenAiClient::with_base_url("fixture", "http://127.0.0.1:1/v1").unwrap(), - ), - ) - .unwrap(); - app.native_agent = Some(agent); - app.tool_executor = Arc::new(ToolExecutor::new(temp.path().display().to_string())); - app.last_mcp_status_refresh = Some(Instant::now()); - app.state.mcp_connected = 7; - app.state.mcp_tool_count = 9; - app.state.mcp_failed = 2; - - app.handle_config_event(ConfigEvent::Changed(std::path::PathBuf::from( - ".composer/mcp.json", - ))) - .await; - - assert!(app.mcp_status_refresh_in_flight); - tokio::time::timeout(Duration::from_secs(1), async { - loop { - if app.poll_mcp_status_refresh() { - break; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("background MCP status refresh"); - assert_eq!(app.state.mcp_connected, 0); - assert_eq!(app.state.mcp_tool_count, 0); - assert_eq!(app.state.mcp_failed, 0); - assert!(app.last_mcp_status_refresh.is_some()); -} - #[tokio::test] async fn test_handle_config_event_reports_watcher_errors() { let mut app = new_test_app(); diff --git a/packages/tui-rs/src/app/tests/agent_launch.rs b/packages/tui-rs/src/app/tests/agent_launch.rs new file mode 100644 index 000000000..d4853b507 --- /dev/null +++ b/packages/tui-rs/src/app/tests/agent_launch.rs @@ -0,0 +1,48 @@ +use super::*; + +#[tokio::test] +async fn test_handle_config_event_forces_mcp_badge_refresh() { + let temp = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(temp.path().join(".composer")).expect("create config dir"); + + let mut app = new_test_app(); + let (agent, _events) = crate::agent::NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + crate::agent::NativeAgentConfig { + model: "openai/gpt-4o".into(), + cwd: temp.path().display().to_string(), + ..Default::default() + }, + crate::ai::UnifiedClient::OpenAI( + crate::ai::OpenAiClient::with_base_url("fixture", "http://127.0.0.1:1/v1").unwrap(), + ), + ) + .unwrap(); + app.native_agent = Some(agent); + app.tool_executor = Arc::new(ToolExecutor::new(temp.path().display().to_string())); + app.last_mcp_status_refresh = Some(Instant::now()); + app.state.mcp_connected = 7; + app.state.mcp_tool_count = 9; + app.state.mcp_failed = 2; + + app.handle_config_event(ConfigEvent::Changed(std::path::PathBuf::from( + ".composer/mcp.json", + ))) + .await; + + assert!(app.mcp_status_refresh_in_flight); + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if app.poll_mcp_status_refresh() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("background MCP status refresh"); + assert_eq!(app.state.mcp_connected, 0); + assert_eq!(app.state.mcp_tool_count, 0); + assert_eq!(app.state.mcp_failed, 0); + assert!(app.last_mcp_status_refresh.is_some()); +} diff --git a/packages/tui-rs/src/app/tests/session_restore.rs b/packages/tui-rs/src/app/tests/session_restore.rs index 1a8d72d7d..4668f6bd9 100644 --- a/packages/tui-rs/src/app/tests/session_restore.rs +++ b/packages/tui-rs/src/app/tests/session_restore.rs @@ -76,6 +76,7 @@ async fn assert_session_restore_provider_history(interactive: bool) { crate::ai::OpenAiClient::with_base_url("fixture", "http://127.0.0.1:1/v1").unwrap(), ); let (agent, _events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "openai/gpt-5.5".into(), cwd: "/tmp".into(), @@ -202,6 +203,7 @@ async fn resumed_continuation_survives_the_next_compaction() { let saved = crate::session::SessionReader::read_file(&child_path).unwrap(); let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), NativeAgentConfig { model: "openai/gpt-4o".into(), cwd: "/tmp".into(), diff --git a/packages/tui-rs/src/app/tests/startup_recovery.rs b/packages/tui-rs/src/app/tests/startup_recovery.rs index c40f66eaa..8c65cbefb 100644 --- a/packages/tui-rs/src/app/tests/startup_recovery.rs +++ b/packages/tui-rs/src/app/tests/startup_recovery.rs @@ -7,6 +7,7 @@ async fn test_tab_submits_when_idle_with_non_shell_input() { // regression below and must never invent a user-history entry. let temp = tempdir().unwrap(); let (agent, _events) = crate::agent::NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), crate::agent::NativeAgentConfig { model: "openai/gpt-4o".into(), cwd: temp.path().display().to_string(), diff --git a/packages/tui-rs/src/bin/maestro_perf_bench.rs b/packages/tui-rs/src/bin/maestro_perf_bench.rs index f445520c0..e0fc02fb2 100644 --- a/packages/tui-rs/src/bin/maestro_perf_bench.rs +++ b/packages/tui-rs/src/bin/maestro_perf_bench.rs @@ -326,7 +326,12 @@ async fn run_scripted_session( approval_mode: ApprovalMode::Yolo, ..NativeAgentConfig::default() }; - let (agent, mut events) = NativeAgent::new_with_test_client(config, client).expect("agent"); + let (agent, mut events) = NativeAgent::new_with_test_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + client, + ) + .expect("agent"); for prompt in prompts { agent.prompt(prompt, vec![]).await.expect("prompt"); diff --git a/packages/tui-rs/src/print_mode.rs b/packages/tui-rs/src/print_mode.rs index b519a56dc..f45db74bf 100644 --- a/packages/tui-rs/src/print_mode.rs +++ b/packages/tui-rs/src/print_mode.rs @@ -581,11 +581,16 @@ pub async fn run_print_mode(options: PrintModeOptions) -> Result { let credential_vault = CredentialVault::new(); let (agent, mut event_rx) = match &limits.allowed_tools { Some(allowed_tools) => NativeAgent::new_with_allowed_tools_and_credential_vault( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), config, allowed_tools, credential_vault.clone(), ), - None => NativeAgent::new_with_credential_vault(config, credential_vault.clone()), + None => NativeAgent::new_with_credential_vault( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + credential_vault.clone(), + ), } .context("Failed to create native agent for print mode")?; let tool_tx = agent.tool_response_sender(); diff --git a/packages/tui-rs/src/rubber_duck.rs b/packages/tui-rs/src/rubber_duck.rs index 78e534270..62483b9cd 100644 --- a/packages/tui-rs/src/rubber_duck.rs +++ b/packages/tui-rs/src/rubber_duck.rs @@ -429,6 +429,7 @@ async fn drive_review(model: &str, cwd: &str, prompt: &str) -> Result { .collect(); let credential_vault = CredentialVault::new(); let (agent, mut event_rx) = NativeAgent::new_with_allowed_tools_and_credential_vault( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), config, &allowed_tools, credential_vault.clone(), diff --git a/packages/tui-rs/src/scenario_cli/execute.rs b/packages/tui-rs/src/scenario_cli/execute.rs index 6140e8a43..02414ac66 100644 --- a/packages/tui-rs/src/scenario_cli/execute.rs +++ b/packages/tui-rs/src/scenario_cli/execute.rs @@ -323,9 +323,18 @@ pub async fn execute_scripted_scenario_with_options( .map(|tool| tool.to_lowercase()) .collect(); let (agent, mut events) = if allowed_tools.is_empty() { - NativeAgent::new_with_client(config, client) + NativeAgent::new_with_client( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + client, + ) } else { - NativeAgent::new_with_client_and_allowed_tools(config, &allowed_tools, client) + NativeAgent::new_with_client_and_allowed_tools( + std::sync::Arc::new(maestro_local_host::agent::tool_host::BuiltInToolHost), + config, + &allowed_tools, + client, + ) } .context("create scripted-replay agent")?; diff --git a/packages/tui-rs/src/workflow_cli/native_child.rs b/packages/tui-rs/src/workflow_cli/native_child.rs index 55c095ca7..1d94d04f2 100644 --- a/packages/tui-rs/src/workflow_cli/native_child.rs +++ b/packages/tui-rs/src/workflow_cli/native_child.rs @@ -134,7 +134,9 @@ pub(super) async fn run_native_workflow_child( bail!("native workflow child timed out during admission"); } - let factory = LocalChildAgentFactory; + let factory = LocalChildAgentFactory::new(std::sync::Arc::new( + maestro_local_host::agent::tool_host::BuiltInToolHost, + )); let (agent, events) = factory .spawn(ChildLaunchRequest { config,