From 8c3340483c859f190366d5b5ac351056d7e7a589 Mon Sep 17 00:00:00 2001 From: LiZhuohang1243 Date: Wed, 26 Aug 2026 11:22:03 +0800 Subject: [PATCH] feat(extensions): integrate OpenCode plugin hooks runtime Integrate the managed OpenCode Plugin Host with BitFun's hook and agent runtime across CLI, desktop, and app-server surfaces. - add framed Plugin Host RPC, lifecycle supervision, and tool invocation - add the shared HookRegistry/AgentHookEngine path for native and plugin hooks - project plugin Config Hook agents, permissions, tools, and skills into the existing BitFun registries with workspace and generation isolation - preserve external agent routing, model binding, steering, and session compatibility across runtime surfaces - update protocol schemas, examples, and focused integration contracts --- AGENTS-CN.md | 4 +- AGENTS.md | 4 +- .../rules/source/public-api-rules.mjs | 12 + .../rules/source/required-rules.mjs | 77 +- scripts/core-boundaries/self-test.mjs | 16 +- src/apps/cli/src/agent/runtime_client.rs | 96 +- src/apps/cli/src/dispatch/worker.rs | 1 + src/apps/cli/src/main.rs | 6 +- src/apps/cli/src/modes/chat/selection.rs | 9 +- src/apps/cli/src/modes/chat/tests.rs | 2 +- .../cli/src/peer_host/commands/session.rs | 2 + src/apps/cli/src/plugin_host_activation.rs | 5 +- src/apps/cli/src/shared_runtime.rs | 35 +- src/apps/cli/src/ui/agent_selector.rs | 3 + src/apps/cli/src/ui/chat/popups.rs | 2 + src/apps/cli/src/ui/startup.rs | 9 + src/apps/cli/src/ui/tool_cards.rs | 19 +- src/apps/desktop/src/api/agentic_api.rs | 1 + .../src/runtime/session_application.rs | 14 +- src/apps/extension-host/PROTOCOL.md | 44 +- src/apps/extension-host/README.md | 2 +- .../extension-host/examples/example-plugin.ts | 46 + src/apps/extension-host/protocol.schema.json | 309 +++- src/apps/extension-host/src/host.ts | 108 +- src/apps/extension-host/src/main.ts | 2 + src/apps/extension-host/src/protocol.ts | 58 +- src/apps/extension-host/src/service.ts | 9 +- .../test/fixtures/runtime/opening.js | 14 + .../test/helpers/process-host.ts | 4 +- src/apps/extension-host/test/host.test.ts | 52 +- src/apps/extension-host/test/loader.test.ts | 3 +- .../agent-runtime-ipc/src/operation.rs | 2 + .../src/tests/protocol_contracts.rs | 2 + .../src/tests/shared_controller.rs | 2 + .../adapters/opencode-adapter/src/lib.rs | 5 +- .../opencode-adapter/src/source_adapter.rs | 101 +- .../adapters/opencode-plugin-host/src/lib.rs | 236 ++- .../adapters/opencode-plugin-host/src/peer.rs | 140 +- .../opencode-plugin-host/src/tests.rs | 9 +- .../agentic/agents/definitions/external.rs | 7 + .../assembly/core/src/agentic/agents/mod.rs | 7 + .../src/agentic/agents/registry/external.rs | 272 +++- .../core/src/agentic/agents/registry/mod.rs | 28 + .../src/agentic/agents/registry/resolution.rs | 18 +- .../core/src/agentic/agents/registry/tests.rs | 251 +++ .../src/agentic/coordination/coordinator.rs | 79 +- .../core/src/agentic/deep_review/mod.rs | 1 - .../agentic/deep_review/tool_measurement.rs | 38 - .../src/agentic/execution/execution_engine.rs | 17 + .../src/agentic/session/session_manager.rs | 20 +- .../implementations/session_control_tool.rs | 1 + .../implementations/session_message_tool.rs | 1 + .../tools/implementations/skills/registry.rs | 33 +- .../tools/implementations/task/execution.rs | 28 +- .../tools/implementations/task/tests.rs | 14 +- .../tools/implementations/worktree_tool.rs | 1 + .../assembly/core/src/agentic/tools/mod.rs | 3 + .../src/agentic/tools/plugin_host_tool.rs | 820 ++++++++++ .../core/src/agentic/tools/post_call_hooks.rs | 30 +- .../src/agentic/tools/tool_context_runtime.rs | 2 +- .../assembly/core/src/external_sources.rs | 19 +- .../assembly/core/src/external_subagents.rs | 6 + .../assembly/core/src/external_tools.rs | 634 +++++++- src/crates/assembly/core/src/lib.rs | 4 + src/crates/assembly/core/src/native_hooks.rs | 475 +++++- .../core/src/plugin_config_projection.rs | 1343 +++++++++++++++++ .../assembly/core/src/plugin_hook_bridge.rs | 251 +++ src/crates/assembly/core/src/plugin_host.rs | 735 ++++++++- .../assembly/core/src/plugin_host_http.rs | 18 + .../core/src/plugin_host_http_routes.rs | 8 + .../assembly/core/src/plugin_runtime.rs | 8 + .../core/src/service_agent_runtime.rs | 13 + src/crates/contracts/runtime-ports/AGENTS.md | 11 + .../contracts/runtime-ports/src/agent_api.rs | 6 + .../agent-runtime/src/native_hooks/call.rs | 32 + .../agent-runtime/src/native_hooks/engine.rs | 278 +++- .../agent-runtime/src/native_hooks/handler.rs | 151 ++ .../agent-runtime/src/native_hooks/kind.rs | 43 + .../agent-runtime/src/native_hooks/mod.rs | 23 +- .../src/native_hooks/registry.rs | 627 ++++++++ .../src/native_hooks/settings.rs | 33 + .../agent-runtime/src/post_call_hooks.rs | 155 +- .../execution/agent-runtime/src/runtime.rs | 1 + .../execution/agent-runtime/src/session.rs | 13 + .../tests/agent_interaction_contracts.rs | 5 +- .../native_hook_registry_contracts.rs | 265 ++++ .../post_call_hook_contracts.rs | 111 +- .../agent_session_contracts/sdk_smoke.rs | 1 + .../interfaces/acp/src/runtime/session.rs | 2 + .../app-server-protocol/src/schemas/agent.rs | 2 + .../app-server/src/management/owner.rs | 1 + .../app-server/tests/agent_kernel.rs | 26 +- src/crates/interfaces/sdk-host/src/host.rs | 2 + .../src/remote_connect.rs | 1 + 94 files changed, 7660 insertions(+), 779 deletions(-) create mode 100644 src/apps/extension-host/test/fixtures/runtime/opening.js delete mode 100644 src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs create mode 100644 src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs create mode 100644 src/crates/assembly/core/src/plugin_config_projection.rs create mode 100644 src/crates/assembly/core/src/plugin_hook_bridge.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/call.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/handler.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/kind.rs create mode 100644 src/crates/execution/agent-runtime/src/native_hooks/registry.rs create mode 100644 src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs diff --git a/AGENTS-CN.md b/AGENTS-CN.md index b61220b9b4..6046c9506e 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -202,9 +202,9 @@ BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 ### Agent Hooks -- BitFun 实现的是 Codex Hook 契约,因此 是事件、载荷字段与决策结构的参考来源,不要另起炉灶。[`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))只覆盖 BitFun 特有部分 —— 文件位置、`app.hooks` 开关和差异表 —— 新增或消除差异时必须同步更新。 +- BitFun 的原生用户 Hooks 实现 Codex Hook 契约,因此 是其事件、载荷字段与决策结构的参考来源,不要另起炉灶。[`docs/features/agent-hooks.zh-CN.md`](docs/features/agent-hooks.zh-CN.md)([English](docs/features/agent-hooks.md))只覆盖 BitFun 特有部分 —— 文件位置、`app.hooks` 开关和差异表 —— 新增或消除差异时必须同步更新。 - 可移植引擎(配置解析、载荷构造、进程执行、决策合并)位于 `bitfun-agent-runtime::native_hooks`。`bitfun-core::native_hooks` 负责配置发现、开关门控和按事件的分发辅助函数;各分发点调用这些辅助函数,不要就地执行 Hook。 -- 有三类不同的东西共用 "hook" 一词:本文所述的原生用户 Hooks、内部编译期 `post_call_hooks`,以及其他 AI 应用的只读外部 Hook 目录(`external_hooks`)。三者必须保持区分。 +- 可执行 Hooks 可以来自原生用户配置、生态插件或 BitFun 内置实现(包括当前编译期 `post_call_hooks`)。这些来源保持各自的信任、配置、契约和执行策略语义,但可以统一注册到共享的 `HookRegistry`,并由 `AgentHookEngine` 调度。其他 AI 应用的外部 Hook 目录(`external_hooks`)仍只用于只读发现,不得进入可执行 Registry。 ## 架构 diff --git a/AGENTS.md b/AGENTS.md index c13f3c3330..08da9a7f99 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,9 +260,9 @@ existing installs working without manual repair. ### Agent hooks -- BitFun implements the Codex hook contract, so is the reference for events, payload fields, and the decision schema. Do not fork that contract. [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)) covers only the BitFun-specific parts — file locations, the `app.hooks` gates, and the deviations table — and must be updated whenever a deviation is added or closed. +- BitFun native user hooks implement the Codex hook contract, so is the reference for their events, payload fields, and decision schema. Do not fork that contract. [`docs/features/agent-hooks.md`](docs/features/agent-hooks.md) ([中文](docs/features/agent-hooks.zh-CN.md)) covers only the BitFun-specific parts — file locations, the `app.hooks` gates, and the deviations table — and must be updated whenever a deviation is added or closed. - The portable engine (settings parsing, payload construction, process execution, decision merging) lives in `bitfun-agent-runtime::native_hooks`. `bitfun-core::native_hooks` owns config discovery, gating, and per-event dispatch helpers; dispatch sites call those helpers instead of executing hooks inline. -- Three separate things share the word "hook": these native user hooks, the internal compiled-in `post_call_hooks`, and the read-only external hook catalog of other AI applications (`external_hooks`). Keep them separate. +- Executable hooks may come from native user configuration, ecosystem plugins, or BitFun built-ins (including the current compiled-in `post_call_hooks`). These sources keep distinct trust, configuration, contract, and execution-policy semantics, but may register through the shared `HookRegistry` and be dispatched by `AgentHookEngine`. The external hook catalog of other AI applications (`external_hooks`) remains read-only discovery data and must not enter the executable registry. ## Architecture diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 1723204045..2b5f785545 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -265,6 +265,18 @@ export const opencodeAdapterPublicApiEntries = [ 'load_opencode_package_adapter', 'bitfun-core managed plugin composition root and DefaultPluginRuntimeClient integration tests', ), + opencodeAdapterEntry( + 'load_opencode_config_snapshot', + 'bitfun-core live Plugin Host composition root and OpenCode config snapshot contract tests', + ), + opencodeAdapterEntry( + 'OpenCodeConfigSnapshot', + 'bitfun-core live Plugin Host config input and OpenCode config snapshot contract tests', + ), + opencodeAdapterEntry( + 'OpenCodeConfigSnapshotError', + 'bitfun-core live Plugin Host config validation and OpenCode config snapshot contract tests', + ), opencodeAdapterEntry( 'OpenCodeCommandProvider', 'bitfun-core external source composition root and OpenCode command adapter tests', diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 900dcbae49..7d4e5db409 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -1760,54 +1760,24 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', - reason: - 'agent-runtime must own portable hook registry and post-call routing decisions while concrete hook execution stays in the owning runtime', + path: 'src/crates/execution/agent-runtime/src/native_hooks/kind.rs', + reason: 'agent-runtime must own portable hook kind contracts', patterns: [ - { - regex: /\bpub enum RuntimeHookKind\b/, - message: 'missing runtime hook kind contract', - }, - { - regex: /\bpub enum RuntimeHookErrorPolicy\b/, - message: 'missing runtime hook error policy contract', - }, - { - regex: /\bpub struct RuntimeHookPlan\b/, - message: 'missing runtime hook plan contract', - }, - { - regex: /\bpub struct RuntimeHookRegistry\b/, - message: 'missing runtime hook registry contract', - }, - { - regex: /\btimeout_millis\b/, - message: 'missing runtime hook timeout contract', - }, - { - regex: /\bDuplicateHookId\b/, - message: 'missing runtime hook duplicate-id guard', - }, - { - regex: /\bEmptyHookId\b/, - message: 'missing runtime hook empty-id guard', - }, - { - regex: /\bInvalidTimeoutMillis\b/, - message: 'missing runtime hook non-zero-timeout guard', - }, - { - regex: /\bpub const fn successful_tool_post_call_hooks\b/, - message: 'missing successful tool post-call hook routing decision', - }, - { - regex: /\bpub trait SuccessfulToolPostCallHookExecutor\b/, - message: 'missing successful tool post-call hook executor contract', - }, - { - regex: /\bpub fn run_successful_tool_post_call_hooks\b/, - message: 'missing successful tool post-call hook executor runner', - }, + { regex: /\bpub enum RuntimeHookKind\b/, message: 'missing runtime hook kind contract' }, + { regex: /\bSuccessfulToolPostCall\b/, message: 'missing successful tool post-call hook kind' }, + ], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/registry.rs', + reason: 'agent-runtime must own portable hook registry and validation contracts', + patterns: [ + { regex: /\bpub enum RuntimeHookErrorPolicy\b/, message: 'missing runtime hook error policy contract' }, + { regex: /\bpub struct RuntimeHookPlan\b/, message: 'missing runtime hook plan contract' }, + { regex: /\bpub struct RuntimeHookRegistry\b/, message: 'missing runtime hook registry contract' }, + { regex: /\btimeout_millis\b/, message: 'missing runtime hook timeout contract' }, + { regex: /\bDuplicateHookId\b/, message: 'missing runtime hook duplicate-id guard' }, + { regex: /\bEmptyHookId\b/, message: 'missing runtime hook empty-id guard' }, + { regex: /\bInvalidTimeoutMillis\b/, message: 'missing runtime hook non-zero-timeout guard' }, ], }, { @@ -1816,11 +1786,11 @@ export const requiredContentRules = [ 'agent-runtime post-call hook owner must keep behavior-equivalence contracts for successful tool-call hook routing', patterns: [ { - regex: /\bsuccessful_tool_call_routes_to_shared_context_measurement_hook\b/, + regex: /\bsuccessful_tool_call_uses_stable_builtin_registration_id\b/, message: 'missing successful tool post-call hook routing regression', }, { - regex: /\bruntime_hook_registry_preserves_order_timeout_and_error_policy\b/, + regex: /\bruntime_hook_registry_preserves_source_order_timeout_and_error_policy\b/, message: 'missing runtime hook order/timeout/error-policy regression', }, { @@ -2702,14 +2672,7 @@ export const requiredContentRules = [ reason: 'core post-call hooks must delegate portable hook routing to agent-runtime while retaining concrete hook execution', patterns: [ - { - regex: /\brun_successful_tool_post_call_hooks\b/, - message: 'missing post-call hook executor runner delegation', - }, - { - regex: /\bSuccessfulToolPostCallHookExecutor\b/, - message: 'missing post-call hook executor implementation', - }, + { regex: /\bdispatch_successful_tool_post_call\b/, message: 'missing post-call hook dispatch delegation' }, ], }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 9b57ce94da..22b956de69 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1603,7 +1603,7 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' + 'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions' ) { throw new Error( 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers', @@ -2818,24 +2818,24 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/execution/agent-runtime/src/post_call_hooks.rs', + path: 'src/crates/execution/agent-runtime/src/native_hooks/kind.rs', + contracts: ['RuntimeHookKind', 'SuccessfulToolPostCall'], + }, + { + path: 'src/crates/execution/agent-runtime/src/native_hooks/registry.rs', contracts: [ - 'RuntimeHookKind', 'RuntimeHookErrorPolicy', 'RuntimeHookPlan', 'RuntimeHookRegistry', 'EmptyHookId', 'InvalidTimeoutMillis', - 'successful_tool_post_call_hooks', - 'SuccessfulToolPostCallHookExecutor', - 'run_successful_tool_post_call_hooks', ], }, { path: 'src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs', contracts: [ - 'successful_tool_call_routes_to_shared_context_measurement_hook', - 'runtime_hook_registry_preserves_order_timeout_and_error_policy', + 'successful_tool_call_uses_stable_builtin_registration_id', + 'runtime_hook_registry_preserves_source_order_timeout_and_error_policy', 'runtime_hook_registry_rejects_duplicate_ids', 'runtime_hook_registry_rejects_unstable_ids_and_zero_timeouts', ], diff --git a/src/apps/cli/src/agent/runtime_client.rs b/src/apps/cli/src/agent/runtime_client.rs index 03e6811a28..f7d904febf 100644 --- a/src/apps/cli/src/agent/runtime_client.rs +++ b/src/apps/cli/src/agent/runtime_client.rs @@ -367,6 +367,7 @@ enum CliAgentRuntimeBackend { #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct CliAgentMode { pub(crate) id: String, + pub(crate) route_key: String, pub(crate) description: String, pub(crate) model_id: Option, pub(crate) is_external: bool, @@ -444,24 +445,38 @@ impl CliAgentRuntimeClient { /// consults the controller process's local registry. pub(crate) async fn available_agent_modes(&self) -> Result> { match &self.backend { - CliAgentRuntimeBackend::Embedded(runtime) => runtime - .list_agent_modes(AgentModeCatalogQuery { - workspace_root: Some(self.workspace_path_string()), - include_external: true, - }) - .await - .map(|modes| { - modes - .into_iter() - .map(|mode| CliAgentMode { - id: mode.id, - description: mode.description, - model_id: mode.model_id, - is_external: mode.is_external, - }) - .collect() - }) - .map_err(|error| anyhow::anyhow!(error.into_message())), + CliAgentRuntimeBackend::Embedded(_) => { + let binding = self.current_workspace_binding(); + if let Err(error) = self.ensure_embedded_plugin_workspace_ready(&binding).await { + tracing::warn!( + "Configured plugin activation failed while loading agent modes; continuing with native agents: {}", + error + ); + } + let workspace = PathBuf::from(&binding.workspace_path); + if let Err(error) = + bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some( + &workspace, + )) + .await + { + tracing::warn!("Failed to initialize external agent sources: {error}"); + } + let registry = bitfun_core::agentic::agents::get_agent_registry(); + Ok(registry + .get_modes_info_for_workspace(Some(&workspace), true) + .await + .into_iter() + .map(|mode| CliAgentMode { + id: mode.id, + route_key: mode.key, + description: mode.description, + model_id: mode.model, + is_external: mode.source + == bitfun_core::agentic::agents::AgentSource::External, + }) + .collect()) + } CliAgentRuntimeBackend::Shared(client) => { let session_id = self.session_id.lock().await.clone(); match client @@ -472,6 +487,7 @@ impl CliAgentRuntimeClient { .into_iter() .map(|mode| CliAgentMode { id: mode.id, + route_key: mode.route_key, description: mode.description, model_id: mode.model_id, is_external: mode.is_external, @@ -602,15 +618,33 @@ impl CliAgentRuntimeClient { .apply_binding(binding); } + fn current_workspace_binding(&self) -> AgentSessionWorkspaceBinding { + let paths = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let execution = paths.execution(); + let project = paths.project(); + AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: execution.to_string_lossy().to_string(), + project_workspace_path: Some(project.to_string_lossy().to_string()), + execution_target: paths.execution_target.clone().or_else(|| { + Some(SessionExecutionTarget::local( + execution.to_string_lossy().to_string(), + )) + }), + remote_connection_id: paths.remote_connection_id.clone(), + remote_ssh_host: paths.remote_ssh_host.clone(), + } + } + pub(crate) fn remote_workspace_scope(&self) -> (Option, Option) { let paths = self .workspace_paths .read() .unwrap_or_else(|poisoned| poisoned.into_inner()); - ( - paths.remote_connection_id.clone(), - paths.remote_ssh_host.clone(), - ) + (paths.remote_connection_id.clone(), paths.remote_ssh_host.clone()) } pub(crate) fn is_remote_workspace(&self) -> bool { @@ -1065,10 +1099,21 @@ impl CliAgentRuntimeClient { &self, session_id: &str, mode_id: &str, + ) -> std::result::Result<(), SessionOperationError> { + self.update_session_mode_with_route(session_id, mode_id, None) + .await + } + + pub(crate) async fn update_session_mode_with_route( + &self, + session_id: &str, + mode_id: &str, + route_key: Option<&str>, ) -> std::result::Result<(), SessionOperationError> { let request = AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), + agent_route_key: route_key.map(str::to_string), }; match &self.backend { CliAgentRuntimeBackend::Embedded(runtime) => runtime @@ -1351,6 +1396,7 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name, agent_type: effective_agent_type, + agent_route_key: None, workspace_path: Some(workspace.to_string_lossy().to_string()), project_workspace_path: Some(project_workspace.to_string_lossy().to_string()), execution_target: self.execution_target(), @@ -1424,6 +1470,7 @@ impl CliAgentRuntimeClient { AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(workspace_path), project_workspace_path: Some(project_workspace_path), execution_target: self.execution_target(), @@ -1468,6 +1515,7 @@ impl CliAgentRuntimeClient { let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(self.workspace_path_string()), project_workspace_path: None, execution_target: None, @@ -1914,6 +1962,7 @@ impl CliAgentRuntimeClient { let request = AgentSessionCreateRequest { session_name: Self::build_default_session_name(), agent_type: agent_type.to_string(), + agent_route_key: None, workspace_path: Some(project_workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(project_workspace_path)), @@ -2852,12 +2901,14 @@ mod dual_backend_behavior_tests { vec![ AgentModeCatalogEntry { id: "agentic".to_string(), + route_key: "agentic".to_string(), description: "Primary workspace agent".to_string(), model_id: Some("primary-model".to_string()), is_external: false, }, AgentModeCatalogEntry { id: "workspace-plan".to_string(), + route_key: "external::workspace-plan".to_string(), description: "Workspace plan agent".to_string(), model_id: Some("plan-model".to_string()), is_external: true, @@ -3606,6 +3657,7 @@ mod dual_backend_behavior_tests { let create_request = AgentSessionCreateRequest { session_name: "remote-unsupported-session".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(fixture.workspace.to_string_lossy().into_owned()), project_workspace_path: Some(fixture.workspace.to_string_lossy().into_owned()), execution_target: Some(SessionExecutionTarget::local( diff --git a/src/apps/cli/src/dispatch/worker.rs b/src/apps/cli/src/dispatch/worker.rs index c37cbf41a8..d5501f33c9 100644 --- a/src/apps/cli/src/dispatch/worker.rs +++ b/src/apps/cli/src/dispatch/worker.rs @@ -181,6 +181,7 @@ async fn run_inner(store: &DispatchStore, job_id: &str) -> Result<()> { AgentSessionCreateRequest { session_name: job.title.clone(), agent_type: job.request.agent_type.clone(), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: Some(workspace_path.clone()), execution_target: Some(SessionExecutionTarget::local(workspace_path.clone())), diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index baca196035..b9f00c513e 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -52,7 +52,7 @@ use modes::chat::ChatMode; use modes::exec::{ExecApprovalMode, ExecOutputFormat}; pub(crate) const PLUGIN_HOST_LAUNCH_POLICY: bitfun_core::plugin_host::PluginHostLaunchPolicy = - bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled; + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled; // ======================== Global MCP Service ======================== @@ -1909,8 +1909,8 @@ mod bootstrap_profile_tests { #[test] fn profiles_start_only_their_requested_background_services() { let cases = [ - (BootstrapProfile::Interactive, true, true, false), - (BootstrapProfile::Execution, false, true, false), + (BootstrapProfile::Interactive, true, true, true), + (BootstrapProfile::Execution, false, true, true), (BootstrapProfile::Management, false, false, false), ]; diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 9384b0f0c5..532e4d659c 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -455,6 +455,7 @@ impl ChatMode { let selected = AgentItem { id: next.id.clone(), + route_key: (!next.route_key.is_empty()).then(|| next.route_key.clone()), description: next.description.clone(), }; self.apply_agent_selection(&selected, chat_view, chat_state, rt_handle); @@ -624,6 +625,7 @@ impl ChatMode { .into_iter() .map(|m| AgentItem { id: m.id, + route_key: (!m.route_key.is_empty()).then_some(m.route_key), description: m.description, }) .collect(); @@ -689,13 +691,18 @@ impl ChatMode { let session_id = chat_state.core_session_id.clone(); let mode_id = selected.id.clone(); + let route_key = selected.route_key.clone(); let task_mode_id = mode_id.clone(); let agent = self.agent.clone(); chat_view.set_status(Some(format!("Switching agent mode to {mode_id}..."))); let task_session_id = session_id.clone(); let handle = rt_handle.spawn(async move { agent - .update_session_mode(&task_session_id, &task_mode_id) + .update_session_mode_with_route( + &task_session_id, + &task_mode_id, + route_key.as_deref(), + ) .await }); self.pending_session_operation = Some(PendingSessionOperation { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index c6ee510dfc..b8569030be 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -84,7 +84,7 @@ mod tests { .expect("agent selection boundary") .0; - assert!(selection.contains(".update_session_mode(&task_session_id, &task_mode_id)")); + assert!(selection.contains(".update_session_mode_with_route(")); assert!(!selection.contains("selected.id == self.agent_type")); } diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index d939cfa76e..97b80685d7 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -467,6 +467,7 @@ pub(crate) async fn create_session(state: &PeerHostState, args: &Value) -> Resul let create_request = AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path: Some(workspace_path), project_workspace_path: None, execution_target: None, @@ -651,6 +652,7 @@ pub(crate) async fn update_session_mode( .update_session_mode(AgentSessionModeUpdateRequest { session_id, mode_id, + agent_route_key: None, }) .await .map_err(|error| format!("Failed to update session mode: {}", error.into_message()))?; diff --git a/src/apps/cli/src/plugin_host_activation.rs b/src/apps/cli/src/plugin_host_activation.rs index d4d13a26f3..e864b4a0e4 100644 --- a/src/apps/cli/src/plugin_host_activation.rs +++ b/src/apps/cli/src/plugin_host_activation.rs @@ -68,7 +68,6 @@ pub(crate) async fn ensure_plugin_workspace_ready( target.directory, target.worktree, target.project_id, - serde_json::Map::new(), ) .await .map(|_| ()) @@ -112,10 +111,10 @@ mod tests { } #[test] - fn cli_does_not_enable_unowned_plugin_execution() { + fn cli_enables_configured_plugin_execution() { assert_eq!( crate::PLUGIN_HOST_LAUNCH_POLICY, - bitfun_core::plugin_host::PluginHostLaunchPolicy::Disabled + bitfun_core::plugin_host::PluginHostLaunchPolicy::Enabled ); } } diff --git a/src/apps/cli/src/shared_runtime.rs b/src/apps/cli/src/shared_runtime.rs index 54b01811a8..0e019fa11b 100644 --- a/src/apps/cli/src/shared_runtime.rs +++ b/src/apps/cli/src/shared_runtime.rs @@ -239,14 +239,34 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { match operation { RuntimeIpcOperation::Health => unreachable!("Health is owned by the IPC server"), RuntimeIpcOperation::ListAgentModes { session_id } => { - let workspace = match session_id { - Some(session_id) => PathBuf::from( - self.session_workspace_binding(&session_id) - .await? - .workspace_path, - ), - None => self.workspace.clone(), + let binding = match session_id { + Some(session_id) => self.session_workspace_binding(&session_id).await?, + None => AgentSessionWorkspaceBinding { + workspace_id: None, + workspace_path: self.workspace.to_string_lossy().to_string(), + project_workspace_path: Some(self.workspace.to_string_lossy().to_string()), + execution_target: Some( + bitfun_runtime_ports::SessionExecutionTarget::local( + self.workspace.to_string_lossy().to_string(), + ), + ), + remote_connection_id: None, + remote_ssh_host: None, + }, }; + if let Err(error) = self.ensure_plugin_workspace_ready(&binding).await { + tracing::warn!( + "Configured plugin activation failed while loading Shared Runtime agent modes; continuing with native agents: {:?}", + error + ); + } + let workspace = PathBuf::from(&binding.workspace_path); + if let Err(error) = bitfun_core::external_sources::ensure_external_source_workspace_snapshot(Some(&workspace)).await { + tracing::warn!( + "Failed to initialize external agent sources for Shared TUI mode catalog: {}", + error + ); + } let modes = self .runtime .list_agent_modes(AgentModeCatalogQuery { @@ -258,6 +278,7 @@ impl RuntimeIpcRequestHandler for SharedRuntimeHandler { .into_iter() .map(|mode| RuntimeAgentModeSummary { id: mode.id, + route_key: mode.route_key, description: mode.description, model_id: mode.model_id, is_external: mode.is_external, diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index ad7b45c0d4..bfabc72ce3 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -20,6 +20,7 @@ use crate::ui::{ #[derive(Debug, Clone)] pub(crate) struct AgentItem { pub id: String, + pub route_key: Option, pub description: String, } @@ -381,10 +382,12 @@ mod tests { vec![ AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }, AgentItem { id: "ask".to_string(), + route_key: None, description: "Read only".to_string(), }, ] diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 18a86dc755..8f955136d7 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -803,6 +803,7 @@ mod tests { view.show_agent_selector( vec![AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }], Some("agentic".to_string()), @@ -822,6 +823,7 @@ mod tests { view.show_agent_modes_only( vec![AgentItem { id: "agentic".to_string(), + route_key: None, description: "General purpose".to_string(), }], Some("agentic".to_string()), diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index bb81dcea81..137f42b46e 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -314,6 +314,12 @@ impl StartupPage { &self.agent_type } + pub(crate) fn selected_agent_route_key(&self) -> Option { + self.selected_agent_mode() + .map(|mode| mode.route_key) + .filter(|route_key| !route_key.is_empty()) + } + /// Set a model ID override (from `--model` flag) for display and session /// composition. The ID is validated when applied to the session; an invalid /// ID logs a warning and falls back to the default model. @@ -2016,6 +2022,7 @@ impl StartupPage { .into_iter() .map(|m| AgentItem { id: m.id, + route_key: (!m.route_key.is_empty()).then_some(m.route_key), description: m.description, }) .collect(); @@ -2728,12 +2735,14 @@ mod logo_contract_tests { fn external_or_unknown_startup_modes_do_not_change_the_shared_default() { let local = TuiAgentMode { id: "agentic".to_string(), + route_key: "agentic".to_string(), description: String::new(), model_id: None, is_external: false, }; let external = TuiAgentMode { id: "reviewer".to_string(), + route_key: "external::reviewer".to_string(), description: String::new(), model_id: None, is_external: true, diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index 3ffaa331e4..7875c8c6a5 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -530,7 +530,7 @@ fn inline_complete_text(canonical: &str, tool_state: &ToolDisplayState) -> Strin format!("WebFetch {}", truncate_str(&url, 60)) } "Skill" => { - let name = param_str(&tool_state.parameters, &["name", "skill_name"]); + let name = skill_display_name(&tool_state.parameters); format!("Skill \"{}\"", name) } "Git" => { @@ -2056,6 +2056,11 @@ fn param_str(params: &serde_json::Value, keys: &[&str]) -> String { "unknown".to_string() } +/// Extract the Skill tool's command, retaining legacy parameter aliases. +fn skill_display_name(params: &serde_json::Value) -> String { + param_str(params, &["command", "name", "skill_name"]) +} + /// Extract an optional string parameter fn param_str_opt(params: &serde_json::Value, keys: &[&str]) -> Option { for key in keys { @@ -2108,3 +2113,15 @@ fn capitalize_first(s: &str) -> String { Some(c) => c.to_uppercase().to_string() + chars.as_str(), } } + +#[cfg(test)] +mod tests { + use super::skill_display_name; + + #[test] + fn skill_name_is_read_from_command_parameter() { + let params = serde_json::json!({"command": "arkts-error-fixes"}); + + assert_eq!(skill_display_name(¶ms), "arkts-error-fixes"); + } +} diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 30279852ce..575e43555f 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1927,6 +1927,7 @@ pub async fn update_session_mode( .update_session_mode(AgentSessionModeUpdateRequest { session_id, mode_id: request.mode_id, + agent_route_key: None, }) .await .map_err(|error| format!("Failed to update session mode: {}", error.into_message())) diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 3e7a5a6ff6..ad6415372c 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -394,11 +394,16 @@ impl DesktopSessionApplication { let scope = self.resolved_scope(request).await; self.ensure_runtime_ownership(&scope)?; if scope.remote_connection_id.is_some() { - log::debug!( - "Configured plugin host activation skipped for remote workspace: workspace_path={}", + if !bitfun_core::plugin_host::configured_plugins_present() + .await + .map_err(|error| DesktopSessionApplicationError::Core(error.to_string()))? + { + return Ok(None); + } + return Err(DesktopSessionApplicationError::Core(format!( + "OpenCode plugin hooks are unavailable for remote workspace {} because the remote execution domain does not provide a plugin host", scope.workspace_path - ); - return Ok(None); + ))); } let workspace_path = PathBuf::from(&scope.workspace_path); bitfun_core::plugin_host::ensure_configured_plugin_instance( @@ -406,7 +411,6 @@ impl DesktopSessionApplication { workspace_path.clone(), workspace_path, project_id, - serde_json::Map::new(), ) .await .map_err(|error| DesktopSessionApplicationError::Core(error.to_string())) diff --git a/src/apps/extension-host/PROTOCOL.md b/src/apps/extension-host/PROTOCOL.md index d171693f7d..64bb3a8c90 100644 --- a/src/apps/extension-host/PROTOCOL.md +++ b/src/apps/extension-host/PROTOCOL.md @@ -43,7 +43,8 @@ The host sends: "token": "value from OPENCODE_EXTENSION_HOST_RPC_TOKEN", "protocolVersion": 1, "opencodeVersion": "1.17.18", - "maxFrameBytes": 16777216 + "maxFrameBytes": 16777216, + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1"] } } ``` @@ -57,12 +58,13 @@ Rust returns: "result": { "protocolVersion": 1, "maxFrameBytes": 16777216, - "cacheDirectory": "/absolute/path/to/plugin-cache" + "cacheDirectory": "/absolute/path/to/plugin-cache", + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1"] } } ``` -`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. +`cacheDirectory` must be absolute and writable by the host. It is the only location in which the host installs npm plugins. The accepted `maxFrameBytes` remains fixed until disconnect. Capabilities are intersected by both peers. Rust must not execute function hooks or plugin tools unless `generation-fencing-v1` was negotiated. Config projection requires `config-contributors-v1`; multiple Config contributors additionally require `config-contributions-v2`. ## Common wire types @@ -145,7 +147,7 @@ One read returns no more than `maxBytes`, with a 64 KiB maximum. `eof: true` rel ### Process-local identity -`instanceID`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. +`instanceID`, `generationKey`, `revision`, `executionID`, `flowID`, `fetchID`, `requestID`, and `streamID` have no durable meaning. Rust must keep them with their creating instance and connection. `instanceID + generationKey + revision` is the lease for executable Hook and Tool calls. Closing an instance invalidates its active capabilities; losing the process invalidates all of them. ## Rust-to-host methods @@ -158,6 +160,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string project: JsonValue config: Record directory: string @@ -167,6 +171,7 @@ Params: options?: Record baseDirectory?: string }> + configurationFingerprint?: string } ``` @@ -175,7 +180,18 @@ Result: ```ts { instanceID: string + generationKey: string + revision: string config: Record + configContributors: Array<{ + plugin: Record + outcome: "applied" | "failed" + }> + configContributions: Array<{ + plugin: Record + outcome: "applied" | "failed" + config: Record + }> diagnostics: Diagnostic[] hooks: string[] tools: Array<{ @@ -204,7 +220,9 @@ Result: The gateway is listening before plugin entrypoints execute, so SDK calls during initialization work. Config hooks run sequentially before the result is sent. Failed plugins are omitted and represented in `diagnostics`; successful registrations remain available. -Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. +The host binds the requested `instanceID + generationKey + revision` only after the instance opens successfully and echoes all three values unchanged. Opening an active `instanceID` or a directory already owned by another instance is an error. Reopening after close creates a new instance and reruns entrypoints while preserving Bun's normal process-global module cache. + +`configContributors` records every plugin that declared a Config hook, in plugin activation order, and whether its invocation applied or failed. `configContributions` contains the same ordered entries plus a bounded clone of the cumulative config immediately after each hook. Config hooks still retain mutations made before an exception and continue to later contributors. Rust uses the sequence to attribute Agent, permission, and Skill changes without re-executing plugin code. The snapshots are protocol data and must not be written to ordinary logs. `hooks` may contain: @@ -242,10 +260,14 @@ The host closes all instances, responds, closes the RPC connection, and exits no #### `host.hook.call` -Params: `{ instanceID, hook, input, output }`. Result: `{ input, output }`. +Params: `{ instanceID, generationKey, revision, hook, input, output }`. + +Result: `{ instanceID, generationKey, revision, hook, input, output }`. `input` and `output` are JSON values. Matching hooks run sequentially in plugin order on the same live objects for this invocation. The first hook error stops the invocation; earlier mutations are not rolled back. Different hook requests may overlap. +The Host rejects a call whose generation lease does not exactly match the open instance. Rust likewise rejects a response that does not echo the requested instance, generation, revision, and hook name. + For `tool.definition`, `output.parameters` crosses the process boundary as JSON Schema rather than an Effect schema object. #### `host.event.emit` @@ -263,6 +285,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string executionID: string registrationID: string args: JsonValue @@ -297,11 +321,11 @@ The host reconstructs a per-execution `AbortSignal` and fills the public tool co Tool registration parameters and later `tool.definition` parameters use their JSON Schema projection. Rust sends arguments; Bun validates them through the retained plugin schema before execution. -Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. +Rust invokes the tool by the returned opaque `registrationID`. `id` is the plugin-facing tool name and is not an execution handle. The Host validates the complete generation lease before admitting execution and echoes `instanceID`, `generationKey`, `revision`, and `executionID` in the result wrapper together with the plugin `result`. #### `host.tool.cancel` -Params: `{ instanceID, executionID }`. Result: `{ cancelled: boolean }`. +Params: `{ instanceID, generationKey, revision, executionID, reason? }`. Result: `{ cancelled: boolean }`. The host aborts the retained signal. Cancellation is idempotent and does not hard-kill subprocesses created by a plugin. @@ -453,6 +477,8 @@ Params: ```ts { instanceID: string + generationKey: string + revision: string executionID: string permission: string patterns: string[] @@ -465,7 +491,7 @@ Result: `{}` on approval, or a JSON-RPC error on denial/failure. The host awaits ### `backend.tool.metadata` -Notification params: `{ instanceID, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. +Notification params: `{ instanceID, generationKey, revision, executionID, title?, metadata? }`. Because this is a notification, the plugin's `metadata(...)` call returns without waiting for Rust. ### `backend.diagnostic.publish` diff --git a/src/apps/extension-host/README.md b/src/apps/extension-host/README.md index f5abc28eea..9a2582f5dc 100644 --- a/src/apps/extension-host/README.md +++ b/src/apps/extension-host/README.md @@ -40,7 +40,7 @@ Rust backend <====== framed bidirectional JSON-RPC ==+ Control traffic uses JSON-RPC 2.0 messages framed by a four-byte big-endian length. Requests can travel in either direction and may be reentrant; plugin stdout and stderr are never used as protocol channels. HTTP and fetch bodies use pull-based stream handles so the receiver controls backpressure instead of embedding unbounded bodies in JSON. -Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. +Each `host.instance.open` call creates one logical plugin instance and one HTTP gateway. The host resolves and imports retained plugin declarations concurrently, executes successful entrypoints in declaration order, runs their config hooks, and returns the resulting registrations. The open result includes ordered cumulative Config snapshots so Rust can attribute Agent, permission, and Skill changes across multiple Config hooks without re-executing plugin code. Operational hook calls are ordered within one invocation, but unrelated invocations and unrelated instances may overlap. Closing an instance rejects new work, cancels active tools and fetches, closes its gateway, and invokes every registered disposer once. Losing the RPC connection applies the same cleanup to all instances and terminates the host. Rust is responsible for restarting the process and deciding whether any application work should be retried. diff --git a/src/apps/extension-host/examples/example-plugin.ts b/src/apps/extension-host/examples/example-plugin.ts index 8c2e90fdd7..7a4409b833 100644 --- a/src/apps/extension-host/examples/example-plugin.ts +++ b/src/apps/extension-host/examples/example-plugin.ts @@ -1,5 +1,6 @@ import type { Plugin, PluginModule } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin" +import { z } from "zod" const ExamplePlugin: Plugin = async (input) => { input.experimental_workspace.register("example-local", { @@ -23,7 +24,52 @@ const ExamplePlugin: Plugin = async (input) => { }) return { + config: async (config) => { + config.bitfunDemo = { + enabled: true, + directory: input.directory, + } + config.agent = { + ...(config.agent ?? {}), + Cowork: { + mode: "primary", + description: "Demo agent for exercising the BitFun OpenCode plugin bridge", + prompt: "Use the bitfun_demo_echo tool when the user asks you to echo text.", + permission: { + bitfun_demo_echo: "allow", + }, + }, + } + }, tool: { + bitfun_demo_echo: tool({ + description: "Echo text from the BitFun OpenCode plugin demo", + args: { + text: z.string().describe("Text to echo"), + }, + async execute(args, context) { + context.metadata({ + title: "Preparing BitFun demo echo", + metadata: { phase: "before-ask" }, + }) + await context.ask({ + permission: "bitfun_demo_echo", + patterns: ["demo-echo"], + always: ["demo-echo"], + metadata: { + riskDescription: "Allow the demo plugin to echo text", + }, + }) + context.metadata({ + title: "BitFun demo echo approved", + metadata: { phase: "after-ask" }, + }) + return { + title: "BitFun demo echo", + output: `${args.text}:${input.directory}`, + } + }, + }), extension_host_info: tool({ description: "Exercise the injected OpenCode SDK client and raw HTTP gateway", args: {}, diff --git a/src/apps/extension-host/protocol.schema.json b/src/apps/extension-host/protocol.schema.json index 4244566483..32a7c1e808 100644 --- a/src/apps/extension-host/protocol.schema.json +++ b/src/apps/extension-host/protocol.schema.json @@ -504,6 +504,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "project": { "$ref": "#/$defs/HostInstanceOpenParams/$defs/__schema0" }, @@ -605,6 +613,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "config": { "type": "object", "propertyNames": { @@ -614,6 +630,110 @@ "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" } }, + "configContributors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plugin": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "spec": { + "type": "string", + "minLength": 1 + }, + "entry": { + "type": "string", + "minLength": 1 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "spec", + "entry", + "index" + ], + "additionalProperties": false + }, + "outcome": { + "type": "string", + "enum": [ + "applied", + "failed" + ] + } + }, + "required": [ + "plugin", + "outcome" + ], + "additionalProperties": false + } + }, + "configContributions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "plugin": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "spec": { + "type": "string", + "minLength": 1 + }, + "entry": { + "type": "string", + "minLength": 1 + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "spec", + "entry", + "index" + ], + "additionalProperties": false + }, + "outcome": { + "type": "string", + "enum": [ + "applied", + "failed" + ] + }, + "config": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/$defs/HostInstanceOpenResult/$defs/__schema0" + } + } + }, + "required": [ + "plugin", + "outcome", + "config" + ], + "additionalProperties": false + } + }, "diagnostics": { "type": "array", "items": { @@ -925,6 +1045,8 @@ "required": [ "instanceID", "config", + "configContributors", + "configContributions", "diagnostics", "hooks", "tools", @@ -1057,6 +1179,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "hook": { "type": "string", "minLength": 1 @@ -1112,6 +1242,22 @@ "HostHookCallResult": { "type": "object", "properties": { + "instanceID": { + "type": "string", + "minLength": 1 + }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, + "hook": { + "type": "string", + "minLength": 1 + }, "input": { "$ref": "#/$defs/HostHookCallResult/$defs/__schema0" }, @@ -1120,6 +1266,7 @@ } }, "required": [ + "hook", "input", "output" ], @@ -1228,6 +1375,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -1306,62 +1461,88 @@ } }, "HostToolExecuteResult": { - "anyOf": [ - { - "type": "string" + "type": "object", + "properties": { + "instanceID": { + "type": "string", + "minLength": 1 }, - { - "type": "object", - "properties": { - "title": { - "type": "string" - }, - "output": { + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, + "executionID": { + "type": "string", + "minLength": 1 + }, + "result": { + "anyOf": [ + { "type": "string" }, - "metadata": { + { "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" - } - }, - "attachments": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file" - }, - "mime": { - "type": "string" - }, - "url": { + "properties": { + "title": { + "type": "string" + }, + "output": { + "type": "string" + }, + "metadata": { + "type": "object", + "propertyNames": { "type": "string" }, - "filename": { - "type": "string" + "additionalProperties": { + "$ref": "#/$defs/HostToolExecuteResult/$defs/__schema0" } }, - "required": [ - "type", - "mime", - "url" - ], - "additionalProperties": false - } + "attachments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file" + }, + "mime": { + "type": "string" + }, + "url": { + "type": "string" + }, + "filename": { + "type": "string" + } + }, + "required": [ + "type", + "mime", + "url" + ], + "additionalProperties": false + } + } + }, + "required": [ + "output" + ], + "additionalProperties": false } - }, - "required": [ - "output" - ], - "additionalProperties": false + ] } + }, + "required": [ + "executionID", + "result" ], + "additionalProperties": false, "$defs": { "__schema0": { "anyOf": [ @@ -1403,6 +1584,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -2669,6 +2858,13 @@ "type": "integer", "minimum": 65536, "maximum": 67108864 + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "required": [ @@ -2693,6 +2889,13 @@ }, "cacheDirectory": { "type": "string" + }, + "capabilities": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } }, "required": [ @@ -2883,6 +3086,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 @@ -2967,6 +3178,14 @@ "type": "string", "minLength": 1 }, + "generationKey": { + "type": "string", + "minLength": 1 + }, + "revision": { + "type": "string", + "minLength": 1 + }, "executionID": { "type": "string", "minLength": 1 diff --git a/src/apps/extension-host/src/host.ts b/src/apps/extension-host/src/host.ts index 7b064a5cf0..4a596fd9b4 100644 --- a/src/apps/extension-host/src/host.ts +++ b/src/apps/extension-host/src/host.ts @@ -123,6 +123,8 @@ type ActiveAuthFetch = { type Instance = { id: string + generationKey?: string + revision?: string canonicalDirectory: string directory: string worktree: string @@ -146,6 +148,8 @@ type Instance = { export type InstanceOpenInput = { instanceID: string + generationKey?: string + revision?: string project: WireValue directory: string worktree: string @@ -260,6 +264,8 @@ export class ExtensionHost { const opened = Promise.withResolvers() const instance: Instance = { id: input.instanceID, + generationKey: input.generationKey, + revision: input.revision, canonicalDirectory, directory: input.directory, worktree: input.worktree, @@ -315,14 +321,41 @@ export class ExtensionHost { diagnostic_count: diagnostics.length, }) + const configContributions: Array<{ + plugin: PluginMeta + outcome: "applied" | "failed" + config: WireValue + }> = [] for (const retained of instance.hooks) { this.#assertOpening(instance) if (!retained.hooks.config) continue + logEvent("plugin.activation.config_hook.begin", { + instance_id: instance.id, + plugin: retained.plugin.spec, + }, "debug") try { await Promise.resolve(retained.hooks.config(config as never)) + configContributions.push({ + plugin: retained.plugin, + outcome: "applied", + config: cloneWireValue(config, "configContribution.config"), + }) + logEvent("plugin.activation.config_hook.complete", { + instance_id: instance.id, + plugin: retained.plugin.spec, + }, "debug") } catch (error) { const diagnostic = runtimeDiagnostic(retained.plugin, "config", error) diagnostics.push(diagnostic) + configContributions.push({ + plugin: retained.plugin, + outcome: "failed", + config: cloneWireValue(config, "configContribution.config"), + }) + logError("plugin.activation.config_hook.failed", error, { + instance_id: instance.id, + plugin: retained.plugin.spec, + }) await publishDiagnostic(this.#rpc, toPublishedDiagnostic(instance.id, diagnostic)).catch(() => {}) } this.#assertOpening(instance) @@ -331,7 +364,16 @@ export class ExtensionHost { this.#assertOpening(instance) this.#indexRegistrations(instance, diagnostics) this.#assertOpening(instance) - const result = HostMethodSchemas["host.instance.open"].result.parse(openResult(instance, config, diagnostics)) + const result = HostMethodSchemas["host.instance.open"].result.parse( + openResult(instance, config, diagnostics, configContributions), + ) + logEvent("plugin.activation.registrations", { + instance_id: instance.id, + hook_count: result.hooks.length, + config_hook_count: instance.hooks.filter(({ hooks }) => typeof hooks.config === "function").length, + tool_count: result.tools.length, + diagnostic_count: result.diagnostics.length, + }) instance.status = "open" return result } catch (error) { @@ -446,8 +488,16 @@ export class ExtensionHost { return operation } - async callHook(input: { instanceID: string; name: string; input: WireValue; output: WireValue }) { + async callHook(input: { + instanceID: string + generationKey?: string + revision?: string + name: string + input: WireValue + output: WireValue + }) { const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) const hookInput = cloneWireValue(input.input, "input") const hookOutput = cloneWireValue(input.output, "output") @@ -462,6 +512,10 @@ export class ExtensionHost { } return { + instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, + hook: input.name, input: cloneWireValue(hookInput, "input"), output: cloneWireValue(hookOutput, "output"), } @@ -492,6 +546,8 @@ export class ExtensionHost { async executeTool(input: { instanceID: string + generationKey?: string + revision?: string registrationID: string executionID: string args: WireValue @@ -503,6 +559,7 @@ export class ExtensionHost { } }) { const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) const registration = findRegistration(instance.tools, input.registrationID) if (!registration) throw missingHandle("tool", input.registrationID) if (instance.activeTools.has(input.executionID)) { @@ -521,6 +578,8 @@ export class ExtensionHost { metadata: (metadata) => { const pending = this.#rpc.notify("backend.tool.metadata", { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, executionID: input.executionID, ...(cloneWireValue(metadata, "metadata") as Record), }) @@ -531,6 +590,8 @@ export class ExtensionHost { "backend.tool.ask", { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, executionID: input.executionID, ...(cloneWireValue(request, "request") as Record), }, @@ -538,7 +599,13 @@ export class ExtensionHost { ) }, }) - return cloneWireValue(result, "result") + return { + instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, + executionID: input.executionID, + result: cloneWireValue(result, "result"), + } } catch (error) { throw pluginError(registration.plugin, `tool:${registration.id}`, error) } finally { @@ -546,8 +613,10 @@ export class ExtensionHost { } } - cancelTool(input: { instanceID: string; executionID: string; reason?: string }) { - const controller = this.#instance(input.instanceID).activeTools.get(input.executionID) + cancelTool(input: { instanceID: string; generationKey?: string; revision?: string; executionID: string; reason?: string }) { + const instance = this.#instance(input.instanceID) + assertGeneration(instance, input.generationKey, input.revision) + const controller = instance.activeTools.get(input.executionID) if (!controller) return { cancelled: false } controller.abort(input.reason) return { cancelled: true } @@ -823,7 +892,10 @@ export class ExtensionHost { #beginClose(instance: Instance) { if (instance.closePromise) return instance.closePromise instance.status = "closing" - instance.closePromise = this.#disposeInstance(instance) + instance.closePromise = (async () => { + await instance.openDone + await this.#disposeInstance(instance) + })() return instance.closePromise } @@ -850,7 +922,6 @@ export class ExtensionHost { }).catch(() => {}) } finally { await instance.gateway.close().catch(() => {}) - await instance.openDone instance.flows.clear() instance.fetches.clear() @@ -980,10 +1051,23 @@ export class ExtensionHost { } } -function openResult(instance: Instance, config: WireValue, diagnostics: HostDiagnostic[]) { +function openResult( + instance: Instance, + config: WireValue, + diagnostics: HostDiagnostic[], + configContributions: Array<{ + plugin: PluginMeta + outcome: "applied" | "failed" + config: WireValue + }>, +) { return { instanceID: instance.id, + generationKey: instance.generationKey, + revision: instance.revision, config: cloneWireValue(config, "config"), + configContributors: configContributions.map(({ plugin, outcome }) => ({ plugin, outcome })), + configContributions, diagnostics: diagnostics.map(protocolDiagnostic), gatewayURL: instance.gateway.url.toString(), hooks: GENERIC_HOOKS.filter((name) => @@ -1012,6 +1096,14 @@ function openResult(instance: Instance, config: WireValue, diagnostics: HostDiag } } +function assertGeneration(instance: Instance, generationKey?: string, revision?: string) { + if (instance.generationKey === generationKey && instance.revision === revision) return + throw new ExtensionHostError(-32002, `Generation lease does not match instance ${instance.id}`, { + kind: "generation_mismatch", + instanceID: instance.id, + }) +} + function authDescriptor(provider: string, registration: AuthRegistration) { return { provider, diff --git a/src/apps/extension-host/src/main.ts b/src/apps/extension-host/src/main.ts index 931de00043..5a1cd5dc47 100644 --- a/src/apps/extension-host/src/main.ts +++ b/src/apps/extension-host/src/main.ts @@ -8,6 +8,7 @@ import { prepareBunPlugins } from "./bun-loader" import { BackendMethodSchemas, DEFAULT_MAX_FRAME_BYTES, + HOST_CAPABILITIES, OPENCODE_VERSION, PROTOCOL_VERSION, type BackendMethod, @@ -78,6 +79,7 @@ async function main() { protocolVersion: PROTOCOL_VERSION, opencodeVersion: OPENCODE_VERSION, maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + capabilities: [...HOST_CAPABILITIES], }), ) if (!path.isAbsolute(handshake.cacheDirectory)) { diff --git a/src/apps/extension-host/src/protocol.ts b/src/apps/extension-host/src/protocol.ts index 749a82f1c5..a66a889f41 100644 --- a/src/apps/extension-host/src/protocol.ts +++ b/src/apps/extension-host/src/protocol.ts @@ -2,7 +2,11 @@ import { z } from "zod" export const PROTOCOL_VERSION = 1 export const OPENCODE_VERSION = "1.17.18" -export const MIN_NEGOTIATED_FRAME_BYTES = 64 * 1024 +export const HOST_CAPABILITIES = [ + "config-contributors-v1", + "config-contributions-v2", + "generation-fencing-v1", +] as const export const DEFAULT_MAX_FRAME_BYTES = 16 * 1024 * 1024 export const MAX_MAX_FRAME_BYTES = 64 * 1024 * 1024 export const MAX_STREAM_CHUNK_BYTES = 64 * 1024 @@ -145,6 +149,19 @@ export const WorkspaceRegistrationSchema = z.object({ name: z.string(), description: z.string(), }) +export const PluginMetaSchema = z.object({ + id: z.string().optional(), + spec: z.string().min(1), + entry: z.string().min(1), + index: z.number().int().nonnegative(), +}) +export const ConfigContributorSchema = z.object({ + plugin: PluginMetaSchema, + outcome: z.enum(["applied", "failed"]), +}) +export const ConfigContributionSchema = ConfigContributorSchema.extend({ + config: JsonObjectSchema, +}) export const AuthSuccessSchema = z.union([ z.object({ type: z.literal("success"), @@ -203,6 +220,8 @@ export const HostMethodSchemas = { "host.instance.open": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), project: JsonValueSchema, config: JsonObjectSchema, directory: z.string(), @@ -212,7 +231,11 @@ export const HostMethodSchemas = { }), result: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), config: JsonObjectSchema, + configContributors: z.array(ConfigContributorSchema), + configContributions: z.array(ConfigContributionSchema), diagnostics: z.array(DiagnosticSchema), hooks: z.array(z.string()), tools: z.array(ToolRegistrationSchema), @@ -228,11 +251,20 @@ export const HostMethodSchemas = { "host.hook.call": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), + hook: z.string().min(1), + input: JsonValueSchema, + output: JsonValueSchema, + }), + result: z.object({ + instanceID: z.string().min(1).optional(), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), hook: z.string().min(1), input: JsonValueSchema, output: JsonValueSchema, }), - result: z.object({ input: JsonValueSchema, output: JsonValueSchema }), }, "host.event.emit": { params: z.object({ instanceID: z.string().min(1), event: JsonValueSchema }), @@ -241,6 +273,8 @@ export const HostMethodSchemas = { "host.tool.execute": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), registrationID: z.string().min(1), args: JsonValueSchema, @@ -251,11 +285,19 @@ export const HostMethodSchemas = { callID: z.string().optional(), }), }), - result: ToolResultSchema, + result: z.object({ + instanceID: z.string().min(1).optional(), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), + executionID: z.string().min(1), + result: ToolResultSchema, + }), }, "host.tool.cancel": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), reason: z.string().optional(), }), @@ -373,12 +415,14 @@ export const BackendMethodSchemas = { token: z.string().min(1), protocolVersion: z.literal(PROTOCOL_VERSION), opencodeVersion: z.literal(OPENCODE_VERSION), - maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), + capabilities: z.array(z.string().min(1)).optional(), }), result: z.object({ protocolVersion: z.literal(PROTOCOL_VERSION), - maxFrameBytes: z.number().int().min(MIN_NEGOTIATED_FRAME_BYTES).max(MAX_MAX_FRAME_BYTES), + maxFrameBytes: z.number().int().positive().max(MAX_MAX_FRAME_BYTES), cacheDirectory: z.string(), + capabilities: z.array(z.string().min(1)).optional(), }), }, "backend.http.request": { @@ -399,6 +443,8 @@ export const BackendMethodSchemas = { "backend.tool.ask": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), permission: z.string(), patterns: z.array(z.string()), @@ -410,6 +456,8 @@ export const BackendMethodSchemas = { "backend.tool.metadata": { params: z.object({ instanceID: z.string().min(1), + generationKey: z.string().min(1).optional(), + revision: z.string().min(1).optional(), executionID: z.string().min(1), title: z.string().optional(), metadata: JsonObjectSchema.optional(), diff --git a/src/apps/extension-host/src/service.ts b/src/apps/extension-host/src/service.ts index 6a4e443df2..445f25a415 100644 --- a/src/apps/extension-host/src/service.ts +++ b/src/apps/extension-host/src/service.ts @@ -39,7 +39,14 @@ export function registerHostMethods(input: { register("host.hook.call", async (params) => (await host()).callHook( (() => { - const value = params as { instanceID: string; hook: string; input: WireValue; output: WireValue } + const value = params as { + instanceID: string + generationKey?: string + revision?: string + hook: string + input: WireValue + output: WireValue + } return { ...value, name: value.hook } })(), ), diff --git a/src/apps/extension-host/test/fixtures/runtime/opening.js b/src/apps/extension-host/test/fixtures/runtime/opening.js new file mode 100644 index 0000000000..c43c5fb534 --- /dev/null +++ b/src/apps/extension-host/test/fixtures/runtime/opening.js @@ -0,0 +1,14 @@ +import { writeFileSync } from "node:fs" + +export default { + id: "fixture.opening", + server: async (_input, options = {}) => { + writeFileSync(options.started, "started") + await new Promise((resolve) => setTimeout(resolve, 30)) + return { + dispose() { + writeFileSync(options.disposed, "disposed") + }, + } + }, +} diff --git a/src/apps/extension-host/test/helpers/process-host.ts b/src/apps/extension-host/test/helpers/process-host.ts index 16b4ddd96e..9fc9bc607f 100644 --- a/src/apps/extension-host/test/helpers/process-host.ts +++ b/src/apps/extension-host/test/helpers/process-host.ts @@ -1,7 +1,7 @@ import { mkdir, mkdtemp, rm } from "node:fs/promises" import path from "node:path" import { tmpdir } from "node:os" -import { DEFAULT_MAX_FRAME_BYTES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" +import { DEFAULT_MAX_FRAME_BYTES, HOST_CAPABILITIES, OPENCODE_VERSION, PROTOCOL_VERSION } from "../../src/protocol" import { RpcPeer } from "../../src/rpc" type Handshake = { @@ -9,6 +9,7 @@ type Handshake = { protocolVersion: number opencodeVersion: string maxFrameBytes: number + capabilities: string[] } export async function launchExtensionHost( @@ -139,6 +140,7 @@ export function expectedHandshake(token = "test-rpc-token") { protocolVersion: PROTOCOL_VERSION, opencodeVersion: OPENCODE_VERSION, maxFrameBytes: DEFAULT_MAX_FRAME_BYTES, + capabilities: [...HOST_CAPABILITIES], } } diff --git a/src/apps/extension-host/test/host.test.ts b/src/apps/extension-host/test/host.test.ts index 54cd370281..da47e6ebaf 100644 --- a/src/apps/extension-host/test/host.test.ts +++ b/src/apps/extension-host/test/host.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdtemp, mkdir, rm } from "node:fs/promises" +import { tmpdir } from "node:os" import path from "node:path" import type { RpcConnection, StreamBridge, StreamDescriptor } from "../src/backend" import { ExtensionHost } from "../src/host" @@ -72,6 +73,18 @@ describe("ExtensionHost lifecycle and hooks", () => { }) expect(opened.config).toMatchObject({ order: ["a", "b"] }) + expect(opened.configContributors).toHaveLength(2) + expect(opened.configContributions).toHaveLength(2) + expect(opened.configContributions[0]).toMatchObject({ + plugin: expect.objectContaining({ id: "fixture.sequence-a" }), + outcome: "failed", + config: { order: ["a"] }, + }) + expect(opened.configContributions[1]).toMatchObject({ + plugin: expect.objectContaining({ id: "fixture.sequence-b" }), + outcome: "applied", + config: { order: ["a", "b"] }, + }) expect(opened.diagnostics).toHaveLength(1) expect(opened.diagnostics[0]).toMatchObject({ code: "runtime", method: "runtime" }) expect(opened.hooks).toContain("chat.message") @@ -82,7 +95,14 @@ describe("ExtensionHost lifecycle and hooks", () => { input: { order: [] }, output: { order: [] }, }) - expect(called).toEqual({ input: { order: ["a", "b"] }, output: { order: ["a", "b"] } }) + expect(called).toEqual({ + instanceID: "lifecycle", + generationKey: undefined, + revision: undefined, + hook: "chat.message", + input: { order: ["a", "b"] }, + output: { order: ["a", "b"] }, + }) expect(await harness.host.close({ instanceID: "lifecycle" })).toEqual({ closed: true }) expect(await Bun.file(disposeMarker).text()).toBe("a\nb\n") @@ -167,10 +187,16 @@ describe("ExtensionHost tools", () => { context: { sessionID: "session", messageID: "message", agent: "agent", callID: "call" }, }) expect(result).toEqual({ - title: "echo:hello", - output: `hello:${directory}:${directory}`, - metadata: { sessionID: "session", callID: "call" }, - attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + instanceID: "tools", + generationKey: undefined, + revision: undefined, + executionID: "execute-1", + result: { + title: "echo:hello", + output: `hello:${directory}:${directory}`, + metadata: { sessionID: "session", callID: "call" }, + attachments: [{ type: "file", mime: "text/plain", url: "data:text/plain,fixture", filename: "fixture.txt" }], + }, }) expect(harness.rpc.notifications).toContainEqual({ method: "backend.tool.metadata", @@ -441,21 +467,9 @@ describe("ExtensionHost instance isolation", () => { const harness = await createHarness() const firstDirectory = await projectDirectory(harness.root, "first-race") const secondDirectory = await projectDirectory(harness.root, "second-race") - const plugin = path.join(harness.root, "opening-plugin.ts") + const plugin = path.join(fixtures, "opening.js") const started = path.join(harness.root, "started.txt") const disposed = path.join(harness.root, "disposed.txt") - await Bun.write( - plugin, - `export default { - id: "fixture.opening", - server: async (_input, options) => { - await Bun.write(options.started, "started") - await Bun.sleep(30) - return { async dispose() { await Bun.write(options.disposed, "disposed") } } - }, - }\n`, - ) - const cancelledBeforeReady = harness.host.open({ instanceID: "cancel-before-ready", project: {}, @@ -641,7 +655,7 @@ async function projectDirectory(root: string, name: string) { } async function temporaryDirectory() { - const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-runtime-")) + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-runtime-")) temporaryDirectories.push(directory) return directory } diff --git a/src/apps/extension-host/test/loader.test.ts b/src/apps/extension-host/test/loader.test.ts index b80bd4ad13..896f568c8c 100644 --- a/src/apps/extension-host/test/loader.test.ts +++ b/src/apps/extension-host/test/loader.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises" +import { tmpdir } from "node:os" import path from "node:path" import { pathToFileURL } from "node:url" import { z } from "zod" @@ -420,7 +421,7 @@ describe("plugin tool schemas", () => { }) async function temporaryDirectory() { - const directory = await mkdtemp(path.join(process.env.TMPDIR ?? "/tmp", "opencode-extension-host-")) + const directory = await mkdtemp(path.join(tmpdir(), "opencode-extension-host-")) temporaryDirectories.push(directory) return directory } diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs index 5dd337d4a9..9915906c5c 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/operation.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -52,6 +52,8 @@ pub struct RuntimeUserAnswersRequest { #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct RuntimeAgentModeSummary { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs index 741f38d2d5..c07e9371e4 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -87,6 +87,7 @@ fn protocol_round_trips_read_only_main_agent_catalog() { let result = RuntimeIpcOperationResult::AgentModes { modes: vec![RuntimeAgentModeSummary { id: "review".to_string(), + route_key: "opencode:review".to_string(), description: "Review the current workspace".to_string(), model_id: Some("provider/model".to_string()), is_external: true, @@ -330,6 +331,7 @@ fn protocol_round_trips_the_reviewed_session_mode_operation() { request: AgentSessionModeUpdateRequest { session_id: "session-1".to_string(), mode_id: "ask".to_string(), + agent_route_key: None, }, }; diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs index a63b29e4dd..d7c350e799 100644 --- a/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/shared_controller.rs @@ -670,6 +670,7 @@ fn create_operation(workspace: &Path, name: &str) -> RuntimeIpcOperation { request: AgentSessionCreateRequest { session_name: name.to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.to_string_lossy().to_string()), project_workspace_path: None, execution_target: None, @@ -751,6 +752,7 @@ fn update_mode_operation(session_id: &str, mode_id: &str) -> RuntimeIpcOperation request: AgentSessionModeUpdateRequest { session_id: session_id.to_string(), mode_id: mode_id.to_string(), + agent_route_key: None, }, } } diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index c22c160ab3..6c7cdfa5f3 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -29,5 +29,8 @@ pub use reference_source::{ pub use skill_source::{ OpenCodeConfiguredSkillRoot, OpenCodeSkillRootProvider, OpenCodeSkillRootProviderOptions, }; -pub use source_adapter::load_opencode_package_adapter; +pub use source_adapter::{ + load_opencode_config_snapshot, load_opencode_package_adapter, OpenCodeConfigSnapshot, + OpenCodeConfigSnapshotError, +}; pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/source_adapter.rs b/src/crates/adapters/opencode-adapter/src/source_adapter.rs index 3201e20514..40e3aca77d 100644 --- a/src/crates/adapters/opencode-adapter/src/source_adapter.rs +++ b/src/crates/adapters/opencode-adapter/src/source_adapter.rs @@ -38,9 +38,13 @@ use oxc_parse::{ parser::Parser, span::SourceType, }; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::{collections::HashSet, path::Path, sync::Arc}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::Arc, +}; const OPENCODE_ADAPTER_ID: &str = "opencode-compatible"; const OPENCODE_CONFIG_SCHEMA: &str = "https://opencode.ai/config.json"; @@ -1696,6 +1700,53 @@ impl OpenCodeSourceProjection { } } +/// Typed snapshot of the OpenCode base configuration (`opencode.json`) passed +/// to the extension host when a workspace plugin instance opens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpenCodeConfigSnapshot { + /// URI of the `opencode.json` document the snapshot was read from. + pub config_uri: String, + /// Ordered, de-duplicated npm plugin package names from `opencode.json`. + pub npm_plugins: Vec, +} + +#[derive(Debug, thiserror::Error)] +pub enum OpenCodeConfigSnapshotError { + #[error("failed to read OpenCode config {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("invalid OpenCode config {path}: {message}")] + Invalid { path: PathBuf, message: String }, +} + +/// Load the typed base config for one local workspace. A missing +/// `opencode.json` is a valid empty snapshot; unreadable or invalid files fail +/// activation instead of silently sending a different configuration. +pub fn load_opencode_config_snapshot( + workspace: &Path, +) -> Result { + let path = workspace.join("opencode.json"); + let config_uri = source_file_uri(&path.to_string_lossy()); + let source = match std::fs::read_to_string(&path) { + Ok(source) => source, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(OpenCodeConfig::empty(config_uri).snapshot()); + } + Err(source) => { + return Err(OpenCodeConfigSnapshotError::Read { path, source }); + } + }; + parse_opencode_config(&source, &config_uri) + .map(|config| config.snapshot()) + .map_err(|error| OpenCodeConfigSnapshotError::Invalid { + path, + message: error.to_string(), + }) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct OpenCodeConfig { config_uri: String, @@ -1768,6 +1819,13 @@ impl OpenCodeConfig { npm_plugins, }) } + + fn snapshot(&self) -> OpenCodeConfigSnapshot { + OpenCodeConfigSnapshot { + config_uri: self.config_uri.clone(), + npm_plugins: self.npm_plugins.clone(), + } + } } #[derive(Debug, Deserialize)] @@ -2458,6 +2516,45 @@ mod opencode_projection_contracts { const CONFIG: &str = include_str!("../tests/fixtures/opencode-example/opencode.json"); const LOCAL_PLUGIN_PATH: &str = ".opencode/plugins/workspace-tools.ts"; + + #[test] + fn config_snapshot_loader_reads_workspace_config_and_deduplicates_plugins() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("opencode.json"), + serde_json::json!({ + "$schema": OPENCODE_CONFIG_SCHEMA, + "plugin": ["alpha", "alpha", "beta"] + }) + .to_string(), + ) + .expect("config"); + + let snapshot = load_opencode_config_snapshot(workspace.path()).expect("snapshot"); + assert_eq!(snapshot.npm_plugins, vec!["alpha", "beta"]); + assert!(snapshot.config_uri.starts_with("file://")); + } + + #[test] + fn config_snapshot_loader_accepts_missing_workspace_config_as_empty() { + let workspace = tempfile::tempdir().expect("workspace"); + let snapshot = load_opencode_config_snapshot(workspace.path()).expect("snapshot"); + assert!(snapshot.npm_plugins.is_empty()); + } + + #[test] + fn config_snapshot_loader_rejects_invalid_workspace_config() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write( + workspace.path().join("opencode.json"), + "{\"plugin\":[\"alpha\"]}", + ) + .expect("config"); + assert!(matches!( + load_opencode_config_snapshot(workspace.path()), + Err(OpenCodeConfigSnapshotError::Invalid { .. }) + )); + } const LOCAL_PLUGIN_SOURCE: &str = include_str!("../tests/fixtures/opencode-example/.opencode/plugins/workspace-tools.ts"); diff --git a/src/crates/adapters/opencode-plugin-host/src/lib.rs b/src/crates/adapters/opencode-plugin-host/src/lib.rs index 52b206da72..c246b0295a 100644 --- a/src/crates/adapters/opencode-plugin-host/src/lib.rs +++ b/src/crates/adapters/opencode-plugin-host/src/lib.rs @@ -7,8 +7,9 @@ mod stream_registry; use bitfun_services_core::process_tree::{CleanupOutcome, ProcessTreeChild}; use rand::{distributions::Alphanumeric, Rng}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; @@ -31,12 +32,57 @@ pub use stream_registry::{ }; const PROTOCOL_VERSION: u64 = 1; -const MIN_NEGOTIATED_FRAME_BYTES: usize = 64 * 1024; const DEFAULT_MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024; const STARTUP_TIMEOUT: Duration = Duration::from_secs(15); +pub const CONFIG_CONTRIBUTORS_V1: &str = "config-contributors-v1"; +pub const CONFIG_CONTRIBUTIONS_V2: &str = "config-contributions-v2"; +pub const GENERATION_FENCING_V1: &str = "generation-fencing-v1"; static NEXT_CONNECTION_GENERATION: AtomicU64 = AtomicU64::new(1); +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginHostCapabilities { + negotiated: BTreeSet, +} + +impl PluginHostCapabilities { + pub fn all_supported() -> Self { + Self { + negotiated: [ + CONFIG_CONTRIBUTORS_V1, + CONFIG_CONTRIBUTIONS_V2, + GENERATION_FENCING_V1, + ] + .into_iter() + .map(str::to_string) + .collect(), + } + } + + pub fn supports(&self, capability: &str) -> bool { + self.negotiated.contains(capability) + } + + pub fn values(&self) -> impl Iterator { + self.negotiated.iter().map(String::as_str) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginGenerationLease { + #[serde(rename = "instanceID")] + pub instance_id: String, + pub generation_key: String, + pub revision: String, +} + +#[derive(Debug)] +struct HandshakeNegotiation { + max_frame_bytes: usize, + capabilities: PluginHostCapabilities, +} + #[derive(Debug, Clone)] pub struct PluginHostConfig { pub runtime_command: PathBuf, @@ -72,6 +118,8 @@ pub struct PluginPrepareRequest { pub struct PluginInstanceOpenRequest { #[serde(rename = "instanceID")] pub instance_id: String, + pub generation_key: String, + pub revision: String, pub project: Value, pub config: serde_json::Map, pub directory: String, @@ -93,20 +141,12 @@ pub enum PluginHostError { PrepareCache(#[source] std::io::Error), #[error("failed to bind plugin host listener: {0}")] Bind(#[source] std::io::Error), - #[error("plugin host runtime executable was not found: {0}")] - RuntimeNotFound(PathBuf), #[error("failed to start plugin host runtime: {0}")] Spawn(#[source] std::io::Error), #[error("failed to prepare plugin host log: {0}")] PrepareLog(#[source] std::io::Error), #[error("plugin host did not connect within the startup timeout")] StartupTimeout, - #[error("plugin host startup failed ({startup}) and process-tree cleanup failed: {cleanup}")] - StartupCleanup { - startup: String, - #[source] - cleanup: std::io::Error, - }, #[error("plugin host IPC failed: {0}")] Io(#[source] std::io::Error), #[error("plugin host handshake frame is invalid: {0}")] @@ -169,7 +209,6 @@ pub enum PluginHostShutdownDisposition { pub struct PluginHostShutdownReport { pub generation: u64, pub disposition: PluginHostShutdownDisposition, - pub reaped: bool, pub rpc_completed: bool, pub exit_code: Option, pub duration_ms: u64, @@ -177,13 +216,6 @@ pub struct PluginHostShutdownReport { impl PluginHost { pub async fn start(config: PluginHostConfig) -> Result { - Self::start_with_timeout(config, STARTUP_TIMEOUT).await - } - - async fn start_with_timeout( - config: PluginHostConfig, - startup_timeout: Duration, - ) -> Result { validate_config(&config)?; tokio::fs::create_dir_all(&config.cache_directory) .await @@ -210,45 +242,28 @@ impl PluginHost { .stderr(Stdio::piped()); let mut child = ProcessTreeChild::spawn(&mut command) .await - .map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - PluginHostError::RuntimeNotFound(config.runtime_command.clone()) - } else { - PluginHostError::Spawn(error) - } - })?; - let host_log = match host_log::attach_host_log(&mut child, &config.log_file).await { - Ok(host_log) => host_log, - Err(error) => { - return Err(cleanup_failed_start( - &mut child, - None, - PluginHostError::PrepareLog(error), - ) - .await); - } - }; + .map_err(PluginHostError::Spawn)?; + let host_log = host_log::attach_host_log(&mut child, &config.log_file) + .await + .map_err(PluginHostError::PrepareLog)?; - let (stream, max_frame_bytes) = match accept_authenticated_connection( - &listener, - &token, - &config.cache_directory, - startup_timeout, - ) - .await - { - Ok(connection) => connection, - Err(error) => { - return Err(cleanup_failed_start(&mut child, Some(host_log), error).await); - } - }; + let (mut stream, _) = tokio::time::timeout(STARTUP_TIMEOUT, listener.accept()) + .await + .map_err(|_| PluginHostError::StartupTimeout)? + .map_err(PluginHostError::Io)?; + let negotiation = complete_handshake(&mut stream, &token, &config.cache_directory).await?; let generation = NEXT_CONNECTION_GENERATION.fetch_add(1, Ordering::Relaxed); - let peer = JsonRpcPeer::start(stream, generation, max_frame_bytes); + let peer = JsonRpcPeer::start_with_capabilities( + stream, + generation, + negotiation.max_frame_bytes, + negotiation.capabilities, + ); Ok(Self { child, client: peer.client(), host_log: Some(host_log), - max_frame_bytes, + max_frame_bytes: negotiation.max_frame_bytes, }) } @@ -310,10 +325,8 @@ impl PluginHost { } else { PluginHostShutdownDisposition::ExitedAfterShutdown }; - let mut report = + let report = shutdown_report(generation, disposition, true, status.code(), started_at); - report.reaped = - reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; if report.disposition == PluginHostShutdownDisposition::Graceful { log::info!( "Plugin host exited gracefully: generation={}, exit_code={:?}, duration_ms={}", @@ -347,15 +360,13 @@ impl PluginHost { .close("plugin host graceful shutdown fallback") .await; if let Ok(Ok(status)) = tokio::time::timeout(policy.eof_timeout, self.child.wait()).await { - let mut report = shutdown_report( + let report = shutdown_report( generation, PluginHostShutdownDisposition::ExitedAfterConnectionClose, rpc_completed, status.code(), started_at, ); - report.reaped = - reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; log::info!( "Plugin host exited after RPC connection close: generation={}, exit_code={:?}, duration_ms={}", generation, @@ -366,21 +377,37 @@ impl PluginHost { return report; } - let reaped = reap_process_tree(&mut self.child, policy.terminate_grace, generation).await; + let cleanup = self.child.terminate(policy.terminate_grace).await; let exit_code = self .child .try_wait() .ok() .flatten() .and_then(|status| status.code()); - let mut report = shutdown_report( + let report = shutdown_report( generation, PluginHostShutdownDisposition::Forced, rpc_completed, exit_code, started_at, ); - report.reaped = reaped; + match cleanup { + Ok(CleanupOutcome::AlreadyExited) => log::warn!( + "Plugin host exited during forced cleanup: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Ok(_) => log::warn!( + "Plugin host process tree terminated: generation={}, duration_ms={}", + generation, + report.duration_ms + ), + Err(error) => log::error!( + "Plugin host process tree termination failed: generation={}, error={}", + generation, + error + ), + } self.flush_host_log(policy.eof_timeout).await; report } @@ -398,62 +425,6 @@ impl PluginHost { } } -async fn reap_process_tree(child: &mut ProcessTreeChild, grace: Duration, generation: u64) -> bool { - match child.terminate(grace).await { - Ok(CleanupOutcome::AlreadyExited) => { - log::info!("Plugin host process tree already exited: generation={generation}"); - true - } - Ok(_) => { - log::info!("Plugin host process tree reaped: generation={generation}"); - true - } - Err(error) => { - log::error!( - "Plugin host process tree termination failed: generation={}, error={}", - generation, - error - ); - false - } - } -} - -async fn cleanup_failed_start( - child: &mut ProcessTreeChild, - host_log: Option, - startup: PluginHostError, -) -> PluginHostError { - let policy = PluginHostShutdownPolicy::default(); - let cleanup = child.terminate(policy.terminate_grace).await; - if let Some(host_log) = host_log { - let _ = host_log.flush(policy.eof_timeout).await; - } - match cleanup { - Ok(_) => startup, - Err(cleanup) => PluginHostError::StartupCleanup { - startup: startup.to_string(), - cleanup, - }, - } -} - -async fn accept_authenticated_connection( - listener: &TcpListener, - expected_token: &str, - cache_directory: &Path, - startup_timeout: Duration, -) -> Result<(TcpStream, usize), PluginHostError> { - tokio::time::timeout(startup_timeout, async { - let (mut stream, _) = listener.accept().await.map_err(PluginHostError::Io)?; - let max_frame_bytes = - complete_handshake(&mut stream, expected_token, cache_directory).await?; - Ok((stream, max_frame_bytes)) - }) - .await - .map_err(|_| PluginHostError::StartupTimeout)? -} - fn shutdown_report( generation: u64, disposition: PluginHostShutdownDisposition, @@ -464,7 +435,6 @@ fn shutdown_report( PluginHostShutdownReport { generation, disposition, - reaped: false, rpc_completed, exit_code, duration_ms: elapsed_ms(started_at), @@ -494,7 +464,7 @@ async fn complete_handshake( stream: &mut TcpStream, expected_token: &str, cache_directory: &Path, -) -> Result { +) -> Result { let request = read_frame(stream, DEFAULT_MAX_FRAME_BYTES).await?; let jsonrpc = request.get("jsonrpc").and_then(Value::as_str); let method = request.get("method").and_then(Value::as_str); @@ -513,6 +483,16 @@ async fn complete_handshake( .and_then(|params| params.get("maxFrameBytes")) .and_then(Value::as_u64) .and_then(|value| usize::try_from(value).ok()); + let requested_capabilities = params + .and_then(|params| params.get("capabilities")) + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); if jsonrpc != Some("2.0") || method != Some("backend.handshake") || request_id.is_none() @@ -527,18 +507,32 @@ async fn complete_handshake( } let max_frame_bytes = requested_frame_bytes .unwrap_or(DEFAULT_MAX_FRAME_BYTES) - .clamp(MIN_NEGOTIATED_FRAME_BYTES, MAX_FRAME_BYTES); + .min(DEFAULT_MAX_FRAME_BYTES) + .min(MAX_FRAME_BYTES); + let negotiated = [ + CONFIG_CONTRIBUTORS_V1, + CONFIG_CONTRIBUTIONS_V2, + GENERATION_FENCING_V1, + ] + .into_iter() + .filter(|capability| requested_capabilities.contains(capability)) + .map(str::to_string) + .collect::>(); let response = json!({ "jsonrpc": "2.0", "id": request_id, "result": { "protocolVersion": PROTOCOL_VERSION, "maxFrameBytes": max_frame_bytes, - "cacheDirectory": cache_directory.to_string_lossy() + "cacheDirectory": cache_directory.to_string_lossy(), + "capabilities": negotiated } }); write_frame(stream, &response, DEFAULT_MAX_FRAME_BYTES).await?; - Ok(max_frame_bytes) + Ok(HandshakeNegotiation { + max_frame_bytes, + capabilities: PluginHostCapabilities { negotiated }, + }) } #[cfg(test)] diff --git a/src/crates/adapters/opencode-plugin-host/src/peer.rs b/src/crates/adapters/opencode-plugin-host/src/peer.rs index e2ebf9719c..d2f4f53f10 100644 --- a/src/crates/adapters/opencode-plugin-host/src/peer.rs +++ b/src/crates/adapters/opencode-plugin-host/src/peer.rs @@ -1,5 +1,8 @@ use crate::peer_runtime::{run_reader, run_writer}; -use crate::{PluginHostError, PluginInstanceOpenRequest, PluginPrepareRequest}; +use crate::{ + PluginGenerationLease, PluginHostCapabilities, PluginHostError, PluginInstanceOpenRequest, + PluginPrepareRequest, GENERATION_FENCING_V1, +}; use serde_json::{json, Value}; use std::collections::HashMap; use std::future::Future; @@ -51,6 +54,10 @@ impl PluginHostClient { self.state.closed.load(Ordering::Acquire) } + pub fn capabilities(&self) -> &PluginHostCapabilities { + &self.state.capabilities + } + pub async fn set_log_level(&self, level: &str) -> Result<(), PluginHostError> { let result = self .request( @@ -109,6 +116,91 @@ impl PluginHostClient { }) } + pub async fn call_hook( + &self, + lease: &PluginGenerationLease, + hook: &str, + input: Value, + output: Value, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + let result = self + .request( + "host.hook.call", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "hook": hook, + "input": input, + "output": output, + }), + deadline, + ) + .await?; + validate_fenced_response(&result, lease, None)?; + if result.get("hook").and_then(Value::as_str) != Some(hook) { + return Err(PluginHostError::Protocol( + "host.hook.call response returned a mismatched hook name".to_string(), + )); + } + Ok(result) + } + + pub async fn execute_tool( + &self, + lease: &PluginGenerationLease, + execution_id: &str, + registration_id: &str, + args: Value, + context: Value, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + let result = self + .request( + "host.tool.execute", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "executionID": execution_id, + "registrationID": registration_id, + "args": args, + "context": context, + }), + deadline, + ) + .await?; + validate_fenced_response(&result, lease, Some(execution_id))?; + result.get("result").cloned().ok_or_else(|| { + PluginHostError::Protocol("host.tool.execute response is missing result".to_string()) + }) + } + + pub async fn cancel_tool( + &self, + lease: &PluginGenerationLease, + execution_id: &str, + reason: Option<&str>, + deadline: Duration, + ) -> Result { + self.require_generation_fencing()?; + self.request( + "host.tool.cancel", + json!({ + "instanceID": lease.instance_id, + "generationKey": lease.generation_key, + "revision": lease.revision, + "executionID": execution_id, + "reason": reason, + }), + deadline, + ) + .await + } + pub async fn request( &self, method: &str, @@ -258,6 +350,36 @@ impl PluginHostClient { ); Ok(()) } + + fn require_generation_fencing(&self) -> Result<(), PluginHostError> { + if self.capabilities().supports(GENERATION_FENCING_V1) { + Ok(()) + } else { + Err(PluginHostError::Protocol( + "plugin host did not negotiate generation-fencing-v1".to_string(), + )) + } + } +} + +fn validate_fenced_response( + result: &Value, + lease: &PluginGenerationLease, + execution_id: Option<&str>, +) -> Result<(), PluginHostError> { + let identity_matches = result.get("instanceID").and_then(Value::as_str) + == Some(lease.instance_id.as_str()) + && result.get("generationKey").and_then(Value::as_str) + == Some(lease.generation_key.as_str()) + && result.get("revision").and_then(Value::as_str) == Some(lease.revision.as_str()); + let execution_matches = execution_id + .is_none_or(|expected| result.get("executionID").and_then(Value::as_str) == Some(expected)); + if identity_matches && execution_matches { + return Ok(()); + } + Err(PluginHostError::Protocol( + "plugin host response generation lease does not match the request".to_string(), + )) } pub struct JsonRpcPeer { @@ -266,9 +388,24 @@ pub struct JsonRpcPeer { impl JsonRpcPeer { pub fn start(stream: TcpStream, generation: u64, max_frame_bytes: usize) -> Self { + Self::start_with_capabilities( + stream, + generation, + max_frame_bytes, + PluginHostCapabilities::default(), + ) + } + + pub fn start_with_capabilities( + stream: TcpStream, + generation: u64, + max_frame_bytes: usize, + capabilities: PluginHostCapabilities, + ) -> Self { let (outbound, receiver) = mpsc::channel(OUTBOUND_CAPACITY); let state = Arc::new(PeerState { generation, + capabilities, max_frame_bytes, sequence: AtomicU64::new(0), admission: Mutex::new(()), @@ -296,6 +433,7 @@ impl JsonRpcPeer { pub(super) struct PeerState { pub(super) generation: u64, + pub(super) capabilities: PluginHostCapabilities, pub(super) max_frame_bytes: usize, pub(super) sequence: AtomicU64, pub(super) admission: Mutex<()>, diff --git a/src/crates/adapters/opencode-plugin-host/src/tests.rs b/src/crates/adapters/opencode-plugin-host/src/tests.rs index 8c71d21b90..44f5a7fcab 100644 --- a/src/crates/adapters/opencode-plugin-host/src/tests.rs +++ b/src/crates/adapters/opencode-plugin-host/src/tests.rs @@ -50,7 +50,8 @@ async fn handshake_accepts_matching_token_and_returns_cache_directory() { "token": "test-token", "protocolVersion": 1, "opencodeVersion": "1.17.18", - "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES + "maxFrameBytes": DEFAULT_MAX_FRAME_BYTES, + "capabilities": ["config-contributors-v1", "config-contributions-v2", "generation-fencing-v1", "unknown-v1"] } }), DEFAULT_MAX_FRAME_BYTES, @@ -71,7 +72,11 @@ async fn handshake_accepts_matching_token_and_returns_cache_directory() { .expect("matching handshake should succeed"); let response = host.await.expect("fake host task should finish"); - assert_eq!(negotiated, DEFAULT_MAX_FRAME_BYTES); + assert_eq!(negotiated.max_frame_bytes, DEFAULT_MAX_FRAME_BYTES); + assert!(negotiated.capabilities.supports("config-contributors-v1")); + assert!(negotiated.capabilities.supports("config-contributions-v2")); + assert!(negotiated.capabilities.supports("generation-fencing-v1")); + assert!(!negotiated.capabilities.supports("unknown-v1")); assert_eq!( response["result"]["cacheDirectory"], expected_cache_directory diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs index ec36963e6d..98782964bc 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -17,6 +17,7 @@ pub(crate) struct ExternalProvidedAgent { prompt: String, tools: Vec, permission_constraints: PermissionConstraintLayer, + temperature: Option, readonly: bool, behavior_version: String, } @@ -29,6 +30,7 @@ impl ExternalProvidedAgent { prompt: String, tools: Vec, permission_constraints: PermissionConstraintLayer, + temperature: Option, readonly: bool, behavior_version: String, ) -> Self { @@ -39,6 +41,7 @@ impl ExternalProvidedAgent { prompt, tools, permission_constraints, + temperature, readonly, behavior_version, } @@ -85,6 +88,10 @@ impl Agent for ExternalProvidedAgent { &self.permission_constraints } + fn model_temperature_override(&self) -> Option { + self.temperature + } + fn user_context_policy(&self) -> UserContextPolicy { default_custom_agent_user_context_policy(CustomAgentKind::Subagent) } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index b8dba65560..abb3eb12f1 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -281,6 +281,13 @@ pub trait Agent: Send + Sync + 'static { true } + /// Optional model sampling temperature supplied by an external Agent + /// definition. The execution owner applies this to a per-turn client + /// clone; built-in Agents inherit the configured model temperature. + fn model_temperature_override(&self) -> Option { + None + } + /// Whether this agent is read-only (prevents file modifications) fn is_readonly(&self) -> bool { false diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs index c04aa2f7c4..b21b402d60 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/external.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -56,6 +56,7 @@ impl ExternalSubagentModelBinding { pub struct ExternalSubagentRegistration { pub runtime_key: String, pub logical_id: String, + pub route_key: String, pub ecosystem_id: EcosystemId, pub provider_label: String, pub model_binding: ExternalSubagentModelBinding, @@ -96,6 +97,8 @@ struct ExternalSubagentGenerationEntry { pub(super) struct ExternalSubagentRegistryState { generations: RwLock>, workspace_routes: RwLock>>, + workspace_route_overlays: + RwLock>>>, } impl ExternalSubagentRegistryState { @@ -103,6 +106,7 @@ impl ExternalSubagentRegistryState { Self { generations: RwLock::new(HashMap::new()), workspace_routes: RwLock::new(HashMap::new()), + workspace_route_overlays: RwLock::new(HashMap::new()), } } @@ -140,18 +144,81 @@ impl ExternalSubagentRegistryState { .unwrap_or_else(std::sync::PoisonError::into_inner) } + fn read_route_overlays( + &self, + ) -> std::sync::RwLockReadGuard< + '_, + HashMap>>, + > { + self.workspace_route_overlays + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_route_overlays( + &self, + ) -> std::sync::RwLockWriteGuard< + '_, + HashMap>>, + > { + self.workspace_route_overlays + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn effective_routes_for_workspace( + &self, + workspace_root: &Path, + ) -> BTreeMap { + let mut effective = self + .read_routes() + .get(workspace_root) + .cloned() + .unwrap_or_default(); + if let Some(overlays) = self.read_route_overlays().get(workspace_root) { + // Owner keys provide deterministic overlay precedence. The + // OpenCode Config owner is currently the only overlay publisher; + // unlike the base table, removing it reveals the latest route + // published by another external-source owner. + for routes in overlays.values() { + effective.extend(routes.clone()); + } + } + effective + } + pub(super) fn find_generation_entry(&self, runtime_key: &str) -> Option { self.read_generations() .get(runtime_key) .map(|entry| entry.agent_entry.clone()) } + /// Resolve a user-facing logical Agent id through the external route table + /// for one workspace. External generations are keyed by an opaque runtime + /// key, while sessions and product surfaces use the logical id. + pub(super) fn find_external_route_entry( + &self, + logical_id: &str, + workspace_root: &Path, + ) -> Option { + let workspace_root = canonical_local_workspace_path(workspace_root); + let logical_key = normalize_external_logical_id(logical_id); + let runtime_key = match self + .effective_routes_for_workspace(&workspace_root) + .get(&logical_key)? + { + ExternalSubagentRoute::External(runtime_key) => runtime_key.clone(), + ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => return None, + }; + self.find_generation_entry(&runtime_key) + } + pub(super) fn has_generation(&self, runtime_key: &str) -> bool { self.read_generations().contains_key(runtime_key) } fn prune_unrouted_generations(&self) { - let routed = self + let mut routed = self .read_routes() .values() .flat_map(BTreeMap::values) @@ -160,6 +227,16 @@ impl ExternalSubagentRegistryState { ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, }) .collect::>(); + routed.extend( + self.read_route_overlays() + .values() + .flat_map(BTreeMap::values) + .flat_map(BTreeMap::values) + .filter_map(|route| match route { + ExternalSubagentRoute::External(runtime_key) => Some(runtime_key.clone()), + ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, + }), + ); self.write_generations() .retain(|runtime_key, entry| entry.lease_count > 0 || routed.contains(runtime_key)); } @@ -212,6 +289,7 @@ impl ExternalSubagentRegistryState { let model_binding = entry.registration.model_binding.clone(); Some(ExternalPrimaryAgentTurnBinding { runtime_agent_key: runtime_key.to_string(), + route_key: Some(entry.registration.route_key.clone()), model_binding: Some(model_binding.clone()), route_owner: SessionAgentRouteOwner::External, lease: Some(ExternalSubagentGenerationLease { @@ -286,12 +364,22 @@ pub struct ExternalSubagentInvocationBinding { pub struct ExternalPrimaryAgentTurnBinding { pub runtime_agent_key: String, + pub route_key: Option, pub model_binding: Option, pub route_owner: SessionAgentRouteOwner, pub lease: Option, } impl AgentRegistry { + pub(super) fn find_external_route_entry( + &self, + logical_id: &str, + workspace_root: &Path, + ) -> Option { + self.external_subagents + .find_external_route_entry(logical_id, workspace_root) + } + /// Returns whether the logical id is owned by an external route in the /// requested workspace. `Unavailable` remains externally owned so a /// withdrawn candidate cannot expose a same-name local mutation path. @@ -300,7 +388,6 @@ impl AgentRegistry { logical_id: &str, workspace_root: Option<&Path>, ) -> bool { - let routes = self.external_subagents.read_routes(); let logical_key = normalize_external_logical_id(logical_id); let is_external = |route: &ExternalSubagentRoute| { matches!( @@ -310,9 +397,9 @@ impl AgentRegistry { }; workspace_root.is_some_and(|workspace| { let workspace = canonical_local_workspace_path(workspace); - routes - .get(&workspace) - .and_then(|workspace_routes| workspace_routes.get(&logical_key)) + self.external_subagents + .effective_routes_for_workspace(&workspace) + .get(&logical_key) .is_some_and(is_external) }) } @@ -322,6 +409,38 @@ impl AgentRegistry { workspace_root: &Path, registrations: Vec, routes: BTreeMap, + ) { + self.install_external_subagent_routes_with_policy( + workspace_root, + registrations, + routes, + true, + ); + } + + /// Atomically publish a complete, validated external route generation. + /// Routes omitted by the new generation are intentionally released so a + /// same-name local Agent becomes visible again. + pub fn replace_external_subagent_routes( + &self, + workspace_root: &Path, + registrations: Vec, + routes: BTreeMap, + ) { + self.install_external_subagent_routes_with_policy( + workspace_root, + registrations, + routes, + false, + ); + } + + fn install_external_subagent_routes_with_policy( + &self, + workspace_root: &Path, + registrations: Vec, + routes: BTreeMap, + preserve_missing_external_routes: bool, ) { let workspace_root = canonical_local_workspace_path(workspace_root); { @@ -359,12 +478,12 @@ impl AgentRegistry { .get(&workspace_root) .cloned() .unwrap_or_default(); - // An active external implementation disappearing must never expose a - // same-name local implementation implicitly. Keep a fail-closed route - // until the external candidate returns or product reconciliation - // records an explicit Local choice. + // Discovery and temporary-unavailable updates preserve missing + // external ownership. A validated plugin generation replacement does + // not, because removing a contributed Agent must restore local routing. for (logical_id, previous_route) in previous { - if !routes.contains_key(&logical_id) + if preserve_missing_external_routes + && !routes.contains_key(&logical_id) && matches!( previous_route, ExternalSubagentRoute::External(_) | ExternalSubagentRoute::Unavailable @@ -387,6 +506,70 @@ impl AgentRegistry { self.external_subagents.prune_unrouted_generations(); } + /// Publish a complete route overlay owned by one extension source. + /// Replacing or removing this owner never mutates the base external-source + /// routes for the workspace. + pub fn replace_external_subagent_route_overlay( + &self, + workspace_root: &Path, + owner: &str, + registrations: Vec, + routes: BTreeMap, + ) { + let workspace_root = canonical_local_workspace_path(workspace_root); + let routes = routes + .into_iter() + .map(|(logical_id, route)| (normalize_external_logical_id(&logical_id), route)) + .collect(); + // Hold the overlay publication lock before making its generations + // visible. A concurrent base-route refresh may prune generations, but + // it cannot observe the new entries without also observing this route + // overlay. + let mut overlays = self.external_subagents.write_route_overlays(); + { + let mut generations = self.external_subagents.write_generations(); + for registration in registrations { + let runtime_key = registration.runtime_key.clone(); + let lease_count = generations + .get(&runtime_key) + .map_or(0, |entry| entry.lease_count); + let agent_entry = AgentEntry { + category: AgentCategory::SubAgent, + source: AgentSource::External, + subagent_source: Some(SubAgentSource::External), + agent: registration.agent.clone(), + visibility_policy: SubagentVisibilityPolicy::public(), + custom_config: None, + }; + generations.insert( + runtime_key, + ExternalSubagentGenerationEntry { + registration, + agent_entry, + lease_count, + }, + ); + } + } + let workspace_overlays = overlays.entry(workspace_root).or_default(); + workspace_overlays.insert(owner.to_string(), routes); + drop(overlays); + self.external_subagents.prune_unrouted_generations(); + } + + pub fn release_external_subagent_route_overlay(&self, workspace_root: &Path, owner: &str) { + let workspace_root = canonical_local_workspace_path(workspace_root); + let mut overlays = self.external_subagents.write_route_overlays(); + if let Some(workspace_overlays) = overlays.get_mut(&workspace_root) { + workspace_overlays.remove(owner); + if workspace_overlays.is_empty() { + overlays.remove(&workspace_root); + } + } + drop(overlays); + self.external_subagents.prune_unrouted_generations(); + } + pub fn resolve_subagent_for_fresh_invocation( &self, logical_id: &str, @@ -399,9 +582,8 @@ impl AgentRegistry { let workspace_key = canonical_local_workspace_path(workspace_root); if let Some(route) = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned() { return match route { @@ -429,6 +611,23 @@ impl AgentRegistry { workspace_root: Option<&Path>, external_sources_supported: bool, expected_owner: Option, + ) -> Option { + self.resolve_primary_agent_for_turn_with_route( + logical_id, + workspace_root, + external_sources_supported, + expected_owner, + None, + ) + } + + pub fn resolve_primary_agent_for_turn_with_route( + &self, + logical_id: &str, + workspace_root: Option<&Path>, + external_sources_supported: bool, + expected_owner: Option, + expected_route_key: Option<&str>, ) -> Option { let logical_key = normalize_external_logical_id(logical_id); if external_sources_supported { @@ -436,16 +635,15 @@ impl AgentRegistry { let workspace_key = canonical_local_workspace_path(workspace_root); if let Some(route) = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned() { let binding = match route { ExternalSubagentRoute::Local => { match self.find_agent_entry(logical_id, Some(workspace_root)) { Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) + Some(local_primary_binding(&entry)) } Some(entry) => { warn!( @@ -466,6 +664,9 @@ impl AgentRegistry { }; return binding.filter(|binding| { expected_owner.is_none_or(|owner| binding.route_owner == owner) + && expected_route_key.is_none_or(|route_key| { + binding.route_key.as_deref() == Some(route_key) + }) }); } } @@ -473,9 +674,9 @@ impl AgentRegistry { if expected_owner == Some(SessionAgentRouteOwner::External) { return None; } - match self.find_agent_entry(logical_id, workspace_root) { + let binding = match self.find_agent_entry(logical_id, workspace_root) { Some(entry) if is_local_session_primary_entry(&entry) => { - Some(local_primary_binding(entry.agent.id())) + Some(local_primary_binding(&entry)) } Some(entry) => { warn!( @@ -494,7 +695,11 @@ impl AgentRegistry { ); None } - } + }; + binding.filter(|binding| { + expected_route_key + .is_none_or(|route_key| binding.route_key.as_deref() == Some(route_key)) + }) } /// Resolve only the currently approved external route for an exact @@ -511,9 +716,8 @@ impl AgentRegistry { let logical_key = normalize_external_logical_id(logical_id); let route = self .external_subagents - .read_routes() - .get(&workspace_key) - .and_then(|routes| routes.get(&logical_key)) + .effective_routes_for_workspace(&workspace_key) + .get(&logical_key) .cloned()?; match route { ExternalSubagentRoute::External(runtime_key) => { @@ -535,10 +739,7 @@ impl AgentRegistry { let workspace_root = canonical_local_workspace_path(workspace_root); let routes = self .external_subagents - .read_routes() - .get(&workspace_root) - .cloned() - .unwrap_or_default(); + .effective_routes_for_workspace(&workspace_root); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { @@ -572,10 +773,7 @@ impl AgentRegistry { let workspace_root = canonical_local_workspace_path(workspace_root); let routes = self .external_subagents - .read_routes() - .get(&workspace_root) - .cloned() - .unwrap_or_default(); + .effective_routes_for_workspace(&workspace_root); let generations = self.external_subagents.read_generations(); for (logical_id, route) in routes { match route { @@ -642,9 +840,11 @@ fn is_local_session_primary_entry(entry: &AgentEntry) -> bool { && is_builtin_session_primary_agent(entry.agent.id())) } -fn local_primary_binding(runtime_agent_key: &str) -> ExternalPrimaryAgentTurnBinding { +fn local_primary_binding(entry: &AgentEntry) -> ExternalPrimaryAgentTurnBinding { + let route_key = AgentInfo::from_agent_entry(entry).key; ExternalPrimaryAgentTurnBinding { - runtime_agent_key: runtime_agent_key.to_string(), + runtime_agent_key: entry.agent.id().to_string(), + route_key: Some(route_key), model_binding: None, route_owner: SessionAgentRouteOwner::Local, lease: None, @@ -658,11 +858,7 @@ fn external_agent_info( let agent = entry.registration.agent.as_ref(); let default_tools = agent.default_tools(); AgentInfo { - key: format!( - "external::{}::{}", - entry.registration.provider_label.to_ascii_lowercase(), - entry.registration.logical_id - ), + key: entry.registration.route_key.clone(), id: entry.registration.logical_id.clone(), name: agent.name().to_string(), description: agent.description().to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index a6fdb41951..e7eedb5ef1 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -150,6 +150,34 @@ impl AgentRegistry { .map(|entry| entry.agent) } + /// Resolve the effective non-external definition that an external route + /// displaces. Plugin generations use this immutable baseline instead of + /// accidentally inheriting a previous external generation. + pub(crate) fn get_local_agent( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option> { + if let Some(entry) = self.read_agents().values().find(|entry| { + entry.source != types::AgentSource::External + && entry.agent.id().eq_ignore_ascii_case(agent_type) + }) { + return Some(entry.agent.clone()); + } + workspace_root + .and_then(|root| { + self.read_project_subagents() + .get(root)? + .values() + .find(|entry| { + entry.source != types::AgentSource::External + && entry.agent.id().eq_ignore_ascii_case(agent_type) + }) + .cloned() + }) + .map(|entry| entry.agent) + } + /// Check if an agent exists pub fn check_agent_exists(&self, agent_type: &str) -> bool { self.external_subagents.has_generation(agent_type) diff --git a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs index 896fd2a804..13c9b13655 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs @@ -42,12 +42,18 @@ impl AgentRegistry { agent_type: &str, workspace_root: Option<&Path>, ) -> BitFunResult { - let entry = self - .find_agent_entry(agent_type, workspace_root) - .ok_or_else(|| { - error!("[AgentRegistry] Agent not found: {}", agent_type); - BitFunError::agent(format!("[AgentRegistry] Agent not found: {}", agent_type)) - })?; + let externally_owned = workspace_root + .is_some_and(|workspace| self.is_external_subagent_route(agent_type, Some(workspace))); + let entry = if externally_owned { + workspace_root + .and_then(|workspace| self.find_external_route_entry(agent_type, workspace)) + } else { + self.find_agent_entry(agent_type, workspace_root) + }; + let entry = entry.ok_or_else(|| { + error!("[AgentRegistry] Agent not found: {}", agent_type); + BitFunError::agent(format!("[AgentRegistry] Agent not found: {}", agent_type)) + })?; if let Some(config) = entry.custom_config { let model = config.model.trim(); diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 50ed72794d..b9878788ce 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1339,6 +1339,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( vec![ExternalSubagentRegistration { runtime_key: runtime_v1.to_string(), logical_id: "Explore".to_string(), + route_key: "opencode:test:explore".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::Fixed { @@ -1462,6 +1463,7 @@ async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased( vec![ExternalSubagentRegistration { runtime_key: runtime_v2.to_string(), logical_id: "Explore".to_string(), + route_key: "opencode:test:explore".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::Fixed { @@ -1525,6 +1527,7 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations vec![ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: "canonical-profile".to_string(), + route_key: "opencode:test:canonical-profile".to_string(), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1548,6 +1551,17 @@ async fn external_routes_use_one_canonical_workspace_identity_for_all_operations .expect("alias path should resolve the installed external generation"); assert_eq!(binding.runtime_agent_key, runtime_key); drop(binding); + let routed_entry = registry + .find_external_route_entry("canonical-profile", &workspace_alias) + .expect("model lookup should resolve the logical id through the external route"); + assert_eq!(routed_entry.agent.id(), runtime_key); + assert_eq!( + registry + .get_model_id_for_agent("canonical-profile", Some(&workspace_alias)) + .await + .expect("external logical id should resolve a model fallback"), + default_model_id_for_builtin_agent("canonical-profile").to_string() + ); assert!(registry .get_modes_info_for_workspace(Some(&workspace_alias), true) .await @@ -1592,6 +1606,7 @@ async fn external_agent_role_controls_main_and_task_projection() { let registration = |runtime_key: &str, mode| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1677,6 +1692,7 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { vec![ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, @@ -1720,6 +1736,240 @@ fn persisted_primary_route_owner_rejects_same_name_route_takeover() { .is_none()); } +#[test] +fn validated_generation_replacement_restores_same_name_local_agent() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-removed"); + let logical_id = "agentic"; + let runtime_key = "external::agentic::generation-1"; + registry.install_external_subagent_routes( + &workspace, + vec![ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }], + [( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + )] + .into_iter() + .collect(), + ); + let old_turn = registry + .resolve_primary_agent_for_turn(logical_id, Some(&workspace), true, None) + .expect("external generation"); + assert_eq!(old_turn.runtime_agent_key, runtime_key); + + registry.replace_external_subagent_routes(&workspace, Vec::new(), BTreeMap::new()); + + let fresh_turn = registry + .resolve_primary_agent_for_turn(logical_id, Some(&workspace), true, None) + .expect("same-name local agent"); + assert_eq!( + fresh_turn.route_owner, + bitfun_core_types::SessionAgentRouteOwner::Local + ); + assert_eq!(fresh_turn.runtime_agent_key, logical_id); + assert_eq!(old_turn.runtime_agent_key, runtime_key); +} + +#[test] +fn route_overlay_overrides_without_replacing_base_external_routes() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-overlay"); + let registration = |runtime_key: &str, + logical_id: &str, + provider: &str, + ecosystem: &str| + -> ExternalSubagentRegistration { + ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: format!("{ecosystem}:{provider}:{logical_id}"), + ecosystem_id: EcosystemId::new(ecosystem).unwrap(), + provider_label: provider.to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + } + }; + let route = |logical_id: &str, runtime_key: &str| { + ( + logical_id.to_string(), + ExternalSubagentRoute::External(runtime_key.to_string()), + ) + }; + + registry.install_external_subagent_routes( + &workspace, + vec![ + registration("external::base-agentic", "agentic", "Base", "claude-code"), + registration("external::base-only", "base-only", "Base", "claude-code"), + ], + [ + route("agentic", "external::base-agentic"), + route("base-only", "external::base-only"), + ] + .into_iter() + .collect(), + ); + registry.replace_external_subagent_route_overlay( + &workspace, + "opencode-plugin-config", + vec![ + registration("external::plugin-agentic", "agentic", "Plugin", "opencode"), + registration("external::plugin-only", "plugin-only", "Plugin", "opencode"), + ], + [ + route("agentic", "external::plugin-agentic"), + route("plugin-only", "external::plugin-only"), + ] + .into_iter() + .collect(), + ); + + let plugin_turn = registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("overlay route"); + assert_eq!(plugin_turn.runtime_agent_key, "external::plugin-agentic"); + assert_eq!( + registry + .resolve_primary_agent_for_turn("base-only", Some(&workspace), true, None) + .expect("unrelated base route") + .runtime_agent_key, + "external::base-only" + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("plugin-only", Some(&workspace), true, None) + .expect("plugin-only overlay route") + .runtime_agent_key, + "external::plugin-only" + ); + + registry.install_external_subagent_routes( + &workspace, + vec![ + registration( + "external::base-agentic-v2", + "agentic", + "Base v2", + "claude-code", + ), + registration("external::base-only", "base-only", "Base", "claude-code"), + ], + [ + route("agentic", "external::base-agentic-v2"), + route("base-only", "external::base-only"), + ] + .into_iter() + .collect(), + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("overlay still wins after base refresh") + .runtime_agent_key, + "external::plugin-agentic" + ); + + registry.release_external_subagent_route_overlay(&workspace, "opencode-plugin-config"); + + assert_eq!( + registry + .resolve_primary_agent_for_turn("agentic", Some(&workspace), true, None) + .expect("latest base route restored") + .runtime_agent_key, + "external::base-agentic-v2" + ); + assert_eq!( + registry + .resolve_primary_agent_for_turn("base-only", Some(&workspace), true, None) + .expect("base route retained") + .runtime_agent_key, + "external::base-only" + ); + assert!(registry + .resolve_primary_agent_for_turn("plugin-only", Some(&workspace), true, None) + .is_none()); + assert!(registry.check_agent_exists("external::plugin-agentic")); + drop(plugin_turn); + assert!(!registry.check_agent_exists("external::plugin-agentic")); +} + +#[test] +fn persisted_route_key_rejects_same_name_external_provider_takeover() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("D:/workspace/plugin-agent-takeover"); + let logical_id = "agentic"; + let registration = |runtime_key: &str, route_key: &str| ExternalSubagentRegistration { + runtime_key: runtime_key.to_string(), + logical_id: logical_id.to_string(), + route_key: route_key.to_string(), + ecosystem_id: EcosystemId::new("opencode").unwrap(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding::InheritParent, + hidden: false, + mode: ExternalSubagentMode::Primary, + agent: Arc::new(TestAgent { + id: runtime_key.to_string(), + }), + }; + registry.replace_external_subagent_routes( + &workspace, + vec![registration("external::one", "opencode:plugin-one:agentic")], + [( + logical_id.to_string(), + ExternalSubagentRoute::External("external::one".to_string()), + )] + .into_iter() + .collect(), + ); + let binding = registry + .resolve_primary_agent_for_turn_with_route( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + Some("opencode:plugin-one:agentic"), + ) + .expect("original route"); + drop(binding); + + registry.replace_external_subagent_routes( + &workspace, + vec![registration("external::two", "opencode:plugin-two:agentic")], + [( + logical_id.to_string(), + ExternalSubagentRoute::External("external::two".to_string()), + )] + .into_iter() + .collect(), + ); + + assert!(registry + .resolve_primary_agent_for_turn_with_route( + logical_id, + Some(&workspace), + true, + Some(bitfun_core_types::SessionAgentRouteOwner::External), + Some("opencode:plugin-one:agentic"), + ) + .is_none()); +} + #[test] fn external_primary_route_follows_the_session_execution_worktree() { let registry = AgentRegistry::new(); @@ -1729,6 +1979,7 @@ fn external_primary_route_follows_the_session_execution_worktree() { let registration = |runtime_key: &str| ExternalSubagentRegistration { runtime_key: runtime_key.to_string(), logical_id: logical_id.to_string(), + route_key: format!("opencode:test:{logical_id}"), ecosystem_id: EcosystemId::new("opencode").unwrap(), provider_label: "OpenCode".to_string(), model_binding: super::ExternalSubagentModelBinding::InheritParent, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index b2064f8c07..13050e0e46 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1211,6 +1211,7 @@ pub struct ConversationCoordinator { thread_goal_runtime: Arc, terminal_port: OnceLock>, remote_exec_port: OnceLock>, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, } impl ConversationCoordinator { @@ -1574,16 +1575,18 @@ impl ConversationCoordinator { workspace_root: Option<&Path>, external_sources_supported: bool, expected_owner: Option, + expected_route_key: Option<&str>, ) -> BitFunResult { let external_sources_supported = cfg!(feature = "external-sources") && external_sources_supported; let registry = get_agent_registry(); registry.load_custom_agents(workspace_root).await; - let local_binding = registry.resolve_primary_agent_for_turn( + let local_binding = registry.resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, false, expected_owner, + expected_route_key, ); if !external_sources_supported { @@ -1596,11 +1599,12 @@ impl ConversationCoordinator { if let Err(error) = crate::external_sources::ensure_external_source_workspace_snapshot(workspace_root).await { - if let Some(external_binding) = registry.resolve_primary_agent_for_turn( + if let Some(external_binding) = registry.resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, true, expected_owner, + expected_route_key, ) { warn!( "External agent source discovery failed; continuing with the existing resolved route: agent_type={}, route_owner={:?}, error_category={}", @@ -1631,11 +1635,12 @@ impl ConversationCoordinator { } registry - .resolve_primary_agent_for_turn( + .resolve_primary_agent_for_turn_with_route( agent_type, workspace_root, true, expected_owner, + expected_route_key, ) .ok_or_else(|| { if expected_owner == Some(SessionAgentRouteOwner::External) @@ -1663,11 +1668,16 @@ impl ConversationCoordinator { let expected_owner = agent_type .eq_ignore_ascii_case(&session.agent_type) .then_some(session.config.agent_route_owner); + let expected_route_key = agent_type + .eq_ignore_ascii_case(&session.agent_type) + .then(|| session.config.agent_route_key.as_deref()) + .flatten(); Self::resolve_primary_agent_for_workspace( agent_type, workspace_root, external_sources_supported, expected_owner, + expected_route_key, ) .await } @@ -2252,9 +2262,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet thread_goal_runtime: Arc::new(ThreadGoalRuntime::new()), terminal_port: OnceLock::new(), remote_exec_port: OnceLock::new(), + hook_registry: crate::native_hooks::new_runtime_hook_registry(), } } + pub(crate) fn hook_registry(&self) -> &bitfun_agent_runtime::native_hooks::RuntimeHookRegistry { + &self.hook_registry + } + fn ensure_runtime_ownership( &self, workspace_path: &Path, @@ -2610,6 +2625,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet )?; config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; let agent_type = Self::normalize_agent_type(&agent_type); + let expected_route_key = config.agent_route_key.clone(); let workspace_binding = Self::build_workspace_binding(&config).await; let external_workspace_root = crate::agentic::workspace::session_execution_workspace_root(&config); @@ -2621,9 +2637,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet external_workspace_root, external_sources_supported, None, + expected_route_key.as_deref(), ) .await?; config.agent_route_owner = primary_agent_binding.route_owner; + config.agent_route_key = primary_agent_binding.route_key.clone(); apply_primary_agent_model_default( &mut config, primary_agent_binding.model_binding.as_ref(), @@ -4045,6 +4063,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; let primary_runtime_agent_key = primary_agent_binding.runtime_agent_key.clone(); let primary_route_owner = primary_agent_binding.route_owner; + let primary_route_key = primary_agent_binding.route_key.clone(); let primary_agent_generation_lease = primary_agent_binding.lease; let binding = get_agent_registry() @@ -4073,12 +4092,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await?; if session.agent_type != effective_agent_type || session.config.agent_route_owner != primary_route_owner + || session.config.agent_route_key != primary_route_key { self.session_manager .update_session_agent_binding( &session_id, &effective_agent_type, primary_route_owner, + primary_route_key, ) .await?; } @@ -5718,6 +5739,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; let runtime_agent_type = primary_agent_binding.runtime_agent_key.clone(); + let primary_route_key = primary_agent_binding.route_key.clone(); let external_agent_generation_lease = primary_agent_binding.lease; // Resolve Swarm lineage before creating or mutating any turn state. A @@ -5760,12 +5782,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if session.agent_type != effective_agent_type || session.config.agent_route_owner != primary_agent_binding.route_owner + || session.config.agent_route_key != primary_route_key { self.session_manager .update_session_agent_binding( &session_id, &effective_agent_type, primary_agent_binding.route_owner, + primary_route_key, ) .await?; } @@ -12355,6 +12379,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } pub async fn update_session_mode(&self, session_id: &str, mode_id: &str) -> BitFunResult<()> { + self.update_session_mode_with_route(session_id, mode_id, None) + .await + } + + async fn update_session_mode_with_route( + &self, + session_id: &str, + mode_id: &str, + expected_route_key: Option<&str>, + ) -> BitFunResult<()> { self.ensure_session_runtime_ownership(session_id, None)?; let mode_id = mode_id.trim(); if mode_id.is_empty() { @@ -12378,11 +12412,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet workspace_root, external_sources_supported, None, + expected_route_key, ) .await?; self.session_manager - .update_session_agent_binding(session_id, mode_id, binding.route_owner) + .update_session_agent_binding( + session_id, + mode_id, + binding.route_owner, + binding.route_key, + ) .await } @@ -12544,6 +12584,7 @@ async fn create_agent_session_from_runtime_request( request.session_name, request.agent_type, SessionConfig { + agent_route_key: request.agent_route_key, workspace_path: Some(workspace_path.clone()), project_workspace_path: request.project_workspace_path, execution_target: request.execution_target, @@ -13402,9 +13443,13 @@ impl bitfun_runtime_ports::AgentSessionModePort for ConversationCoordinator { &self, request: bitfun_runtime_ports::AgentSessionModeUpdateRequest, ) -> bitfun_runtime_ports::PortResult<()> { - self.update_session_mode(&request.session_id, &request.mode_id) - .await - .map_err(runtime_port_error_preserving_message) + self.update_session_mode_with_route( + &request.session_id, + &request.mode_id, + request.agent_route_key.as_deref(), + ) + .await + .map_err(runtime_port_error_preserving_message) } } @@ -15100,6 +15145,7 @@ mod tests { &session_id, &external_agent_id, SessionAgentRouteOwner::External, + Some("test:external".to_string()), ) .await .expect("persist external route owner"); @@ -15144,6 +15190,7 @@ mod tests { &session_id, &external_agent_id, SessionAgentRouteOwner::External, + Some("test:external".to_string()), ) .await .expect("persist external route owner"); @@ -15163,7 +15210,12 @@ mod tests { assert_eq!(binding.route_owner, SessionAgentRouteOwner::Local); session_manager - .update_session_agent_binding(&session_id, "AGENTIC", SessionAgentRouteOwner::External) + .update_session_agent_binding( + &session_id, + "AGENTIC", + SessionAgentRouteOwner::External, + Some("test:external".to_string()), + ) .await .expect("persist case-variant external route owner"); let case_variant_session = session_manager @@ -15739,6 +15791,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: "missing-session".to_string(), mode_id: "agentic".to_string(), + agent_route_key: None, }, ) .await @@ -15781,6 +15834,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: session.session_id, mode_id: " ".to_string(), + agent_route_key: None, }, ) .await @@ -15826,6 +15880,7 @@ mod tests { AgentSessionModeUpdateRequest { session_id: session.session_id, mode_id: "__missing_runtime_mode__".to_string(), + agent_route_key: None, }, ) .await @@ -15876,6 +15931,7 @@ mod tests { .update_session_mode(AgentSessionModeUpdateRequest { session_id: session.session_id.clone(), mode_id: " Plan ".to_string(), + agent_route_key: None, }) .await .expect("runtime mode port should update the Core owner"); @@ -18079,6 +18135,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18118,6 +18175,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Original".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.clone()), project_workspace_path: None, execution_target: None, @@ -18215,6 +18273,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Over capacity".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18246,6 +18305,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Fixed worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18280,6 +18340,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Duplicate worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, @@ -18694,6 +18755,7 @@ mod tests { let request = |name: &str| AgentSessionCreateRequest { session_name: name.to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(workspace.clone()), project_workspace_path: None, execution_target: None, @@ -18827,6 +18889,7 @@ mod tests { AgentSessionCreateRequest { session_name: "Invalid worker".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(std::env::temp_dir().to_string_lossy().into_owned()), project_workspace_path: None, execution_target: None, diff --git a/src/crates/assembly/core/src/agentic/deep_review/mod.rs b/src/crates/assembly/core/src/agentic/deep_review/mod.rs index cd975fb36d..207c70321e 100644 --- a/src/crates/assembly/core/src/agentic/deep_review/mod.rs +++ b/src/crates/assembly/core/src/agentic/deep_review/mod.rs @@ -14,4 +14,3 @@ pub mod capabilities; pub mod report; pub mod scope; pub mod task_adapter; -pub mod tool_measurement; diff --git a/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs b/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs deleted file mode 100644 index 3df9c6bfe5..0000000000 --- a/src/crates/assembly/core/src/agentic/deep_review/tool_measurement.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Deep Review shared-context measurement hook for successful tool calls. -//! -//! The hook is intentionally narrow: only successful reviewer `Read` and -//! `GetFileDiff` calls are measured, and BitFun runtime URIs are ignored. It -//! records normalized metadata for diagnostics, not file contents. - -use crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use; -use crate::agentic::tools::framework::ToolUseContext; -use bitfun_agent_runtime::post_call_hooks::{ - resolve_deep_review_shared_context_tool_use, DeepReviewSharedContextToolUseFacts, -}; -use serde_json::Value; - -pub(crate) fn maybe_record_shared_context_tool_use( - tool_name: &str, - input: &Value, - context: &ToolUseContext, -) { - let Some(record) = - resolve_deep_review_shared_context_tool_use(DeepReviewSharedContextToolUseFacts { - tool_name, - input, - custom_data: &context.custom_data, - workspace_root: context.workspace_root(), - is_remote: context.is_remote(), - agent_type: context.agent_type.as_deref(), - }) - else { - return; - }; - - record_deep_review_shared_context_tool_use( - &record.parent_turn_id, - &record.subagent_type, - &record.tool_name, - &record.measured_path, - ); -} diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index bf700f2d00..44da573965 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -114,6 +114,21 @@ fn runtime_context_needs_for_manifest(manifest: &ResolvedToolManifest) -> Runtim ) } +fn apply_agent_temperature_override( + agent: &dyn crate::agentic::agents::Agent, + client: Arc, +) -> Arc { + let Some(temperature) = agent.model_temperature_override() else { + return client; + }; + if client.config.temperature == Some(temperature) { + return client; + } + let mut derived = client.as_ref().clone(); + derived.config.temperature = Some(temperature); + Arc::new(derived) +} + fn resolve_round_permission_mode( active_turn_mode: Option, fixed_context_mode: Option, @@ -2606,6 +2621,7 @@ impl ExecutionEngine { ))); } }; + let ai_client = apply_agent_temperature_override(current_agent.as_ref(), ai_client); Self::validate_frozen_model_contract(context).await?; Self::validate_frozen_reasoning_contract(context, ai_client.as_ref())?; let model_request_context = @@ -3528,6 +3544,7 @@ impl ExecutionEngine { ))); } }; + let ai_client = apply_agent_temperature_override(current_agent.as_ref(), ai_client); Self::validate_frozen_model_contract(&context).await?; Self::validate_frozen_reasoning_contract(&context, ai_client.as_ref())?; let model_request_context = diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 06a190d858..010c75530f 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -3934,6 +3934,7 @@ impl SessionManager { session_id: &str, agent_type: &str, route_owner: SessionAgentRouteOwner, + route_key: Option, ) -> BitFunResult<()> { let _mutation_guard = self.acquire_session_mutation(session_id).await?; let original_session = self @@ -3943,6 +3944,7 @@ impl SessionManager { .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; if original_session.agent_type == agent_type && original_session.config.agent_route_owner == route_owner + && original_session.config.agent_route_key == route_key { return Ok(()); } @@ -3951,6 +3953,7 @@ impl SessionManager { let now = SystemTime::now(); updated_session.agent_type = agent_type.to_string(); updated_session.config.agent_route_owner = route_owner; + updated_session.config.agent_route_key = route_key.clone(); updated_session.updated_at = now; updated_session.last_activity_at = now; @@ -3982,11 +3985,12 @@ impl SessionManager { }; active_session.agent_type = updated_session.agent_type; active_session.config.agent_route_owner = route_owner; + active_session.config.agent_route_key = route_key.clone(); active_session.updated_at = now; active_session.last_activity_at = now; debug!( - "Session agent binding updated: session_id={}, agent_type={}, route_owner={:?}", - session_id, agent_type, route_owner + "Session agent binding updated: session_id={}, agent_type={}, route_owner={:?}, route_key={:?}", + session_id, agent_type, route_owner, route_key ); Ok(()) @@ -5830,15 +5834,19 @@ impl SessionManager { let available_modes = agent_registry .get_modes_info_for_workspace(external_workspace_root, external_sources_supported) .await; - let persisted_binding = agent_registry.resolve_primary_agent_for_turn( + let persisted_binding = agent_registry.resolve_primary_agent_for_turn_with_route( &session.agent_type, external_workspace_root, external_sources_supported, Some(session.config.agent_route_owner), + session.config.agent_route_key.as_deref(), ); if let Some(binding) = persisted_binding { - if session.config.agent_route_owner != binding.route_owner { + if session.config.agent_route_owner != binding.route_owner + || session.config.agent_route_key != binding.route_key + { session.config.agent_route_owner = binding.route_owner; + session.config.agent_route_key = binding.route_key; should_persist_restored_session = true; } } else if session.config.agent_route_owner == SessionAgentRouteOwner::External { @@ -5864,6 +5872,7 @@ impl SessionManager { ); session.agent_type = fallback_mode; session.config.agent_route_owner = SessionAgentRouteOwner::Local; + session.config.agent_route_key = None; should_persist_restored_session = true; } } @@ -12912,6 +12921,7 @@ mod tests { &session.session_id, "agentic", SessionAgentRouteOwner::External, + Some("test:external:agentic".to_string()), ) .await .expect("same-id local-to-external rebind should persist"); @@ -12928,6 +12938,7 @@ mod tests { &session.session_id, "agentic", SessionAgentRouteOwner::Local, + Some("local:agentic".to_string()), ) .await .expect("same-id external-to-local rebind should persist"); @@ -12945,6 +12956,7 @@ mod tests { &session.session_id, "Plan", SessionAgentRouteOwner::External, + Some("test:external:plan".to_string()), ) .await .expect("external route update should persist without a turn"); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index 5b2237d879..31ec3eb61b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -389,6 +389,7 @@ Arguments: .create_session(AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path: Some(workspace.display_workspace.clone()), project_workspace_path: Some(workspace.project_workspace.clone()), execution_target: workspace.execution_target.clone(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index a94aee7a0b..094ba1af27 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -664,6 +664,7 @@ Allowed agent types when creating a session: .create_session(AgentSessionCreateRequest { session_name, agent_type: agent_type.clone(), + agent_route_key: None, workspace_path: Some(workspace_target.workspace_path.clone()), project_workspace_path: Some( workspace_target.project_workspace_path.clone(), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 676345e847..9935163661 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -1243,10 +1243,41 @@ impl SkillRegistry { async fn apply_mode_filters_for_workspace( &self, - candidates: Vec, + mut candidates: Vec, workspace_root: Option<&Path>, agent_type: Option<&str>, ) -> Vec { + #[cfg(feature = "opencode-plugin-host")] + { + let plugin_roots = + crate::plugin_config_projection::skill_roots_for_agent(workspace_root, agent_type) + .into_iter() + .map(|root| { + LocalConfiguredSkillRootContribution { + path: root.path, + scope: + bitfun_product_domains::external_sources::ExternalSourceScope::WorkspaceLocal, + precedence: root.precedence, + } + }) + .collect::>(); + if !plugin_roots.is_empty() { + let existing_paths = candidates + .iter() + .map(canonical_candidate_path) + .collect::>(); + let mut plugin_candidates = + Self::scan_configured_opencode_candidates(plugin_roots).await; + plugin_candidates.retain(|candidate| { + !existing_paths.contains(&canonical_candidate_path(candidate)) + }); + candidates = Self::merge_configured_opencode_candidates( + candidates, + plugin_candidates, + workspace_root.is_some(), + ); + } + } let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; let candidates = Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index c713e82159..5a4170b7db 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -20,6 +20,19 @@ fn external_subagent_model_override_requested( model_id.is_some() || inherit_parent_model } +pub(super) fn resolved_subagent_is_available( + available_agent_types: &[String], + logical_id: &str, + runtime_agent_key: &str, +) -> bool { + available_agent_types + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(logical_id)) + || available_agent_types + .iter() + .any(|candidate| candidate == runtime_agent_key) +} + fn build_deep_review_subagent_context( role: DeepReviewSubagentRole, subagent_type: Option<&str>, @@ -358,9 +371,18 @@ impl TaskTool { subagent_type )) })?; - if !all_agent_types.contains(&subagent_type) - && !all_agent_types.contains(&binding.runtime_agent_key) - { + // External Agent routes are resolved using their canonical + // case-insensitive logical id, but the model may emit a + // different casing (for example `Explore` for the + // plugin-registered `explore`). Validate against the + // resolved logical id as well as the generation key so a + // successful route lookup is not rejected by this second + // check. + if !resolved_subagent_is_available( + &all_agent_types, + &binding.logical_id, + &binding.runtime_agent_key, + ) { return Err(BitFunError::tool(format!( "subagent_type {} is not valid, must be one of: {}", subagent_type, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index 4df6bd0c65..32d60149d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -1,5 +1,6 @@ use super::{ - AgentInterruptTool, AgentSendInputTool, AgentSpawnTool, LaunchReviewAgentTool, TaskTool, + execution::resolved_subagent_is_available, AgentInterruptTool, AgentSendInputTool, + AgentSpawnTool, LaunchReviewAgentTool, TaskTool, }; use crate::agentic::agents::CustomSubagentConfig; use crate::agentic::agents::{ @@ -24,6 +25,17 @@ struct PromptOrderTestAgent { id: String, } +#[test] +fn external_subagent_validation_accepts_model_emitted_casing() { + let available = vec!["FileFinder".to_string(), "explore".to_string()]; + + assert!(resolved_subagent_is_available( + &available, + "Explore", + "external_subagent_runtime:opencode-plugin:explore" + )); +} + #[async_trait] impl Agent for PromptOrderTestAgent { fn as_any(&self) -> &dyn std::any::Any { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs index 9cadc3e244..3815c2839a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/worktree_tool.rs @@ -515,6 +515,7 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio .session_name .unwrap_or_else(|| "New Worktree Session".to_string()), agent_type: input.agent_type.unwrap_or_else(|| "agentic".to_string()), + agent_route_key: None, workspace_path: Some(created.execution_target.root_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(created.execution_target.clone()), diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index ab56b0e9b1..b12a1fc494 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -20,6 +20,9 @@ pub mod page_deploy_host; #[cfg(feature = "tools-miniapp")] pub mod page_publish_host; pub mod pipeline; +#[cfg(feature = "plugin-runtime")] +#[cfg(feature = "opencode-plugin-host")] +pub mod plugin_host_tool; pub(crate) mod post_call_hooks; #[doc(hidden)] pub mod product_runtime; diff --git a/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs new file mode 100644 index 0000000000..5bfb8d75ab --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/plugin_host_tool.rs @@ -0,0 +1,820 @@ +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_events::ToolExecutionProgressInfo; +use bitfun_opencode_plugin_host::{PluginGenerationLease, PluginHostClient}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{Arc, OnceLock, RwLock}; +use std::time::Duration; + +#[derive(Clone)] +struct PluginHostToolRoute { + client: PluginHostClient, + lease: PluginGenerationLease, + registration_id: String, + description: String, + parameters: Value, + allowed_runtime_agent_keys: BTreeSet, +} + +#[derive(Clone)] +struct PluginToolExecutionRoute { + session_id: String, + dialog_turn_id: String, + agent: String, + tool_name: String, + generation_key: String, + revision: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginToolMetadataParams { + #[serde(rename = "instanceID")] + instance_id: String, + #[serde(rename = "generationKey")] + generation_key: Option, + revision: Option, + #[serde(rename = "executionID")] + execution_id: String, + title: Option, + metadata: Option>, +} + +fn executions() -> &'static dashmap::DashMap<(String, String), PluginToolExecutionRoute> { + static EXECUTIONS: OnceLock> = + OnceLock::new(); + EXECUTIONS.get_or_init(dashmap::DashMap::new) +} + +struct PluginHostToolMux { + id: String, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, + routes: RwLock>, +} + +impl PluginHostToolMux { + fn new( + id: String, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, + ) -> Self { + Self { + id, + hook_registry, + routes: RwLock::new(BTreeMap::new()), + } + } + fn set_route(&self, workspace_scope: String, route: PluginHostToolRoute) { + let generation_key = route.lease.generation_key.clone(); + self.routes + .write() + .expect("plugin tool route lock poisoned") + .insert((workspace_scope, generation_key), route); + } + fn remove_route(&self, workspace_scope: &str, generation_key: &str) -> bool { + self.routes + .write() + .expect("plugin tool route lock poisoned") + .remove(&(workspace_scope.to_string(), generation_key.to_string())); + self.routes + .read() + .expect("plugin tool route lock poisoned") + .is_empty() + } + fn routes_for_scope(&self, workspace_scope: &str) -> Vec { + if self.hook_registry.source_activation_for_workspace( + bitfun_agent_runtime::native_hooks::RuntimeHookSource::OpenCodePlugin, + Some(workspace_scope), + ) != bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready + { + return Vec::new(); + } + self.routes + .read() + .expect("plugin tool route lock poisoned") + .iter() + .filter(|((scope, _), _)| scope == workspace_scope) + .map(|(_, route)| route) + .cloned() + .collect() + } + fn route_for(&self, context: Option<&ToolUseContext>) -> Option { + let context = context?; + let runtime_agent_key = context.agent_type.as_deref()?; + let scope = context + .workspace_root() + .and_then(crate::plugin_host::canonical_plugin_workspace_scope)?; + self.routes_for_scope(&scope) + .into_iter() + .find(|route| route.allowed_runtime_agent_keys.contains(runtime_agent_key)) + } +} + +#[async_trait] +impl Tool for PluginHostToolMux { + fn name(&self) -> &str { + &self.id + } + async fn description(&self) -> BitFunResult { + Ok(self + .routes + .read() + .expect("plugin tool route lock poisoned") + .values() + .next() + .map(|r| r.description.clone()) + .unwrap_or_default()) + } + async fn description_with_context( + &self, + context: Option<&ToolUseContext>, + ) -> BitFunResult { + Ok(self + .route_for(context) + .map(|r| r.description) + .unwrap_or_default()) + } + fn short_description(&self) -> String { + self.id.clone() + } + fn input_schema(&self) -> Value { + self.routes + .read() + .expect("plugin tool route lock poisoned") + .values() + .next() + .map(|r| r.parameters.clone()) + .unwrap_or_else(|| serde_json::json!({"type":"object"})) + } + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { + self.route_for(context) + .map(|r| r.parameters) + .unwrap_or_else(|| serde_json::json!({"type":"object"})) + } + fn dynamic_provider_id(&self) -> Option<&str> { + Some("opencode-plugin") + } + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(vec![ + crate::agentic::tools::framework::PermissionIntent::new( + "custom_tool", + vec![self.id.clone()], + ), + ]) + } + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + self.route_for(context).is_some() + } + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let route = self.route_for(Some(context)).ok_or_else(|| { + BitFunError::service("OpenCode plugin tool is not registered for this workspace") + })?; + let execution_id = context + .tool_call_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let rpc_context = serde_json::json!({ + "sessionID": context.session_id.clone().unwrap_or_default(), + "messageID": context.dialog_turn_id.clone().unwrap_or_default(), + "agent": context.agent_type.clone().unwrap_or_default(), + "callID": context.tool_call_id, + }); + let execution_key = (route.lease.instance_id.clone(), execution_id.clone()); + executions().insert( + execution_key.clone(), + PluginToolExecutionRoute { + session_id: context.session_id.clone().unwrap_or_default(), + dialog_turn_id: context.dialog_turn_id.clone().unwrap_or_default(), + agent: context.agent_type.clone().unwrap_or_default(), + tool_name: self.id.clone(), + generation_key: route.lease.generation_key.clone(), + revision: route.lease.revision.clone(), + }, + ); + let call = route.client.execute_tool( + &route.lease, + &execution_id, + &route.registration_id, + input.clone(), + rpc_context, + Duration::from_secs(120), + ); + let result = if let Some(token) = context.cancellation_token() { + tokio::select! { + value = call => value, + _ = token.cancelled() => { + let _ = route.client.cancel_tool(&route.lease, &execution_id, Some("cancelled"), Duration::from_secs(5)).await; + executions().remove(&execution_key); + return Err(BitFunError::Cancelled("OpenCode plugin tool cancelled".to_string())); + } + } + } else { + call.await + }; + executions().remove(&execution_key); + let result = result.map_err(|error| { + BitFunError::service(format!( + "OpenCode plugin tool '{}' failed: {error}", + self.id + )) + })?; + if result + .get("attachments") + .and_then(Value::as_array) + .is_some_and(|attachments| !attachments.is_empty()) + { + return Err(BitFunError::service("unsupported_tool_attachment")); + } + let (data, assistant) = match result { + Value::String(value) => (Value::String(value.clone()), Some(value)), + Value::Object(object) => { + let output = object.get("output").cloned().unwrap_or(Value::Null); + let assistant = object + .get("output") + .and_then(Value::as_str) + .map(str::to_string); + ( + Value::Object(object), + assistant.or_else(|| Some(output.to_string())), + ) + } + other => (other.clone(), Some(other.to_string())), + }; + Ok(vec![ToolResult::ok(data, assistant)]) + } +} + +pub(crate) async fn handle_tool_metadata( + params: Value, +) -> Result { + let params: PluginToolMetadataParams = serde_json::from_value(params).map_err(|error| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32602, + format!("invalid backend.tool.metadata params: {error}"), + ) + })?; + let route = executions() + .get(&(params.instance_id, params.execution_id.clone())) + .map(|entry| entry.clone()) + .ok_or_else(|| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32004, + "plugin tool execution is no longer active", + ) + })?; + validate_reverse_generation( + params.generation_key.as_deref(), + params.revision.as_deref(), + &route, + )?; + let progress_message = params + .title + .unwrap_or_else(|| Value::Object(params.metadata.unwrap_or_default()).to_string()); + crate::infrastructure::events::emit_global_event( + crate::infrastructure::events::BackendEvent::ToolExecutionProgress( + ToolExecutionProgressInfo { + tool_use_id: params.execution_id, + tool_name: route.tool_name, + progress_message, + percentage: None, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }, + ), + ) + .await + .map_err(|error| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32603, + format!("failed to publish plugin tool metadata: {error}"), + ) + })?; + Ok(serde_json::json!({})) +} + +pub(crate) async fn handle_tool_ask( + params: Value, +) -> Result { + let instance_id = params + .get("instanceID") + .and_then(Value::as_str) + .ok_or_else(|| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32602, + "backend.tool.ask instanceID is missing", + ) + })?; + let execution_id = params + .get("executionID") + .and_then(Value::as_str) + .ok_or_else(|| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32602, + "backend.tool.ask executionID is missing", + ) + })?; + let route = executions() + .get(&(instance_id.to_string(), execution_id.to_string())) + .map(|entry| entry.clone()) + .ok_or_else(|| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32004, + "plugin tool execution is no longer active", + ) + })?; + validate_reverse_generation( + params.get("generationKey").and_then(Value::as_str), + params.get("revision").and_then(Value::as_str), + &route, + )?; + let instance = crate::plugin_host::plugin_host_instance_by_id(instance_id) + .await + .ok_or_else(|| { + bitfun_opencode_plugin_host::RpcHandlerError::new( + -32004, + "plugin instance is unavailable", + ) + })?; + if instance.generation_key != route.generation_key || instance.revision != route.revision { + return Err(bitfun_opencode_plugin_host::RpcHandlerError::new( + -32004, + "plugin tool generation is no longer active", + )); + } + let permission = params + .get("permission") + .and_then(Value::as_str) + .unwrap_or("custom_tool"); + let mut patterns = params + .get("patterns") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + let permission_action = if permission == route.tool_name { + if patterns.is_empty() { + patterns.push(route.tool_name.clone()); + } + "custom_tool" + } else { + permission + }; + let always = params + .get("always") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + let metadata = params + .get("metadata") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let policy = crate::agentic::agents::get_agent_registry() + .get_agent_tool_policy(&route.agent, Some(&instance.directory)) + .await; + let evaluator = bitfun_runtime_ports::PermissionEvaluator::case_sensitive(); + if patterns.iter().any(|resource| { + evaluator.evaluate_constraint_resource( + permission_action, + resource, + &policy.permission_constraints, + ) == bitfun_runtime_ports::PermissionEffect::Deny + }) { + return Err(bitfun_opencode_plugin_host::RpcHandlerError::new( + -32003, + "plugin tool permission denied by the active agent policy", + )); + } + // The pipeline already admitted the exact plugin Tool through the same + // custom_tool intent before entering the Host. Do not ask twice when the + // plugin repeats that declaration through context.ask(). + if permission_action == "custom_tool" + && patterns.iter().all(|resource| resource == &route.tool_name) + { + return Ok(serde_json::json!({})); + } + let manager = crate::product_runtime::core_permission_request_manager() + .map_err(|error| bitfun_opencode_plugin_host::RpcHandlerError::new(-32603, error))?; + let mut pending = manager + .register_batch_for_turn( + vec![bitfun_runtime_ports::PermissionRequest { + request_id: uuid::Uuid::new_v4().to_string(), + round_id: route.dialog_turn_id.clone(), + order: 0, + tool_call_id: Some(execution_id.to_string()), + project_path: Some(instance.canonical_directory.clone()), + project_id: instance.project_id, + session_id: route.session_id, + agent_id: route.agent, + action: permission_action.to_string(), + resources: patterns, + save_resources: always, + source: bitfun_runtime_ports::PermissionRequestSource { + kind: bitfun_runtime_ports::PermissionRequestSourceKind::Extension, + identity: instance_id.to_string(), + }, + delegation: None, + display_metadata: metadata, + }], + route.dialog_turn_id, + ) + .await + .map_err(|error| { + bitfun_opencode_plugin_host::RpcHandlerError::new(-32603, error.to_string()) + })?; + let pending = pending + .pop() + .expect("single permission batch must return one receiver"); + match pending.wait().await { + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_runtime_ports::PermissionReply::Once + | bitfun_runtime_ports::PermissionReply::Always, + ) => Ok(serde_json::json!({})), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Replied( + bitfun_runtime_ports::PermissionReply::Reject { feedback }, + ) => Err(bitfun_opencode_plugin_host::RpcHandlerError::new( + -32003, + feedback.unwrap_or_else(|| "plugin tool permission denied".to_string()), + )), + bitfun_agent_runtime::permission::PermissionWaitOutcome::Cancelled { reason } => Err( + bitfun_opencode_plugin_host::RpcHandlerError::new(-32003, reason), + ), + } +} + +fn validate_reverse_generation( + generation_key: Option<&str>, + revision: Option<&str>, + route: &PluginToolExecutionRoute, +) -> Result<(), bitfun_opencode_plugin_host::RpcHandlerError> { + if generation_key == Some(route.generation_key.as_str()) + && revision == Some(route.revision.as_str()) + { + Ok(()) + } else { + Err(bitfun_opencode_plugin_host::RpcHandlerError::new( + -32004, + "plugin tool reverse RPC generation lease does not match the active execution", + )) + } +} + +fn muxes() -> &'static RwLock>> { + static MUXES: OnceLock>>> = OnceLock::new(); + MUXES.get_or_init(|| RwLock::new(BTreeMap::new())) +} + +pub(crate) async fn register_workspace_tool( + workspace_scope: &str, + workspace_root: &std::path::Path, + client: PluginHostClient, + instance_id: &str, + generation_key: &str, + revision: &str, + registration_id: &str, + id: &str, + description: &str, + parameters: Value, + config_fingerprint: &str, + allowed_runtime_agent_keys: BTreeSet, +) { + let mux = { + let mut muxes = muxes().write().expect("plugin tool mux lock poisoned"); + if let Some(mux) = muxes.get(id) { + mux.clone() + } else { + let mux = Arc::new(PluginHostToolMux::new( + id.to_string(), + crate::native_hooks::runtime_hook_registry(), + )); + muxes.insert(id.to_string(), mux.clone()); + mux + } + }; + let mut hasher = Sha256::new(); + hasher.update(id.as_bytes()); + hasher.update([0]); + hasher.update(description.as_bytes()); + hasher.update([0]); + hasher.update(serde_json::to_vec(¶meters).unwrap_or_default()); + hasher.update([0]); + hasher.update(config_fingerprint.as_bytes()); + let content_version = format!("sha256:{}", hex::encode(hasher.finalize())); + mux.set_route( + workspace_scope.to_string(), + PluginHostToolRoute { + client, + lease: PluginGenerationLease { + instance_id: instance_id.to_string(), + generation_key: generation_key.to_string(), + revision: revision.to_string(), + }, + registration_id: registration_id.to_string(), + description: description.to_string(), + parameters, + allowed_runtime_agent_keys, + }, + ); + crate::external_tools::register_live_external_tool_candidate( + workspace_root, + mux, + "opencode-plugin", + content_version, + ) + .await; +} + +pub(crate) async fn unregister_workspace_tools( + workspace_scope: &str, + workspace_root: &std::path::Path, + names: &[String], + generation_key: &str, +) { + for name in names { + let mux = muxes() + .read() + .expect("plugin tool mux lock poisoned") + .get(name) + .cloned(); + let Some(mux) = mux else { + continue; + }; + if mux.remove_route(workspace_scope, generation_key) { + muxes() + .write() + .expect("plugin tool mux lock poisoned") + .remove(name); + } + if mux.routes_for_scope(workspace_scope).is_empty() { + crate::external_tools::unregister_live_external_tool_candidate( + workspace_root, + name, + "opencode-plugin", + ) + .await; + } + } +} + +#[cfg(test)] +mod tests { + use super::{ + executions, handle_tool_ask, handle_tool_metadata, PluginHostToolMux, PluginHostToolRoute, + PluginToolExecutionRoute, + }; + use crate::agentic::tools::framework::ToolUseContext; + use bitfun_opencode_plugin_host::JsonRpcPeer; + use serde_json::json; + use std::collections::BTreeSet; + use std::path::PathBuf; + use tokio::net::{TcpListener, TcpStream}; + + async fn client() -> bitfun_opencode_plugin_host::PluginHostClient { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let host = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (backend, _) = listener.accept().await.unwrap(); + let _host = host.await.unwrap(); + JsonRpcPeer::start_with_capabilities( + backend, + 1, + 1024 * 1024, + bitfun_opencode_plugin_host::PluginHostCapabilities::all_supported(), + ) + .client() + } + + fn route( + client: bitfun_opencode_plugin_host::PluginHostClient, + instance_id: &str, + ) -> PluginHostToolRoute { + PluginHostToolRoute { + client, + lease: bitfun_opencode_plugin_host::PluginGenerationLease { + instance_id: instance_id.to_string(), + generation_key: "generation-test".to_string(), + revision: "revision-test".to_string(), + }, + registration_id: format!("registration-{instance_id}"), + description: format!("description-{instance_id}"), + parameters: json!({"type": "object", "title": instance_id}), + allowed_runtime_agent_keys: BTreeSet::from(["plugin-agent".to_string()]), + } + } + + fn context(workspace_root: &str, runtime_agent_key: &str) -> ToolUseContext { + crate::agentic::tools::tool_context_runtime::build_tool_description_context( + runtime_agent_key, + Some(&crate::agentic::WorkspaceBinding::new( + None, + PathBuf::from(workspace_root), + )), + None, + None, + &Default::default(), + &Default::default(), + ) + } + + #[tokio::test] + async fn same_named_tool_routes_are_isolated_by_workspace() { + let client = client().await; + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + for scope in ["C:/workspace-a", "D:/workspace-b"] { + registry.set_source_activation_for_workspace( + bitfun_agent_runtime::native_hooks::RuntimeHookSource::OpenCodePlugin, + Some(scope), + bitfun_agent_runtime::native_hooks::RuntimeHookActivation::Ready, + ); + } + let mux = PluginHostToolMux::new("shared-tool".to_string(), registry); + mux.set_route( + "C:/workspace-a".to_string(), + route(client.clone(), "instance-a"), + ); + mux.set_route("D:/workspace-b".to_string(), route(client, "instance-b")); + + assert_eq!( + mux.routes_for_scope("C:/workspace-a") + .pop() + .unwrap() + .lease + .instance_id, + "instance-a" + ); + assert_eq!( + mux.routes_for_scope("D:/workspace-b") + .pop() + .unwrap() + .lease + .instance_id, + "instance-b" + ); + assert!(!mux.remove_route("C:/workspace-a", "generation-test")); + assert!(mux.routes_for_scope("C:/workspace-a").is_empty()); + assert_eq!( + mux.routes_for_scope("D:/workspace-b") + .pop() + .unwrap() + .lease + .instance_id, + "instance-b" + ); + assert!(mux.remove_route("D:/workspace-b", "generation-test")); + } + + #[tokio::test] + async fn tool_route_honors_the_shared_workspace_activation_gate() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + let mux = PluginHostToolMux::new("gated-tool".to_string(), registry.clone()); + mux.set_route( + "C:/workspace-gated".to_string(), + route(client().await, "instance-gated"), + ); + registry.set_source_activation_for_workspace( + RuntimeHookSource::OpenCodePlugin, + Some("C:/workspace-gated"), + RuntimeHookActivation::Unavailable, + ); + + assert!(mux.routes_for_scope("C:/workspace-gated").is_empty()); + + registry.set_source_activation_for_workspace( + RuntimeHookSource::OpenCodePlugin, + Some("C:/workspace-gated"), + RuntimeHookActivation::Ready, + ); + assert!(!mux.routes_for_scope("C:/workspace-gated").is_empty()); + registry.clear_source_workspace(RuntimeHookSource::OpenCodePlugin, "C:/workspace-gated"); + } + + #[tokio::test] + async fn tool_route_requires_the_exact_generation_agent_key() { + use bitfun_agent_runtime::native_hooks::{RuntimeHookActivation, RuntimeHookSource}; + + let workspace = std::env::current_dir().expect("absolute workspace"); + let scope = crate::plugin_host::canonical_plugin_workspace_scope(&workspace) + .expect("canonical workspace scope"); + let generation_a_agent = "external_subagent_runtime:opencode-plugin:generation-a-agent"; + let generation_b_agent = "external_subagent_runtime:opencode-plugin:generation-b-agent"; + let registry = bitfun_agent_runtime::native_hooks::RuntimeHookRegistry::default(); + registry.set_source_activation_for_workspace( + RuntimeHookSource::OpenCodePlugin, + Some(&scope), + RuntimeHookActivation::Ready, + ); + let mux = PluginHostToolMux::new("generation-tool".to_string(), registry.clone()); + let client = client().await; + let mut route_a = route(client.clone(), "instance-a"); + route_a.lease.generation_key = "generation-a".to_string(); + route_a.allowed_runtime_agent_keys = BTreeSet::from([generation_a_agent.to_string()]); + let mut route_b = route(client, "instance-b"); + route_b.lease.generation_key = "generation-b".to_string(); + route_b.allowed_runtime_agent_keys = BTreeSet::from([generation_b_agent.to_string()]); + mux.set_route(scope.clone(), route_a); + mux.set_route(scope.clone(), route_b); + + let workspace_text = workspace.to_string_lossy(); + assert_eq!( + mux.route_for(Some(&context(&workspace_text, generation_a_agent))) + .expect("generation A route") + .lease + .instance_id, + "instance-a" + ); + assert_eq!( + mux.route_for(Some(&context(&workspace_text, generation_b_agent))) + .expect("generation B route") + .lease + .instance_id, + "instance-b" + ); + assert!(mux + .route_for(Some(&context( + &workspace_text, + "external_subagent_runtime:other-provider:agent" + ))) + .is_none()); + assert!(mux + .route_for(Some(&context(&workspace_text, "Agentic"))) + .is_none()); + + registry.clear_source_workspace(RuntimeHookSource::OpenCodePlugin, &scope); + } + + #[tokio::test] + async fn ask_for_missing_execution_route_is_rejected() { + let error = handle_tool_ask(json!({ + "instanceID": "missing-instance", + "executionID": "missing-execution" + })) + .await + .unwrap_err(); + + assert_eq!(error.code, -32004); + } + + #[tokio::test] + async fn metadata_for_missing_execution_route_is_rejected() { + let error = handle_tool_metadata(json!({ + "instanceID": "missing-instance", + "executionID": "missing-execution", + "title": "progress" + })) + .await + .unwrap_err(); + + assert_eq!(error.code, -32004); + } + + #[tokio::test] + async fn metadata_for_active_execution_is_published_as_progress() { + let instance_id = format!("metadata-instance-{}", std::process::id()); + let execution_id = format!("metadata-execution-{}", std::process::id()); + let key = (instance_id.clone(), execution_id.clone()); + executions().insert( + key.clone(), + PluginToolExecutionRoute { + session_id: "session-a".to_string(), + dialog_turn_id: "turn-a".to_string(), + agent: "agentic".to_string(), + tool_name: "plugin-tool".to_string(), + generation_key: "generation-a".to_string(), + revision: "revision-a".to_string(), + }, + ); + + let result = handle_tool_metadata(json!({ + "instanceID": instance_id, + "generationKey": "generation-a", + "revision": "revision-a", + "executionID": execution_id, + "title": "Reading README.md", + "metadata": {"path": "README.md"} + })) + .await; + executions().remove(&key); + + assert_eq!(result.unwrap(), json!({})); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs index db411435d2..c3fde4538e 100644 --- a/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs +++ b/src/crates/assembly/core/src/agentic/tools/post_call_hooks.rs @@ -4,31 +4,21 @@ //! tool execution. Domain-specific hooks must keep their own gating inside the //! owning domain module. -use crate::agentic::deep_review::tool_measurement; use crate::agentic::tools::tool_context_runtime::ToolUseContext; -use bitfun_agent_runtime::post_call_hooks::{ - run_successful_tool_post_call_hooks, SuccessfulToolPostCallHookExecutor, -}; use serde_json::Value; -struct CorePostCallHookExecutor; - -impl SuccessfulToolPostCallHookExecutor for CorePostCallHookExecutor { - fn record_deep_review_shared_context_tool_use( - &mut self, - tool_name: &str, - input: &Value, - context: &ToolUseContext, - ) { - tool_measurement::maybe_record_shared_context_tool_use(tool_name, input, context); - } -} - -pub(crate) fn record_successful_tool_call( +pub(crate) async fn record_successful_tool_call( tool_name: &str, input: &Value, context: &ToolUseContext, ) { - let mut executor = CorePostCallHookExecutor; - run_successful_tool_post_call_hooks(tool_name, input, context, &mut executor); + crate::native_hooks::dispatch_successful_tool_post_call( + context.workspace_root(), + context.is_remote(), + tool_name, + input, + &context.custom_data, + context.agent_type.as_deref(), + ) + .await; } diff --git a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs index 0bf00cdc59..8927600af1 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_context_runtime.rs @@ -231,7 +231,7 @@ pub(crate) async fn call_with_tool_runtime_hooks( }; if result.is_ok() { - post_call_hooks::record_successful_tool_call(tool_name, input, context); + post_call_hooks::record_successful_tool_call(tool_name, input, context).await; } result diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 791ee9da04..8a4a9f9dda 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -3521,10 +3521,21 @@ impl WorkspaceExternalSourceService { ) })?; if matches!(candidate.kind, ExternalToolConflictCandidateKind::External) { - let source_key = candidate.source.as_ref().ok_or_else(|| { - missing_candidate_error("External tool conflict source is missing") - })?; - ensure_source_capability_active(&snapshot, source_key, EXTERNAL_CAPABILITY_TOOL)?; + match candidate.source.as_ref() { + Some(source_key) => { + ensure_source_capability_active( + &snapshot, + source_key, + EXTERNAL_CAPABILITY_TOOL, + )?; + } + None if candidate.provider_id == "opencode-plugin" => {} + None => { + return Err(missing_candidate_error( + "External tool conflict source is missing", + )); + } + } } validate_conflict_preference(conflict_key, candidate_id)?; let preferences = diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 71953f3501..26edb77432 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -1328,6 +1328,7 @@ fn install_active_candidate( candidate.definition.prompt.expose().to_string(), tools, candidate.definition.permission_constraints.clone(), + None, candidate.readonly, candidate.definition.behavior_version.as_str().to_string(), )); @@ -1342,6 +1343,11 @@ fn install_active_candidate( state.registrations.push(ExternalSubagentRegistration { runtime_key: runtime_key.clone(), logical_id: candidate.definition.logical_id.clone(), + route_key: format!( + "{}:{}", + ecosystem_id.as_str(), + candidate.definition.candidate_id.as_str() + ), ecosystem_id, provider_label: candidate.provider_label.clone(), model_binding, diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 97a60cedc8..9bf8d8f7ac 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -334,6 +334,10 @@ enum WorkspaceRoute { tool: Arc, conflict: Option, }, + Live { + tool: Arc, + conflict: Option, + }, Unavailable { conflict: Option, }, @@ -344,6 +348,7 @@ impl WorkspaceRoute { match self { Self::Original { conflict } | Self::External { conflict, .. } + | Self::Live { conflict, .. } | Self::Unavailable { conflict } => conflict.as_ref(), } } @@ -431,6 +436,9 @@ fn retain_fail_closed_routes_during_reconcile( WorkspaceRoute::External { conflict: Some(_), .. + } | WorkspaceRoute::Live { + conflict: Some(_), + .. } | WorkspaceRoute::Unavailable { conflict: Some(_) } ) }); @@ -438,10 +446,13 @@ fn retain_fail_closed_routes_during_reconcile( if discovered_names.contains(name) { continue; } - if let WorkspaceRoute::External { conflict, .. } = route { - *route = WorkspaceRoute::Unavailable { - conflict: conflict.clone(), - }; + match route { + WorkspaceRoute::External { conflict, .. } | WorkspaceRoute::Live { conflict, .. } => { + *route = WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + }; + } + _ => {} } } } @@ -502,7 +513,31 @@ impl ExternalToolMux { // a local route solely because the remote path text matches. return self.original(); } - self.selected_for_workspace(context.and_then(ToolUseContext::workspace_root)) + let workspace_key = workspace_route_key(context.and_then(ToolUseContext::workspace_root)); + match self + .routes + .read() + .expect("external tool route lock poisoned") + .get(&workspace_key) + .cloned() + { + Some(WorkspaceRoute::Live { tool, .. }) + if tool.dynamic_provider_id() == Some("opencode-plugin") => + { + if context + .and_then(|context| context.agent_type.as_deref()) + .is_some_and(crate::plugin_config_projection::is_plugin_agent_runtime_key) + { + Some(tool) + } else { + self.original() + } + } + Some(WorkspaceRoute::External { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Live { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Unavailable { .. }) => None, + Some(WorkspaceRoute::Original { .. }) | None => self.original(), + } } fn selected_for_workspace(&self, workspace_root: Option<&Path>) -> Option> { @@ -515,6 +550,7 @@ impl ExternalToolMux { .cloned() { Some(WorkspaceRoute::External { tool, .. }) => Some(tool), + Some(WorkspaceRoute::Live { tool, .. }) => Some(tool), Some(WorkspaceRoute::Unavailable { .. }) => None, Some(WorkspaceRoute::Original { .. }) | None => self.original(), } @@ -593,7 +629,10 @@ impl Tool for ExternalToolMux { } async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { - self.selected(context).is_some() + match self.selected(context) { + Some(tool) => tool.is_available_in_context(context).await, + None => false, + } } fn is_readonly(&self) -> bool { @@ -683,6 +722,14 @@ impl Tool for ExternalToolMux { } match route { Some(WorkspaceRoute::External { tool, .. }) => tool.call(input, context).await, + Some(WorkspaceRoute::Live { .. }) => { + self.selected(Some(context)) + .ok_or_else(|| { + BitFunError::tool(format!("tool '{}' is unavailable", self.name)) + })? + .call(input, context) + .await + } Some(WorkspaceRoute::Original { .. }) | None => { self.original() .ok_or_else(|| { @@ -704,19 +751,173 @@ impl Tool for ExternalToolMux { struct ExternalToolRouter { muxes: StdMutex>>, + live_candidates: + StdMutex>>>, mutation_gate: Mutex<()>, } +#[derive(Clone)] +struct LiveExternalToolCandidate { + tool: Arc, + candidate: ExternalToolConflictCandidate, +} + impl Default for ExternalToolRouter { fn default() -> Self { Self { muxes: StdMutex::new(BTreeMap::new()), + live_candidates: StdMutex::new(HashMap::new()), mutation_gate: Mutex::new(()), } } } impl ExternalToolRouter { + fn register_live_candidate( + &self, + workspace_key: &str, + tool: Arc, + provider_id: &str, + content_version: String, + ) { + let name = tool.name().to_string(); + let candidate_id = format!("external:live:{provider_id}:{name}"); + let candidate = ExternalToolConflictCandidate { + candidate_id: candidate_id.clone(), + display_name: name.clone(), + kind: ExternalToolConflictCandidateKind::External, + provider_id: provider_id.to_string(), + content_version, + source: None, + source_location: None, + }; + self.live_candidates + .lock() + .expect("external live tool candidate lock poisoned") + .entry(workspace_key.to_string()) + .or_default() + .entry(name) + .or_default() + .insert(candidate_id, LiveExternalToolCandidate { tool, candidate }); + } + + fn unregister_live_candidate(&self, workspace_key: &str, name: &str, provider_id: &str) { + let candidate_id = format!("external:live:{provider_id}:{name}"); + let mut by_workspace = self + .live_candidates + .lock() + .expect("external live tool candidate lock poisoned"); + let Some(by_name) = by_workspace.get_mut(workspace_key) else { + return; + }; + if let Some(candidates) = by_name.get_mut(name) { + candidates.remove(&candidate_id); + if candidates.is_empty() { + by_name.remove(name); + } + } + if by_name.is_empty() { + by_workspace.remove(workspace_key); + } + } + + fn live_candidates( + &self, + workspace_key: &str, + ) -> BTreeMap> { + self.live_candidates + .lock() + .expect("external live tool candidate lock poisoned") + .get(workspace_key) + .map(|by_name| { + by_name + .iter() + .map(|(name, candidates)| { + (name.clone(), candidates.values().cloned().collect()) + }) + .collect() + }) + .unwrap_or_default() + } + + async fn apply_initial_live_candidate_route( + &self, + workspace_key: &str, + name: &str, + tool: Arc, + ) { + let mut routes = self.workspace_routes(workspace_key); + if let Some(existing) = routes.get(name).cloned() { + match existing { + WorkspaceRoute::External { conflict, .. } => { + routes.insert(name.to_string(), WorkspaceRoute::Unavailable { conflict }); + self.apply_routes(workspace_key, routes).await; + } + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + routes.insert( + name.to_string(), + WorkspaceRoute::Unavailable { + conflict: Some(conflict), + }, + ); + self.apply_routes(workspace_key, routes).await; + } + _ => {} + } + return; + } + let route = if tool.dynamic_provider_id() == Some("opencode-plugin") { + WorkspaceRoute::Live { + tool, + conflict: None, + } + } else if self.original_tool(name).await.is_some() { + WorkspaceRoute::Original { conflict: None } + } else { + WorkspaceRoute::Live { + tool, + conflict: None, + } + }; + routes.insert(name.to_string(), route); + self.apply_routes(workspace_key, routes).await; + } + + async fn withdraw_live_candidate_route(&self, workspace_key: &str, name: &str) { + let mut routes = self.workspace_routes(workspace_key); + let Some(route) = routes.remove(name) else { + return; + }; + match route { + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + routes.insert( + name.to_string(), + WorkspaceRoute::Unavailable { + conflict: Some(conflict), + }, + ); + } + WorkspaceRoute::Live { conflict: None, .. } => { + if self.original_tool(name).await.is_some() { + routes.insert( + name.to_string(), + WorkspaceRoute::Original { conflict: None }, + ); + } + } + other => { + routes.insert(name.to_string(), other); + } + } + self.apply_routes(workspace_key, routes).await; + } + fn known_name(&self, tool_name: &str) -> Option { self.muxes .lock() @@ -837,6 +1038,26 @@ impl ExternalToolRouter { return tool; } mux.replace_original(Some(tool)); + let mut routes = mux + .routes + .write() + .expect("external tool route lock poisoned"); + for route in routes.values_mut() { + match route { + WorkspaceRoute::Live { conflict: None, .. } => { + *route = WorkspaceRoute::Original { conflict: None }; + } + WorkspaceRoute::Live { + conflict: Some(conflict), + .. + } => { + *route = WorkspaceRoute::Unavailable { + conflict: Some(conflict.clone()), + }; + } + _ => {} + } + } routed } @@ -916,6 +1137,34 @@ pub(crate) fn intercept_external_tool_registry_registration(tool: Arc) router().intercept_registration(tool) } +pub(crate) async fn register_live_external_tool_candidate( + workspace_root: &Path, + tool: Arc, + provider_id: &str, + content_version: String, +) { + let workspace_key = workspace_route_key(Some(workspace_root)); + router().register_live_candidate(&workspace_key, tool.clone(), provider_id, content_version); + let name = tool.name().to_string(); + router() + .apply_initial_live_candidate_route(&workspace_key, &name, tool) + .await; + crate::external_sources::notify_external_tool_registry_changed(); +} + +pub(crate) async fn unregister_live_external_tool_candidate( + workspace_root: &Path, + name: &str, + provider_id: &str, +) { + let workspace_key = workspace_route_key(Some(workspace_root)); + router().unregister_live_candidate(&workspace_key, name, provider_id); + router() + .withdraw_live_candidate_route(&workspace_key, name) + .await; + crate::external_sources::notify_external_tool_registry_changed(); +} + pub(crate) fn detach_external_tool_mcp_server(server_id: &str) -> Vec> { router().detach_mcp_server(server_id) } @@ -1328,6 +1577,7 @@ pub(super) async fn reconcile_external_tools( let workspace_key = workspace_route_key(workspace_root); let snapshot = control_plane.tools(|coordinator| coordinator.snapshot()); let mut state = ExternalToolProductState::default(); + let mut live_candidates_by_name = router().live_candidates(&workspace_key); let source_by_key = snapshot .sources .iter() @@ -1342,11 +1592,12 @@ pub(super) async fn reconcile_external_tools( .or_default() .push(tool); } - let discovered_names = target_groups + let mut discovered_names = target_groups .values() .flatten() .map(|tool| tool.name.clone()) .collect::>(); + discovered_names.extend(live_candidates_by_name.keys().cloned()); let mut names_to_quiesce = BTreeSet::new(); let mut preapproved_runtime_targets = BTreeSet::new(); for (target_id, definitions) in &target_groups { @@ -1842,12 +2093,18 @@ pub(super) async fn reconcile_external_tools( .await; let conflict_domain = workspace_conflict_domain(execution_domain_id, &workspace_key); - let mut names_by_normalized = BTreeMap::>::new(); + let mut names_by_normalized = BTreeMap::>::new(); for name in conflict_candidates_by_name.keys() { names_by_normalized .entry(name.clone()) .or_default() - .push(name.clone()); + .insert(name.clone()); + } + for name in live_candidates_by_name.keys() { + names_by_normalized + .entry(name.clone()) + .or_default() + .insert(name.clone()); } let conflict_prefix = format!("external_tool:{conflict_domain}:"); for conflict_key in decisions.conflict_choices.keys() { @@ -1861,7 +2118,7 @@ pub(super) async fn reconcile_external_tools( .entry(normalized_name.to_string()) .or_default(); if names.is_empty() { - names.push( + names.insert( router() .known_name(normalized_name) .unwrap_or_else(|| normalized_name.to_string()), @@ -1874,8 +2131,9 @@ pub(super) async fn reconcile_external_tools( let external_candidates = conflict_candidates_by_name .remove(&name) .unwrap_or_default(); + let live_candidates = live_candidates_by_name.remove(&name).unwrap_or_default(); let original = router().original_tool(&name).await; - if external_candidates.is_empty() && original.is_none() { + if external_candidates.is_empty() && live_candidates.is_empty() && original.is_none() { continue; } let mut candidates = Vec::new(); @@ -1894,6 +2152,11 @@ pub(super) async fn reconcile_external_tools( source_location: Some(definition.module_path.clone()), }); } + candidates.extend( + live_candidates + .iter() + .map(|candidate| candidate.candidate.clone()), + ); let has_conflict_history = if external_candidates.is_empty() { tool_conflict_history_requires_fail_closed( @@ -1916,6 +2179,14 @@ pub(super) async fn reconcile_external_tools( } }); routes.insert(name, route); + } else if let Some(candidate) = live_candidates.first() { + routes.insert( + name, + WorkspaceRoute::Live { + tool: candidate.tool.clone(), + conflict: None, + }, + ); } continue; } @@ -1930,10 +2201,15 @@ pub(super) async fn reconcile_external_tools( ) }), ); - let external_candidate_ids = external_candidates + let mut external_candidate_ids = external_candidates .iter() .map(ExternalToolDefinition::candidate_id) .collect::>(); + external_candidate_ids.extend( + live_candidates + .iter() + .map(|candidate| candidate.candidate.candidate_id.clone()), + ); let (selected, route_choice) = resolve_conflict_route_choice( &conflict_key, &candidates, @@ -1947,15 +2223,24 @@ pub(super) async fn reconcile_external_tools( }); let route = match route_choice { ConflictRouteChoice::External(candidate_id) => { - loaded_by_candidate_id.get(&candidate_id).cloned().map_or( - WorkspaceRoute::Unavailable { - conflict: conflict.clone(), - }, - |tool| WorkspaceRoute::External { + if let Some(tool) = loaded_by_candidate_id.get(&candidate_id).cloned() { + WorkspaceRoute::External { tool, conflict: conflict.clone(), - }, - ) + } + } else if let Some(candidate) = live_candidates + .iter() + .find(|candidate| candidate.candidate.candidate_id == candidate_id) + { + WorkspaceRoute::Live { + tool: candidate.tool.clone(), + conflict: conflict.clone(), + } + } else { + WorkspaceRoute::Unavailable { + conflict: conflict.clone(), + } + } } ConflictRouteChoice::Original => WorkspaceRoute::Original { conflict: conflict.clone(), @@ -2134,6 +2419,55 @@ mod tests { } } + struct PluginTestTool { + name: String, + } + + #[async_trait] + impl Tool for PluginTestTool { + fn name(&self) -> &str { + &self.name + } + + async fn description(&self) -> BitFunResult { + Ok("plugin test tool".to_string()) + } + + fn short_description(&self) -> String { + "plugin test tool".to_string() + } + + fn input_schema(&self) -> Value { + serde_json::json!({ "type": "object" }) + } + + fn dynamic_provider_id(&self) -> Option<&str> { + Some("opencode-plugin") + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + Ok(Vec::new()) + } + } + + fn local_tool_context(workspace_root: &Path, runtime_agent_key: &str) -> ToolUseContext { + crate::agentic::tools::tool_context_runtime::build_tool_description_context( + runtime_agent_key, + Some(&crate::agentic::WorkspaceBinding::new( + None, + workspace_root.to_path_buf(), + )), + None, + None, + &Default::default(), + &Default::default(), + ) + } + fn candidate( id: &str, kind: ExternalToolConflictCandidateKind, @@ -2258,6 +2592,48 @@ mod tests { assert!(has_tool_conflict_history(&choices, "domain", "read")); } + #[test] + fn live_plugin_route_is_visible_only_to_opencode_plugin_agents() { + let workspace = std::env::current_dir().expect("absolute workspace"); + let tool_name = "plugin_agent_scope_contract".to_string(); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let plugin: Arc = Arc::new(PluginTestTool { + name: tool_name.clone(), + }); + let mux = ExternalToolMux::new(tool_name, Some(original.clone())); + mux.set_route( + workspace_route_key(Some(&workspace)), + WorkspaceRoute::Live { + tool: plugin.clone(), + conflict: None, + }, + ); + + let plugin_context = local_tool_context( + &workspace, + "external_subagent_runtime:opencode-plugin:generation-agent", + ); + assert!(Arc::ptr_eq( + &mux.selected(Some(&plugin_context)).expect("plugin route"), + &plugin + )); + + for runtime_agent_key in [ + "Agentic", + "Plan", + "external_subagent_runtime:other-provider:agent", + "external_subagent_runtime:opencode:agent", + ] { + let context = local_tool_context(&workspace, runtime_agent_key); + assert!(Arc::ptr_eq( + &mux.selected(Some(&context)).expect("native fallback"), + &original + )); + } + } + #[tokio::test] async fn concurrent_workspace_routes_install_one_shared_mux() { let router = Arc::new(ExternalToolRouter::default()); @@ -2292,6 +2668,226 @@ mod tests { .unregister_tool(&tool_name); } + #[tokio::test] + async fn live_plugin_conflict_preserves_builtin_until_selected() { + use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalMcpRevisionKey, ExternalSourceContext, + }; + + let directory = tempfile::tempdir().expect("temporary workspace"); + let workspace_key = workspace_route_key(Some(directory.path())); + let tool_name = format!("live_plugin_conflict_{}", std::process::id()); + let original: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + get_global_tool_registry() + .write() + .await + .register_tool_without_external_source_notification(original.clone()); + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let candidate_id = format!("external:live:opencode-plugin:{tool_name}"); + let control_plane = Arc::new( + ExternalSourceControlPlane::new( + ExternalSourceContext { + workspace_root: Some(directory.path().to_path_buf()), + execution_domain_id: ExecutionDomainId::new("test-domain").unwrap(), + }, + ExternalMcpRevisionKey::new([3; 32]), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + ) + .unwrap(), + ); + router().register_live_candidate( + &workspace_key, + live.clone(), + "opencode-plugin", + "plugin-v1".to_string(), + ); + let empty_ecosystems = BTreeSet::new(); + let empty_strings = BTreeSet::new(); + let empty_map = BTreeMap::new(); + + let unresolved = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &empty_map, + }, + &empty_strings, + ) + .await; + assert_eq!(unresolved.conflicts.len(), 1); + assert!(unresolved.conflicts[0] + .candidates + .iter() + .any(|candidate| candidate.candidate_id == candidate_id)); + let mux = router() + .muxes + .lock() + .expect("router lock") + .get(&tool_name) + .cloned() + .expect("installed mux"); + assert!(Arc::ptr_eq( + &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &original + )); + + let choices = + BTreeMap::from([(unresolved.conflicts[0].conflict_key.clone(), candidate_id)]); + let selected = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &choices, + }, + &empty_strings, + ) + .await; + assert_eq!( + selected.conflicts[0].selected_candidate_id, + Some(format!("external:live:opencode-plugin:{tool_name}")) + ); + assert!(Arc::ptr_eq( + &mux.selected_for_workspace(Some(directory.path())).unwrap(), + &live + )); + + router().unregister_live_candidate(&workspace_key, &tool_name, "opencode-plugin"); + let withdrawn = reconcile_external_tools( + Some(directory.path()), + "test-domain", + &control_plane, + ExternalToolDecisions { + active_ecosystems: &empty_ecosystems, + approved_targets: &empty_strings, + declined_decisions_by_approval: &empty_map, + conflict_choices: &choices, + }, + &empty_strings, + ) + .await; + assert_eq!(withdrawn.conflicts.len(), 1); + assert_eq!(withdrawn.conflicts[0].selected_candidate_id, None); + assert!(mux.selected_for_workspace(Some(directory.path())).is_none()); + + router().apply_routes(&workspace_key, BTreeMap::new()).await; + router() + .muxes + .lock() + .expect("router lock") + .remove(&tool_name); + get_global_tool_registry() + .write() + .await + .unregister_tool(&tool_name); + } + + #[tokio::test] + async fn late_local_registration_pauses_an_active_live_plugin_route() { + let router = ExternalToolRouter::default(); + let tool_name = "late_local_plugin_conflict".to_string(); + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let mux = Arc::new(ExternalToolMux::new(tool_name.clone(), None)); + mux.set_route( + "workspace".to_string(), + WorkspaceRoute::Live { + tool: live, + conflict: None, + }, + ); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux.clone()); + + let local: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + let routed = router.intercept_registration(local.clone()); + + let expected_mux: Arc = mux; + assert!(Arc::ptr_eq(&routed, &expected_mux)); + assert!(Arc::ptr_eq( + &router + .original_tool(&tool_name) + .await + .expect("local candidate"), + &local + )); + assert!(matches!( + router.workspace_routes("workspace").get(&tool_name), + Some(WorkspaceRoute::Original { conflict: None }) + )); + } + + #[tokio::test] + async fn late_live_registration_pauses_an_active_external_route() { + let router = ExternalToolRouter::default(); + let tool_name = "late_plugin_external_conflict".to_string(); + let external = Arc::new(LoadedExternalTool { + descriptor: ScriptToolDescriptor { + export_name: "run".to_string(), + name: tool_name.clone(), + description: "external".to_string(), + input_schema: serde_json::json!({"type": "object"}), + }, + ecosystem_id: "test".to_string(), + provider_id: "test-provider".to_string(), + runtime_target_id: "target".to_string(), + load_generation: 1, + revision: "v1".to_string(), + approval_key: "approval".to_string(), + source_preference_key: "test:source".to_string(), + workspace_key: "workspace".to_string(), + target_tool_names: Arc::new(vec![tool_name.clone()]), + worktree_root: None, + runtime: Arc::new(NodeScriptToolRuntime::discover()), + }); + let mux = Arc::new(ExternalToolMux::new(tool_name.clone(), None)); + mux.set_route( + "workspace".to_string(), + WorkspaceRoute::External { + tool: external, + conflict: None, + }, + ); + router + .muxes + .lock() + .expect("router lock") + .insert(tool_name.clone(), mux); + + let live: Arc = Arc::new(TestTool { + name: tool_name.clone(), + }); + router + .apply_initial_live_candidate_route("workspace", &tool_name, live) + .await; + + assert!(matches!( + router.workspace_routes("workspace").get(&tool_name), + Some(WorkspaceRoute::Unavailable { conflict: None }) + )); + } + #[tokio::test] async fn last_route_removal_keeps_concurrent_registration_behind_the_mux() { let tool_name = "external_router_last_route_registration_contract".to_string(); diff --git a/src/crates/assembly/core/src/lib.rs b/src/crates/assembly/core/src/lib.rs index 080b66a3a7..1fc2c50c42 100644 --- a/src/crates/assembly/core/src/lib.rs +++ b/src/crates/assembly/core/src/lib.rs @@ -37,6 +37,10 @@ pub mod native_hooks; #[cfg(all(test, feature = "agent-runtime"))] mod native_hooks_tests; #[cfg(feature = "opencode-plugin-host")] +mod plugin_config_projection; +#[cfg(feature = "opencode-plugin-host")] +mod plugin_hook_bridge; +#[cfg(feature = "opencode-plugin-host")] pub mod plugin_host; #[cfg(feature = "opencode-plugin-host")] mod plugin_host_http; diff --git a/src/crates/assembly/core/src/native_hooks.rs b/src/crates/assembly/core/src/native_hooks.rs index e2aa8e70a4..33b51212d6 100644 --- a/src/crates/assembly/core/src/native_hooks.rs +++ b/src/crates/assembly/core/src/native_hooks.rs @@ -20,21 +20,185 @@ use crate::infrastructure::try_get_path_manager_arc; use crate::service::config::get_global_config_service; pub use crate::service::config::types::AgentHooksConfig; +use async_trait::async_trait; use bitfun_agent_runtime::native_hooks::{ AgentHookEngine, AgentHookEvent, AgentHookEventPayload, AgentHookMatcher, AgentHookOutcome, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, AgentHookPermissionOutcome, - AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, + AgentHookScope, AgentHookSettings, AgentHookSettingsLayer, BuiltinHookExecutor, HookCall, + HookCallPayload, HookHandler, HookHandlerResult, RuntimeHookKind, RuntimeHookPlan, + RuntimeHookRegistration, RuntimeHookRegistry, RuntimeHookSource, MAX_HOOKS_FILE_BYTES, +}; +use bitfun_agent_runtime::post_call_hooks::{ + resolve_deep_review_shared_context_tool_use, DeepReviewSharedContextToolUseFacts, }; use dashmap::DashMap; use log::{debug, info, warn}; use serde_json::Value; -use std::collections::BTreeMap; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -const MAX_CACHED_WORKSPACE_ENGINES: usize = 32; +const MAX_CACHED_WORKSPACE_HOOK_SOURCES: usize = 32; const MAX_PENDING_CONTEXT_SESSIONS: usize = 1024; +pub(crate) fn new_runtime_hook_registry() -> RuntimeHookRegistry { + let registry = RuntimeHookRegistry::default(); + registry + .register_batch(vec![deep_review_builtin_registration()]) + .expect("deep review builtin registration must be valid"); + registry +} + +pub(crate) fn runtime_hook_registry() -> RuntimeHookRegistry { + crate::agentic::coordination::get_global_coordinator() + .map(|coordinator| coordinator.hook_registry().clone()) + .expect("Agent Runtime hook registry is unavailable before coordinator initialization") +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn plugin_hook_registry(_workspace_scope: &str) -> RuntimeHookRegistry { + runtime_hook_registry() +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_hook( + workspace_scope: &str, + generation: Option<&bitfun_agent_runtime::native_hooks::PluginHookGenerationIdentity>, + hook_name: &str, + input: Value, + output: Value, +) -> bitfun_agent_runtime::native_hooks::PluginHookDispatchResult { + AgentHookEngine::with_registry(plugin_hook_registry(workspace_scope)) + .dispatch_plugin_hook_for_generation( + Some(workspace_scope), + generation, + hook_name, + input, + output, + ) + .await +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_tool_before( + workspace_scope: &str, + tool_name: &str, + session_id: Option<&str>, + call_id: Option<&str>, + runtime_agent_key: Option<&str>, + args: Value, +) -> Option { + let generation = match runtime_agent_key { + Some(runtime_agent_key) => { + let generation = crate::plugin_host::plugin_hook_generation_for_agent( + workspace_scope, + runtime_agent_key, + ) + .await; + if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + && generation.is_none() + { + return None; + } + generation + } + None => None, + }; + let result = dispatch_plugin_hook( + workspace_scope, + generation.as_ref(), + "tool.execute.before", + serde_json::json!({ + "tool": tool_name, + "sessionID": session_id, + "callID": call_id, + }), + serde_json::json!({ "args": args }), + ) + .await; + for warning in &result.warnings { + warn!("OpenCode plugin hook warning (tool.execute.before): {warning}"); + } + result + .output + .get("args") + .cloned() + .filter(|updated| updated != &serde_json::Value::Null) +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) async fn dispatch_plugin_tool_after( + workspace_scope: &str, + tool_name: &str, + session_id: Option<&str>, + call_id: Option<&str>, + runtime_agent_key: Option<&str>, + args: Value, + title: String, + output: String, + metadata: Value, +) -> Option { + let generation = match runtime_agent_key { + Some(runtime_agent_key) => { + let generation = crate::plugin_host::plugin_hook_generation_for_agent( + workspace_scope, + runtime_agent_key, + ) + .await; + if crate::plugin_config_projection::is_plugin_agent_runtime_key(runtime_agent_key) + && generation.is_none() + { + return None; + } + generation + } + None => None, + }; + let result = dispatch_plugin_hook( + workspace_scope, + generation.as_ref(), + "tool.execute.after", + serde_json::json!({ + "tool": tool_name, + "sessionID": session_id, + "callID": call_id, + "args": args, + }), + serde_json::json!({ + "title": title, + "output": output, + "metadata": metadata, + }), + ) + .await; + for warning in &result.warnings { + warn!("OpenCode plugin hook warning (tool.execute.after): {warning}"); + } + match serde_json::from_value::(result.output) { + Ok(output) => Some(output), + Err(error) => { + warn!("Ignoring invalid tool.execute.after output: {error}"); + None + } + } +} + +#[cfg(feature = "opencode-plugin-host")] +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PluginToolAfterOutput { + pub(crate) title: String, + pub(crate) output: String, + pub(crate) metadata: Value, +} + +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn clear_plugin_hook_workspace(workspace_scope: &str) { + runtime_hook_registry() + .clear_source_workspace(RuntimeHookSource::OpenCodePlugin, workspace_scope); +} + /// Everything a dispatch site knows about the running session. #[derive(Debug, Clone, Copy)] pub struct NativeHookSessionFacts<'a> { @@ -344,10 +508,222 @@ pub fn clear_session_hook_state(session_id: &str) { pending_session_context().remove(session_id); } +/// Built-in DeepReview shared-context measurement hook. +/// +/// Registered as a SuccessfulToolPostCall builtin so the shared hook +/// registry owns it alongside command hooks, instead of a hard-coded +/// function call in the tool pipeline. +struct DeepReviewSharedContextExecutor; + +#[async_trait] +impl BuiltinHookExecutor for DeepReviewSharedContextExecutor { + async fn execute(&self, call: &HookCall) -> HookHandlerResult { + let HookCallPayload::ToolUse { + name, + input, + custom_data, + agent_type, + } = &call.payload + else { + return HookHandlerResult::default(); + }; + let facts = DeepReviewSharedContextToolUseFacts { + tool_name: name.as_str(), + input, + custom_data, + workspace_root: call.workspace_root.as_deref(), + is_remote: call.is_remote, + agent_type: agent_type.as_deref(), + }; + if let Some(record) = resolve_deep_review_shared_context_tool_use(facts) { + crate::agentic::deep_review_policy::record_deep_review_shared_context_tool_use( + &record.parent_turn_id, + &record.subagent_type, + &record.tool_name, + &record.measured_path, + ); + } + HookHandlerResult::default() + } +} + +fn deep_review_builtin_registration() -> RuntimeHookRegistration { + let plan = RuntimeHookPlan::new( + "deep-review.shared-context", + RuntimeHookKind::SuccessfulToolPostCall, + RuntimeHookSource::Builtin { priority: 0 }, + ); + RuntimeHookRegistration::new( + plan, + HookHandler::Builtin { + executor: Arc::new(DeepReviewSharedContextExecutor), + }, + AgentHookMatcher::Any, + ) +} + +fn canonical_hook_workspace_scope(path: &Path) -> Option { + if !path.is_absolute() { + return None; + } + let mut scope = crate::agentic::workspace::canonical_local_workspace_path(path) + .to_string_lossy() + .replace('\\', "/"); + #[cfg(windows)] + scope.make_ascii_lowercase(); + Some(scope) +} + +fn command_registration_id( + source: RuntimeHookSource, + workspace_scope: Option<&str>, + original_id: &str, +) -> String { + let identity = format!( + "{}\0{}\0{}", + source, + workspace_scope.unwrap_or(""), + original_id + ); + format!( + "command.{}.{}", + source, + hex::encode(Sha256::digest(identity.as_bytes())) + ) +} + +fn registrations_for_source( + settings: AgentHookSettings, + source: RuntimeHookSource, + workspace_scope: Option<&str>, +) -> Vec { + settings + .registrations() + .into_iter() + .filter(|entry| match source { + RuntimeHookSource::UserCommand => entry.plan.source() == RuntimeHookSource::UserCommand, + RuntimeHookSource::ProjectCommand => { + entry.plan.source() == RuntimeHookSource::ProjectCommand + } + RuntimeHookSource::ImportedCommand => true, + _ => false, + }) + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry + .plan + .with_source(source) + .with_id(command_registration_id( + source, + workspace_scope, + &original_id, + )); + entry.workspace_scope = workspace_scope.map(str::to_string); + entry + }) + .collect() +} + +fn publish_command_registrations( + registry: &RuntimeHookRegistry, + workspace_scope: Option<&str>, + manual_settings: AgentHookSettings, + imported_settings: AgentHookSettings, +) -> Result<(), bitfun_agent_runtime::native_hooks::RuntimeHookRegistryError> { + let manual = manual_settings.registrations(); + let user_entries = manual + .iter() + .filter(|entry| entry.plan.source() == RuntimeHookSource::UserCommand) + .cloned() + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry.plan.with_id(command_registration_id( + RuntimeHookSource::UserCommand, + None, + &original_id, + )); + entry + }) + .collect(); + let project_entries = manual + .into_iter() + .filter(|entry| entry.plan.source() == RuntimeHookSource::ProjectCommand) + .map(|mut entry| { + let original_id = entry.plan.id().to_string(); + entry.plan = entry.plan.with_id(command_registration_id( + RuntimeHookSource::ProjectCommand, + workspace_scope, + &original_id, + )); + entry.workspace_scope = workspace_scope.map(str::to_string); + entry + }) + .collect(); + let imported_entries = registrations_for_source( + imported_settings, + RuntimeHookSource::ImportedCommand, + workspace_scope, + ); + + registry.replace_command_source(RuntimeHookSource::UserCommand, None, user_entries)?; + registry.replace_command_source( + RuntimeHookSource::ProjectCommand, + workspace_scope, + project_entries, + )?; + registry.replace_command_source( + RuntimeHookSource::ImportedCommand, + workspace_scope, + imported_entries, + ) +} + +/// Dispatch SuccessfulToolPostCall builtin hooks (currently the DeepReview +/// shared-context measurement) for one successful tool call. Command hooks +/// are not dispatched here because the tool pipeline owns post-call context. +pub async fn dispatch_successful_tool_post_call( + workspace_root: Option<&Path>, + is_remote: bool, + tool_name: &str, + input: &Value, + custom_data: &HashMap, + agent_type: Option<&str>, +) { + let config = hooks_config().await; + let Some(engine) = engine_for(workspace_root, config.project_hooks_enabled).await else { + return; + }; + let call = HookCall { + kind: RuntimeHookKind::SuccessfulToolPostCall, + cwd: workspace_root.map(Path::to_path_buf).unwrap_or_default(), + session_id: None, + turn_id: None, + workspace_root: workspace_root.map(Path::to_path_buf), + is_remote, + model: None, + bypass_permissions: false, + payload: HookCallPayload::ToolUse { + name: tool_name.to_string(), + input: input.clone(), + custom_data: custom_data.clone(), + agent_type: agent_type.map(str::to_string), + }, + }; + let _ = engine + .dispatch_call( + workspace_root + .and_then(canonical_hook_workspace_scope) + .as_deref(), + &call, + ) + .await; +} + struct PreparedDispatch<'a> { - engine: Arc, + engine: AgentHookEngine, facts: NativeHookSessionFacts<'a>, cwd: PathBuf, + workspace_scope: Option, } impl PreparedDispatch<'_> { @@ -368,7 +744,10 @@ impl PreparedDispatch<'_> { event, }; let event_name = payload.event(); - let outcome = self.engine.dispatch(&payload, &self.cwd).await; + let outcome = self + .engine + .dispatch_for_workspace(&payload, &self.cwd, self.workspace_scope.as_deref()) + .await; for warning in &outcome.warnings { warn!("Agent hook warning ({event_name}): {warning}"); } @@ -397,7 +776,10 @@ async fn prepare<'a>( return None; } let engine = engine_for(facts.workspace_root, config.project_hooks_enabled).await?; - if !engine.has_rules(event) { + let workspace_scope = facts + .workspace_root + .and_then(canonical_hook_workspace_scope); + if !engine.has_rules_for_workspace(event, workspace_scope.as_deref()) { return None; } let cwd = facts @@ -405,7 +787,12 @@ async fn prepare<'a>( .map(Path::to_path_buf) .or_else(|| std::env::current_dir().ok()) .unwrap_or_default(); - Some(PreparedDispatch { engine, facts, cwd }) + Some(PreparedDispatch { + engine, + facts, + cwd, + workspace_scope, + }) } /// Dot-path of the hook gates inside the settings document. Config paths @@ -447,17 +834,16 @@ fn fingerprint(path: PathBuf) -> HookFileFingerprint { } } -struct CachedHookEngine { - engine: Arc, +struct CachedHookSourceState { fingerprints: Vec, project_hooks_enabled: bool, imported_generation: u64, } -type EngineCache = tokio::sync::Mutex, CachedHookEngine>>; +type HookSourceCache = tokio::sync::Mutex, CachedHookSourceState>>; -fn engine_cache() -> &'static EngineCache { - static CACHE: OnceLock = OnceLock::new(); +fn hook_source_cache() -> &'static HookSourceCache { + static CACHE: OnceLock = OnceLock::new(); CACHE.get_or_init(|| tokio::sync::Mutex::new(BTreeMap::new())) } @@ -541,12 +927,9 @@ pub(crate) fn build_engine(paths: &[(AgentHookScope, PathBuf)]) -> AgentHookEngi async fn engine_for( workspace_root: Option<&Path>, project_hooks_enabled: bool, -) -> Option> { +) -> Option { let key = workspace_root.map(Path::to_path_buf); let paths = hook_settings_paths(workspace_root, project_hooks_enabled); - if paths.is_empty() { - return None; - } let fingerprints = paths .iter() .map(|(_, path)| fingerprint(path.clone())) @@ -568,15 +951,15 @@ async fn engine_for( } }; { - let cache = engine_cache().lock().await; + let cache = hook_source_cache().lock().await; if let Some(cached) = cache.get(&key) { - if let Some(engine) = reusable_cached_engine( + if reusable_cached_hook_source( cached, &fingerprints, project_hooks_enabled, imported_generation, ) { - return Some(engine); + return Some(AgentHookEngine::with_registry(runtime_hook_registry())); } } } @@ -601,41 +984,53 @@ async fn engine_for( for message in &skipped { warn!("{message}"); } - let layers = ordered_layers(manual_layers, imported_layers); - let (settings, issues) = AgentHookSettings::from_layers(&layers); - for issue in &issues { + let (manual_settings, manual_issues) = AgentHookSettings::from_layers(&manual_layers); + let (imported_settings, imported_issues) = AgentHookSettings::from_layers(&imported_layers); + for issue in manual_issues.iter().chain(imported_issues.iter()) { warn!("Agent hook configuration issue: {issue}"); } - let engine = Arc::new(AgentHookEngine::new(settings)); - let mut cache = engine_cache().lock().await; - if cache.len() >= MAX_CACHED_WORKSPACE_ENGINES && !cache.contains_key(&key) { + let workspace_scope = workspace_root.and_then(canonical_hook_workspace_scope); + if let Err(error) = publish_command_registrations( + &runtime_hook_registry(), + workspace_scope.as_deref(), + manual_settings, + imported_settings, + ) { + warn!("Failed to publish agent hook registrations: {error}"); + return None; + } + let mut cache = hook_source_cache().lock().await; + if cache.len() >= MAX_CACHED_WORKSPACE_HOOK_SOURCES && !cache.contains_key(&key) { let oldest = cache.keys().next().cloned(); if let Some(oldest) = oldest { cache.remove(&oldest); + if let Some(scope) = oldest.as_deref().and_then(canonical_hook_workspace_scope) { + let registry = runtime_hook_registry(); + registry.clear_source_workspace(RuntimeHookSource::ProjectCommand, &scope); + registry.clear_source_workspace(RuntimeHookSource::ImportedCommand, &scope); + } } } cache.insert( key, - CachedHookEngine { - engine: Arc::clone(&engine), + CachedHookSourceState { fingerprints, project_hooks_enabled, imported_generation, }, ); - Some(engine) + Some(AgentHookEngine::with_registry(runtime_hook_registry())) } -fn reusable_cached_engine( - cached: &CachedHookEngine, +fn reusable_cached_hook_source( + cached: &CachedHookSourceState, fingerprints: &[HookFileFingerprint], project_hooks_enabled: bool, imported_generation: u64, -) -> Option> { - (cached.fingerprints == fingerprints +) -> bool { + cached.fingerprints == fingerprints && cached.project_hooks_enabled == project_hooks_enabled - && cached.imported_generation == imported_generation) - .then(|| Arc::clone(&cached.engine)) + && cached.imported_generation == imported_generation } #[cfg(test)] @@ -643,19 +1038,15 @@ mod cache_tests { use super::*; #[test] - fn imported_generation_replaces_the_next_engine_without_invalidating_a_captured_one() { - let captured = Arc::new(AgentHookEngine::new(Default::default())); - let cached = CachedHookEngine { - engine: Arc::clone(&captured), + fn imported_generation_invalidates_the_cached_hook_source_state() { + let cached = CachedHookSourceState { fingerprints: Vec::new(), project_hooks_enabled: false, imported_generation: 7, }; - let reused = reusable_cached_engine(&cached, &[], false, 7).unwrap(); - assert!(Arc::ptr_eq(&captured, &reused)); - assert!(reusable_cached_engine(&cached, &[], false, 8).is_none()); - assert_eq!(Arc::strong_count(&captured), 3); + assert!(reusable_cached_hook_source(&cached, &[], false, 7)); + assert!(!reusable_cached_hook_source(&cached, &[], false, 8)); } } diff --git a/src/crates/assembly/core/src/plugin_config_projection.rs b/src/crates/assembly/core/src/plugin_config_projection.rs new file mode 100644 index 0000000000..aeb6e6baeb --- /dev/null +++ b/src/crates/assembly/core/src/plugin_config_projection.rs @@ -0,0 +1,1343 @@ +use crate::agentic::agents::{ + external_subagent_runtime_key, get_agent_registry, shared_coding_mode_tools, ExploreAgent, + ExternalProvidedAgent, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, +}; +use bitfun_product_domains::external_sources::EcosystemId; +use bitfun_product_domains::external_subagents::ExternalSubagentMode; +use bitfun_runtime_ports::{PermissionConstraintLayer, PermissionEffect, PermissionRule}; +use serde::Deserialize; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock, RwLock}; + +const MAX_AGENT_ID_BYTES: usize = 128; +const MAX_DESCRIPTION_BYTES: usize = 4096; +const MAX_PROMPT_BYTES: usize = 1024 * 1024; +const MAX_PLUGIN_SKILL_ROOTS: usize = 64; +const MIN_AGENT_TEMPERATURE: f64 = 0.0; +const MAX_AGENT_TEMPERATURE: f64 = 2.0; +const OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER: &str = "opencode-plugin-config"; + +pub(crate) fn is_plugin_agent_runtime_key(runtime_agent_key: &str) -> bool { + runtime_agent_key.starts_with("external_subagent_runtime:opencode-plugin:") +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PluginIdentity { + id: Option, + spec: String, + entry: String, + index: usize, +} + +impl PluginIdentity { + fn stable_key(&self) -> String { + format!("{}\n{}\n{}", self.spec, self.entry, self.index) + } + + fn label(&self) -> String { + self.id.clone().unwrap_or_else(|| self.spec.clone()) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigContributor { + plugin: PluginIdentity, + outcome: ContributorOutcome, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ConfigContribution { + plugin: PluginIdentity, + outcome: ContributorOutcome, + config: Map, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +enum ContributorOutcome { + Applied, + Failed, +} + +#[derive(Debug, Clone)] +pub(crate) struct PluginSkillRootContribution { + pub(crate) path: PathBuf, + pub(crate) precedence: usize, +} + +#[derive(Debug, Clone)] +struct PublishedSkillGeneration { + generation_key: String, + roots_by_runtime_agent: BTreeMap>, +} + +fn skill_generations() -> &'static RwLock> { + static GENERATIONS: OnceLock>> = + OnceLock::new(); + GENERATIONS.get_or_init(|| RwLock::new(HashMap::new())) +} + +pub(crate) struct PluginConfigProjectionPlan { + workspace_root: PathBuf, + generation_key: String, + registrations: Vec, + routes: BTreeMap, + runtime_agent_keys: BTreeSet, + skill_roots_by_runtime_agent: BTreeMap>, + tool_runtime_agent_keys: BTreeMap<(PluginIdentity, String), BTreeSet>, +} + +impl PluginConfigProjectionPlan { + pub(crate) fn empty(workspace_root: &Path, generation_key: &str) -> Self { + Self { + workspace_root: workspace_root.to_path_buf(), + generation_key: generation_key.to_string(), + registrations: Vec::new(), + routes: BTreeMap::new(), + runtime_agent_keys: BTreeSet::new(), + skill_roots_by_runtime_agent: BTreeMap::new(), + tool_runtime_agent_keys: BTreeMap::new(), + } + } + + pub(crate) fn agent_runtime_keys(&self) -> BTreeSet { + self.runtime_agent_keys.clone() + } + + pub(crate) fn allowed_runtime_agent_keys_for_tool( + &self, + tool: &Value, + ) -> crate::BitFunResult> { + let plugin = tool + .get("plugin") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()) + .ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool identity is missing".to_string()) + })?; + let id = tool.get("id").and_then(Value::as_str).ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool id is missing".to_string()) + })?; + Ok(self + .tool_runtime_agent_keys + .get(&(plugin, id.to_string())) + .cloned() + .unwrap_or_default()) + } + + pub(crate) fn commit(self) { + get_agent_registry().replace_external_subagent_route_overlay( + &self.workspace_root, + OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER, + self.registrations, + self.routes, + ); + let mut generations = skill_generations() + .write() + .expect("plugin skill generation lock poisoned"); + generations.insert( + self.workspace_root, + PublishedSkillGeneration { + generation_key: self.generation_key, + roots_by_runtime_agent: self.skill_roots_by_runtime_agent, + }, + ); + } +} + +pub(crate) fn release_workspace(workspace_root: &Path) { + get_agent_registry().release_external_subagent_route_overlay( + workspace_root, + OPENCODE_PLUGIN_CONFIG_ROUTE_OWNER, + ); + skill_generations() + .write() + .expect("plugin skill generation lock poisoned") + .remove(workspace_root); +} + +pub(crate) fn skill_roots_for_agent( + workspace_root: Option<&Path>, + runtime_agent_key: Option<&str>, +) -> Vec { + let (Some(workspace_root), Some(runtime_agent_key)) = (workspace_root, runtime_agent_key) + else { + return Vec::new(); + }; + let workspace_root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + skill_generations() + .read() + .expect("plugin skill generation lock poisoned") + .get(&workspace_root) + .and_then(|generation| generation.roots_by_runtime_agent.get(runtime_agent_key)) + .cloned() + .unwrap_or_default() +} + +pub(crate) fn prepare( + workspace_root: &Path, + generation_key: &str, + initial_config: &Map, + open_result: &Value, +) -> crate::BitFunResult { + let contributors = serde_json::from_value::>( + open_result + .get("configContributors") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())), + ) + .map_err(|error| { + crate::BitFunError::Validation(format!("Invalid plugin config contributors: {error}")) + })?; + if contributors.is_empty() { + return Ok(PluginConfigProjectionPlan::empty( + workspace_root, + generation_key, + )); + } + let config = open_result + .get("config") + .and_then(Value::as_object) + .ok_or_else(|| { + crate::BitFunError::Validation("Plugin config result is missing".to_string()) + })?; + let contributions = config_contribution_sequence(open_result, &contributors, config)?; + let attribution = attribute_config(initial_config, &contributions, config)?; + let final_agents = config_object_field(config, "agent")?; + let plugin_tools = plugin_tool_ids_by_owner(open_result)?; + let tool_owners = plugin_tools + .iter() + .flat_map(|(owner, tools)| tools.iter().cloned().map(|tool| (tool, owner.clone()))) + .collect::>(); + let all_plugin_tools = tool_owners.keys().cloned().collect::>(); + + let mut registrations = Vec::new(); + let mut routes = BTreeMap::new(); + let mut runtime_agent_keys = BTreeSet::new(); + let mut runtime_agent_keys_by_plugin = BTreeMap::>::new(); + let mut tool_runtime_agent_keys = BTreeMap::<(PluginIdentity, String), BTreeSet>::new(); + for (logical_id, value) in final_agents { + let Some(owner) = attribution.agent_owners.get(&logical_id) else { + continue; + }; + let definition = value.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{logical_id}' must be an object")) + })?; + validate_agent_id(&logical_id)?; + let mode = parse_mode(definition.get("mode"), &logical_id)?; + let hidden = parse_hidden(definition.get("hidden"), &logical_id)?; + let temperature = parse_temperature(definition.get("temperature"), &logical_id)?; + let description = parse_description(definition.get("description"), owner)?; + let prompt = parse_prompt(definition.get("prompt"), &logical_id)?; + let mut eligible_tools = plugin_tools.get(owner).cloned().unwrap_or_default(); + if let Some(permission) = definition.get("permission").and_then(Value::as_object) { + for (tool, effect) in permission { + if !matches!(effect.as_str(), Some("allow" | "ask")) { + continue; + } + let Some(tool_owner) = tool_owners.get(tool) else { + continue; + }; + if attribution + .permission_owners + .get(&(logical_id.clone(), tool.clone())) + == Some(tool_owner) + { + eligible_tools.insert(tool.clone()); + } + } + } + let (permission_constraints, denied_plugin_tools) = + parse_permissions(definition.get("permission"), &all_plugin_tools, &logical_id)?; + let mut tools = native_tool_baseline(&logical_id, mode, workspace_root); + let permitted_plugin_tools = eligible_tools + .iter() + .filter(|tool| !denied_plugin_tools.contains(*tool)) + .cloned() + .collect::>(); + tools.extend(permitted_plugin_tools.iter().cloned()); + // A plugin Tool intentionally shadows a same-name native candidate for + // this plugin Agent. Remove the earlier entry before the final stable + // de-duplication so the manifest still contains one model-facing name. + for plugin_tool in &permitted_plugin_tools { + if let Some(position) = tools.iter().position(|tool| tool == plugin_tool) { + tools.remove(position); + tools.push(plugin_tool.clone()); + } + } + tools.sort(); + tools.dedup(); + + let mut hasher = Sha256::new(); + hasher.update(generation_key.as_bytes()); + hasher.update([0]); + hasher.update(owner.stable_key().as_bytes()); + hasher.update([0]); + hasher.update(logical_id.as_bytes()); + hasher.update([0]); + hasher.update([u8::from(hidden)]); + hasher.update([0]); + if let Some(temperature) = temperature { + hasher.update(temperature.to_bits().to_le_bytes()); + } else { + hasher.update([0xff]); + } + let digest = hex::encode(hasher.finalize()); + let runtime_key = external_subagent_runtime_key(&format!("opencode-plugin:{digest}")); + let behavior_version = format!("sha256:{digest}"); + let agent = Arc::new(ExternalProvidedAgent::new( + runtime_key.clone(), + logical_id.clone(), + description, + prompt, + tools, + permission_constraints, + temperature, + false, + behavior_version, + )); + registrations.push(ExternalSubagentRegistration { + runtime_key: runtime_key.clone(), + logical_id: logical_id.clone(), + route_key: format!( + "opencode:{}:{}", + hex::encode(Sha256::digest(owner.stable_key().as_bytes())), + logical_id.to_ascii_lowercase() + ), + ecosystem_id: EcosystemId::new("opencode").map_err(|error| { + crate::BitFunError::Validation(format!("Invalid OpenCode ecosystem id: {error}")) + })?, + provider_label: owner.label(), + model_binding: ExternalSubagentModelBinding::InheritParent, + hidden, + mode, + agent, + }); + routes.insert( + logical_id, + ExternalSubagentRoute::External(runtime_key.clone()), + ); + runtime_agent_keys_by_plugin + .entry(owner.clone()) + .or_default() + .insert(runtime_key.clone()); + for tool in permitted_plugin_tools { + let Some(tool_owner) = tool_owners.get(&tool) else { + continue; + }; + tool_runtime_agent_keys + .entry((tool_owner.clone(), tool)) + .or_default() + .insert(runtime_key.clone()); + } + runtime_agent_keys.insert(runtime_key); + } + + let skill_roots_by_plugin = attributed_skill_roots(config, &attribution.skill_owners)?; + let mut skill_roots_by_runtime_agent = BTreeMap::new(); + for (plugin, runtime_keys) in &runtime_agent_keys_by_plugin { + let Some(roots) = skill_roots_by_plugin.get(plugin) else { + continue; + }; + for runtime_key in runtime_keys { + skill_roots_by_runtime_agent.insert(runtime_key.clone(), roots.clone()); + } + } + Ok(PluginConfigProjectionPlan { + workspace_root: crate::agentic::workspace::canonical_local_workspace_path(workspace_root), + generation_key: generation_key.to_string(), + registrations, + routes, + runtime_agent_keys, + skill_roots_by_runtime_agent, + tool_runtime_agent_keys, + }) +} + +struct ConfigAttribution { + agent_owners: BTreeMap, + permission_owners: BTreeMap<(String, String), PluginIdentity>, + skill_owners: BTreeMap, +} + +fn config_contribution_sequence( + open_result: &Value, + contributors: &[ConfigContributor], + final_config: &Map, +) -> crate::BitFunResult> { + let Some(value) = open_result.get("configContributions") else { + if contributors.len() == 1 { + return Ok(vec![ConfigContribution { + plugin: contributors[0].plugin.clone(), + outcome: contributors[0].outcome, + config: final_config.clone(), + }]); + } + return Err(crate::BitFunError::Validation( + "unsupported_multiple_config_contributors: plugin host did not provide configContributions" + .to_string(), + )); + }; + let contributions = + serde_json::from_value::>(value.clone()).map_err(|error| { + crate::BitFunError::Validation(format!("Invalid plugin config contributions: {error}")) + })?; + if contributions.len() != contributors.len() + || contributions + .iter() + .zip(contributors) + .any(|(step, contributor)| { + step.plugin != contributor.plugin || step.outcome != contributor.outcome + }) + { + return Err(crate::BitFunError::Validation( + "Plugin config contribution sequence does not match configContributors".to_string(), + )); + } + if contributions.last().map(|step| &step.config) != Some(final_config) { + return Err(crate::BitFunError::Validation( + "Plugin config contribution sequence does not end at the final config".to_string(), + )); + } + Ok(contributions) +} + +fn attribute_config( + initial_config: &Map, + contributions: &[ConfigContribution], + final_config: &Map, +) -> crate::BitFunResult { + let mut previous = initial_config; + let mut agent_owners = BTreeMap::new(); + let mut permission_owners = BTreeMap::new(); + let mut skill_owners = BTreeMap::new(); + let mut previous_skills = skill_paths(initial_config)? + .into_iter() + .map(|path| normalized_skill_path_identity(&path)) + .collect::>(); + + for contribution in contributions { + validate_plugin_identity(&contribution.plugin)?; + let before_agents = config_object_field(previous, "agent")?; + let after_agents = config_object_field(&contribution.config, "agent")?; + let agent_ids = before_agents + .keys() + .chain(after_agents.keys()) + .cloned() + .collect::>(); + for agent_id in agent_ids { + let before_agent = before_agents.get(&agent_id); + let after_agent = after_agents.get(&agent_id); + if before_agent != after_agent && after_agent.is_some() { + // The plugin that first turns a native or absent Agent into a + // plugin-managed Agent remains its execution owner. Later + // hooks may refine fields, but do not silently transfer Tool + // and Skill ownership merely by editing a description or + // permission entry. + agent_owners + .entry(agent_id.clone()) + .or_insert_with(|| contribution.plugin.clone()); + } else if after_agent.is_none() { + agent_owners.remove(&agent_id); + } + + let before_permissions = agent_permission_object(before_agent, &agent_id)?; + let after_permissions = agent_permission_object(after_agent, &agent_id)?; + let permission_keys = before_permissions + .keys() + .chain(after_permissions.keys()) + .cloned() + .collect::>(); + for permission in permission_keys { + if before_permissions.get(&permission) == after_permissions.get(&permission) { + continue; + } + let key = (agent_id.clone(), permission.clone()); + if after_permissions.contains_key(&permission) { + permission_owners.insert(key, contribution.plugin.clone()); + } else { + permission_owners.remove(&key); + } + } + } + + let next_skills = skill_paths(&contribution.config)? + .into_iter() + .map(|path| normalized_skill_path_identity(&path)) + .collect::>(); + skill_owners.retain(|path, _| next_skills.contains(path)); + for added in next_skills.difference(&previous_skills) { + skill_owners.insert(added.clone(), contribution.plugin.clone()); + } + previous_skills = next_skills; + previous = &contribution.config; + } + if previous != final_config { + return Err(crate::BitFunError::Validation( + "Plugin config attribution did not reach the final config".to_string(), + )); + } + Ok(ConfigAttribution { + agent_owners, + permission_owners, + skill_owners, + }) +} + +fn agent_permission_object( + agent: Option<&Value>, + agent_id: &str, +) -> crate::BitFunResult> { + let Some(agent) = agent else { + return Ok(Map::new()); + }; + let agent = agent.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{agent_id}' must be an object")) + })?; + match agent.get("permission") { + None | Some(Value::Null) => Ok(Map::new()), + Some(Value::Object(permission)) => Ok(permission.clone()), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission must be an object" + ))), + } +} + +fn native_tool_baseline( + logical_id: &str, + mode: ExternalSubagentMode, + workspace_root: &Path, +) -> Vec { + if let Some(local_agent) = + get_agent_registry().get_local_agent(logical_id, Some(workspace_root)) + { + return local_agent.default_tools(); + } + if mode == ExternalSubagentMode::Subagent { + use crate::agentic::agents::Agent; + ExploreAgent::new().default_tools() + } else { + shared_coding_mode_tools() + } +} + +fn validate_plugin_identity(plugin: &PluginIdentity) -> crate::BitFunResult<()> { + if plugin.spec.trim().is_empty() || plugin.entry.trim().is_empty() { + return Err(crate::BitFunError::Validation( + "Plugin config contributor identity is incomplete".to_string(), + )); + } + Ok(()) +} + +fn validate_agent_id(id: &str) -> crate::BitFunResult<()> { + if id.trim() != id + || id.is_empty() + || id.len() > MAX_AGENT_ID_BYTES + || id.chars().any(char::is_control) + { + return Err(crate::BitFunError::Validation(format!( + "Invalid plugin agent id '{id}'" + ))); + } + Ok(()) +} + +fn parse_mode(value: Option<&Value>, id: &str) -> crate::BitFunResult { + match value.and_then(Value::as_str).unwrap_or("all") { + "primary" => Ok(ExternalSubagentMode::Primary), + "subagent" => Ok(ExternalSubagentMode::Subagent), + "all" => Ok(ExternalSubagentMode::All), + other => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' has unsupported mode '{other}'" + ))), + } +} + +fn parse_hidden(value: Option<&Value>, id: &str) -> crate::BitFunResult { + match value { + None | Some(Value::Null) => Ok(false), + Some(Value::Bool(hidden)) => Ok(*hidden), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' hidden must be a boolean" + ))), + } +} + +fn parse_temperature(value: Option<&Value>, id: &str) -> crate::BitFunResult> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_null() { + return Ok(None); + } + let temperature = value.as_f64().ok_or_else(|| { + crate::BitFunError::Validation(format!("Plugin agent '{id}' temperature must be a number")) + })?; + if !temperature.is_finite() + || !(MIN_AGENT_TEMPERATURE..=MAX_AGENT_TEMPERATURE).contains(&temperature) + { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' temperature must be between {MIN_AGENT_TEMPERATURE} and {MAX_AGENT_TEMPERATURE}" + ))); + } + Ok(Some(temperature)) +} + +fn parse_description( + value: Option<&Value>, + plugin: &PluginIdentity, +) -> crate::BitFunResult { + let description = value + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("Agent contributed by {}", plugin.label())); + if description.len() > MAX_DESCRIPTION_BYTES { + return Err(crate::BitFunError::Validation( + "Plugin agent description exceeds the size limit".to_string(), + )); + } + Ok(description) +} + +fn parse_prompt(value: Option<&Value>, id: &str) -> crate::BitFunResult { + let prompt = match value { + None | Some(Value::Null) => String::new(), + Some(Value::String(value)) => value.clone(), + Some(_) => { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' prompt must be a string" + ))) + } + }; + if prompt.len() > MAX_PROMPT_BYTES { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{id}' prompt exceeds the size limit" + ))); + } + Ok(prompt) +} + +fn plugin_tool_ids_by_owner( + open_result: &Value, +) -> crate::BitFunResult>> { + let mut result = BTreeMap::>::new(); + for tool in open_result + .get("tools") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let identity = tool + .get("plugin") + .cloned() + .and_then(|value| serde_json::from_value::(value).ok()); + let identity = identity.ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool identity is missing".to_string()) + })?; + validate_plugin_identity(&identity)?; + let id = tool.get("id").and_then(Value::as_str).ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool id is missing".to_string()) + })?; + if id.is_empty() || id.len() > 256 || id.chars().any(char::is_control) { + return Err(crate::BitFunError::Validation( + "Plugin tool id is invalid".to_string(), + )); + } + result.entry(identity).or_default().insert(id.to_string()); + } + Ok(result) +} + +fn parse_permissions( + value: Option<&Value>, + plugin_tools: &BTreeSet, + agent_id: &str, +) -> crate::BitFunResult<(PermissionConstraintLayer, BTreeSet)> { + let Some(value) = value else { + return Ok((PermissionConstraintLayer::default(), BTreeSet::new())); + }; + let permissions = value.as_object().ok_or_else(|| { + crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission must be an object" + )) + })?; + let known_native = [ + "bash", + "read", + "edit", + "task", + "skill", + "webfetch", + "websearch", + "git", + "external_directory", + ]; + let mut rules = Vec::new(); + let mut denied = BTreeSet::new(); + for (key, value) in permissions { + let effect = match value.as_str() { + Some("allow") => PermissionEffect::Allow, + Some("ask") => PermissionEffect::Ask, + Some("deny") => PermissionEffect::Deny, + _ => { + return Err(crate::BitFunError::Validation(format!( + "Plugin agent '{agent_id}' permission '{key}' is invalid" + ))) + } + }; + if plugin_tools.contains(key) { + rules.push(PermissionRule::new("custom_tool", key, effect)); + if effect == PermissionEffect::Deny { + denied.insert(key.clone()); + } + } else if known_native.contains(&key.as_str()) { + rules.push(PermissionRule::new(key, "*", effect)); + } else if effect == PermissionEffect::Allow { + log::warn!( + "Ignoring unsupported OpenCode plugin permission allow rule: agent_id={}, permission_action={}", + agent_id, + key + ); + } else { + return Err(crate::BitFunError::Validation(format!("Plugin agent '{agent_id}' permission '{key}' has no compatible action or plugin tool"))); + } + } + Ok((PermissionConstraintLayer::new(rules), denied)) +} + +fn config_object_field( + config: &Map, + field: &str, +) -> crate::BitFunResult> { + match config.get(field) { + None => Ok(Map::new()), + Some(Value::Object(value)) => Ok(value.clone()), + Some(_) => Err(crate::BitFunError::Validation(format!( + "Plugin config '{field}' must be an object" + ))), + } +} + +fn skill_paths(config: &Map) -> crate::BitFunResult> { + let Some(skills) = config.get("skills") else { + return Ok(Vec::new()); + }; + let skills = skills.as_object().ok_or_else(|| { + crate::BitFunError::Validation("Plugin config 'skills' must be an object".to_string()) + })?; + let Some(paths) = skills.get("paths") else { + return Ok(Vec::new()); + }; + let paths = paths.as_array().ok_or_else(|| { + crate::BitFunError::Validation("Plugin config 'skills.paths' must be an array".to_string()) + })?; + paths + .iter() + .map(|path| { + path.as_str().map(PathBuf::from).ok_or_else(|| { + crate::BitFunError::Validation( + "Plugin config 'skills.paths' entries must be strings".to_string(), + ) + }) + }) + .collect() +} + +fn normalized_skill_path_identity(path: &Path) -> PathBuf { + dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn attributed_skill_roots( + final_config: &Map, + owners: &BTreeMap, +) -> crate::BitFunResult>> { + let mut seen = BTreeSet::new(); + let mut roots = BTreeMap::>::new(); + for path in skill_paths(final_config)? { + let identity = normalized_skill_path_identity(&path); + let Some(owner) = owners.get(&identity) else { + continue; + }; + if !seen.insert(identity) { + continue; + } + if seen.len() > MAX_PLUGIN_SKILL_ROOTS { + return Err(crate::BitFunError::Validation( + "Plugin skill root count exceeds the limit".to_string(), + )); + } + if !path.is_absolute() { + return Err(crate::BitFunError::Validation( + "Plugin skill root must be absolute".to_string(), + )); + } + let metadata = std::fs::symlink_metadata(&path).map_err(|error| { + crate::BitFunError::Validation(format!("Plugin skill root is unavailable: {error}")) + })?; + if bitfun_services_core::bounded_fs::is_symlink_or_reparse(&metadata) || !metadata.is_dir() + { + return Err(crate::BitFunError::Validation( + "Plugin skill root must be a regular directory".to_string(), + )); + } + let canonical = dunce::canonicalize(&path).map_err(|error| { + crate::BitFunError::Validation(format!( + "Plugin skill root cannot be canonicalized: {error}" + )) + })?; + let owned_roots = roots.entry(owner.clone()).or_default(); + owned_roots.push(PluginSkillRootContribution { + path: canonical, + precedence: seen.len() - 1, + }); + } + Ok(roots) +} + +pub(crate) fn active_generation_key(workspace_root: &Path) -> Option { + let root = crate::agentic::workspace::canonical_local_workspace_path(workspace_root); + skill_generations() + .read() + .ok()? + .get(&root) + .map(|generation| generation.generation_key.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn plugin() -> Value { + json!({ + "id": "deveco-harness", + "spec": "D:/code/deveco_harness", + "entry": "D:/code/deveco_harness/dist/index.js", + "index": 0 + }) + } + + fn open_result() -> Value { + let plugin = plugin(); + let config = json!({ + "agent": { + "build": { + "mode": "primary", + "temperature": 0.7, + "description": "Build projects", + "prompt": "Build prompt", + "permission": {"build_project": "allow", "plan_write": "deny"} + }, + "explore": { + "mode": "subagent", + "hidden": true, + "description": "Explore projects", + "prompt": "Explore prompt", + "permission": {"bash": "deny"} + } + } + }); + json!({ + "configContributors": [{"plugin": plugin.clone(), "outcome": "applied"}], + "config": config.clone(), + "configContributions": [{"plugin": plugin.clone(), "outcome": "applied", "config": config}], + "tools": [ + {"id": "build_project", "plugin": plugin.clone()}, + {"id": "plan_write", "plugin": plugin} + ] + }) + } + + #[test] + fn maps_target_agent_fields_and_plugin_tool_permissions() { + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &open_result(), + ) + .expect("projection"); + + assert_eq!(plan.registrations.len(), 2); + let build = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap(); + assert_eq!(build.mode, ExternalSubagentMode::Primary); + assert!(!build.hidden); + assert_eq!(build.agent.model_temperature_override(), Some(0.7)); + assert_eq!(build.agent.description(), "Build projects"); + assert!(build + .agent + .default_tools() + .contains(&"build_project".to_string())); + assert!(!build + .agent + .default_tools() + .contains(&"plan_write".to_string())); + assert!(build + .agent + .permission_constraints() + .rules() + .iter() + .any(|rule| { + rule.action == "custom_tool" + && rule.resource == "plan_write" + && rule.effect == PermissionEffect::Deny + })); + + let explore = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "explore") + .unwrap(); + assert_eq!(explore.mode, ExternalSubagentMode::Subagent); + assert!(explore.hidden); + assert_eq!(explore.agent.model_temperature_override(), None); + assert!(explore + .agent + .permission_constraints() + .rules() + .iter() + .any(|rule| { + rule.action == "bash" + && rule.resource == "*" + && rule.effect == PermissionEffect::Deny + })); + assert_eq!(plan.runtime_agent_keys.len(), 2); + assert!(plan + .runtime_agent_keys + .iter() + .all(|key| is_plugin_agent_runtime_key(key))); + } + + #[test] + fn displaced_local_baseline_is_case_insensitive() { + use crate::agentic::agents::{Agent, PlanMode}; + + assert_eq!( + native_tool_baseline( + "plan", + ExternalSubagentMode::Primary, + Path::new("C:/workspace") + ), + PlanMode::new().default_tools() + ); + } + + #[test] + fn projects_multiple_config_contributors_and_isolates_agent_tools() { + let mut result = open_result(); + let second = json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + }); + let mut second_config = result["config"].as_object().unwrap().clone(); + second_config["agent"]["build"]["description"] = json!("Second build"); + second_config["agent"]["build"]["permission"]["second_tool"] = json!("allow"); + second_config["agent"]["plan"] = json!({ + "mode": "subagent", + "description": "Plan", + "prompt": "Plan prompt", + "permission": {"second_tool": "allow"} + }); + result["configContributors"] = json!([ + {"plugin": plugin(), "outcome": "applied"}, + {"plugin": second.clone(), "outcome":"applied"} + ]); + result["config"] = Value::Object(second_config.clone()); + result["configContributions"] = json!([ + {"plugin": plugin(), "outcome": "applied", "config": open_result()["config"].clone()}, + {"plugin": second.clone(), "outcome":"applied", "config": second_config} + ]); + result["tools"] + .as_array_mut() + .unwrap() + .push(json!({"id": "second_tool", "plugin": second})); + result["tools"].as_array_mut().unwrap().push( + json!({"id": "second_tool_ungranted", "plugin": json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })}), + ); + + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .expect("multiple config contributors should project"); + assert_eq!(plan.registrations.len(), 3); + assert!(plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap() + .agent + .description() + .contains("Second build")); + let build_tools = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "build") + .unwrap() + .agent + .default_tools(); + let plan_tools = plan + .registrations + .iter() + .find(|registration| registration.logical_id == "plan") + .unwrap() + .agent + .default_tools(); + assert!(build_tools.contains(&"build_project".to_string())); + assert!(build_tools.contains(&"second_tool".to_string())); + assert!(!build_tools.contains(&"second_tool_ungranted".to_string())); + assert!(plan_tools.contains(&"second_tool".to_string())); + assert!(plan_tools.contains(&"second_tool_ungranted".to_string())); + } + + #[test] + fn supports_legacy_single_contributor_without_contribution_snapshots() { + let mut result = open_result(); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + + let plan = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .expect("single contributor legacy projection"); + + assert_eq!(plan.registrations.len(), 2); + } + + #[test] + fn rejects_legacy_multiple_contributors_without_contribution_snapshots() { + let mut result = open_result(); + result["configContributors"] = json!([ + {"plugin": plugin(), "outcome": "applied"}, + {"plugin": { + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + }, "outcome": "applied"} + ]); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .err() + .expect("multiple contributors require contribution snapshots"); + + assert!(error + .to_string() + .contains("unsupported_multiple_config_contributors")); + } + + #[test] + fn rejects_inconsistent_config_contribution_sequences() { + let mut result = open_result(); + result["configContributions"][0]["outcome"] = json!("failed"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .err() + .expect("contributor metadata must align"); + assert!(error + .to_string() + .contains("does not match configContributors")); + + let mut result = open_result(); + result["configContributions"][0]["config"] = json!({"agent": {}}); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .err() + .expect("last contribution must equal final config"); + assert!(error + .to_string() + .contains("does not end at the final config")); + } + + #[test] + fn rejects_malformed_agent_and_skill_shapes() { + let mut result = open_result(); + result["config"]["agent"] = json!([]); + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .err() + .expect("agent must be an object"); + assert!(error + .to_string() + .contains("config 'agent' must be an object")); + + for malformed in [json!({"paths": "not-an-array"}), json!({"paths": [42]})] { + let mut result = open_result(); + result["config"]["skills"] = malformed; + result + .as_object_mut() + .unwrap() + .remove("configContributions"); + let error = prepare( + Path::new("C:/workspace"), + "generation-1", + &Map::new(), + &result, + ) + .err() + .expect("malformed skill paths must fail"); + assert!(error.to_string().contains("skills.paths")); + } + } + + #[test] + fn canonical_skill_identity_does_not_republish_an_initial_root() { + let directory = tempfile::tempdir().expect("temp directory"); + let canonical = dunce::canonicalize(directory.path()).expect("canonical path"); + let aliased = canonical.join("."); + let initial = json!({"skills": {"paths": [aliased]}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"skills": {"paths": [canonical]}}) + .as_object() + .unwrap() + .clone(); + + let contributor = ConfigContribution { + plugin: serde_json::from_value(plugin()).unwrap(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }; + let attribution = + attribute_config(&initial, &[contributor], &final_config).expect("skill attribution"); + assert!(attribution.skill_owners.is_empty()); + assert!( + attributed_skill_roots(&final_config, &attribution.skill_owners) + .expect("skill roots") + .is_empty() + ); + } + + #[test] + fn attributes_skill_additions_across_reordering_and_removal() { + let base = tempfile::tempdir().expect("base skill root"); + let first = tempfile::tempdir().expect("first plugin skill root"); + let second = tempfile::tempdir().expect("second plugin skill root"); + let plugin_a: PluginIdentity = serde_json::from_value(plugin()).unwrap(); + let plugin_b: PluginIdentity = serde_json::from_value(json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })) + .unwrap(); + let initial = json!({"skills": {"paths": [base.path()]}}) + .as_object() + .unwrap() + .clone(); + let after_a = json!({"skills": {"paths": [base.path(), first.path()]}}) + .as_object() + .unwrap() + .clone(); + let final_config = json!({"skills": {"paths": [first.path(), base.path(), second.path()]}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: plugin_a.clone(), + outcome: ContributorOutcome::Applied, + config: after_a, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }, + ]; + + let attribution = + attribute_config(&initial, &contributions, &final_config).expect("skill attribution"); + assert_eq!( + attribution + .skill_owners + .get(&normalized_skill_path_identity(first.path())), + Some(&plugin_a) + ); + assert_eq!( + attribution + .skill_owners + .get(&normalized_skill_path_identity(second.path())), + Some(&plugin_b) + ); + assert!(!attribution + .skill_owners + .contains_key(&normalized_skill_path_identity(base.path()))); + + let removed_config = json!({"skills": {"paths": [base.path(), second.path()]}}) + .as_object() + .unwrap() + .clone(); + let mut removal_sequence = contributions; + removal_sequence.push(ConfigContribution { + plugin: plugin_b, + outcome: ContributorOutcome::Applied, + config: removed_config.clone(), + }); + let removed = attribute_config(&initial, &removal_sequence, &removed_config) + .expect("skill removal attribution"); + assert!(!removed + .skill_owners + .contains_key(&normalized_skill_path_identity(first.path()))); + } + + #[test] + fn reattributes_deleted_and_recreated_agents_and_permission_fields() { + let plugin_a: PluginIdentity = serde_json::from_value(plugin()).unwrap(); + let plugin_b: PluginIdentity = serde_json::from_value(json!({ + "id": "second", + "spec": "D:/code/second", + "entry": "D:/code/second/index.js", + "index": 0 + })) + .unwrap(); + let initial = json!({"agent": {"build": {"prompt": "native"}}}) + .as_object() + .unwrap() + .clone(); + let after_a = json!({"agent": {"build": { + "prompt": "plugin-a", + "permission": {"build_project": "allow"} + }}}) + .as_object() + .unwrap() + .clone(); + let after_delete = json!({"agent": {}}).as_object().unwrap().clone(); + let final_config = json!({"agent": {"build": { + "prompt": "plugin-b", + "permission": {"second_tool": "ask"} + }}}) + .as_object() + .unwrap() + .clone(); + let contributions = vec![ + ConfigContribution { + plugin: plugin_a, + outcome: ContributorOutcome::Applied, + config: after_a, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: after_delete, + }, + ConfigContribution { + plugin: plugin_b.clone(), + outcome: ContributorOutcome::Applied, + config: final_config.clone(), + }, + ]; + + let attribution = + attribute_config(&initial, &contributions, &final_config).expect("agent attribution"); + assert_eq!(attribution.agent_owners.get("build"), Some(&plugin_b)); + assert_eq!( + attribution + .permission_owners + .get(&("build".to_string(), "second_tool".to_string())), + Some(&plugin_b) + ); + assert!(!attribution + .permission_owners + .contains_key(&("build".to_string(), "build_project".to_string()))); + } + + #[test] + fn unknown_allow_is_non_expanding_but_unknown_restrictions_fail_closed() { + let plugin_tools = BTreeSet::new(); + let permissions = json!({"future_action": "allow"}); + let (constraints, denied) = + parse_permissions(Some(&permissions), &plugin_tools, "build").expect("allow"); + assert!(constraints.rules().is_empty()); + assert!(denied.is_empty()); + + for effect in ["ask", "deny"] { + let permissions = json!({"future_action": effect}); + let error = parse_permissions(Some(&permissions), &plugin_tools, "build") + .expect_err("unknown restriction cannot be enforced"); + assert!(error.to_string().contains("has no compatible action")); + } + } + + #[test] + fn parses_hidden_and_temperature_with_safe_defaults_and_bounds() { + assert!(!parse_hidden(None, "agent").expect("hidden defaults to false")); + assert!(parse_hidden(Some(&json!(true)), "agent").expect("boolean hidden")); + assert!(!parse_hidden(Some(&json!(null)), "agent").expect("null hidden default")); + assert!(parse_hidden(Some(&json!("true")), "agent") + .expect_err("non-boolean hidden must fail") + .to_string() + .contains("hidden must be a boolean")); + + assert_eq!(parse_temperature(None, "agent").unwrap(), None); + assert_eq!( + parse_temperature(Some(&json!(null)), "agent").unwrap(), + None + ); + assert_eq!( + parse_temperature(Some(&json!(0.2)), "agent").unwrap(), + Some(0.2) + ); + assert_eq!( + parse_temperature(Some(&json!(2)), "agent").unwrap(), + Some(2.0) + ); + for value in [json!(-0.1), json!(2.1), json!("0.2")] { + assert!(parse_temperature(Some(&value), "agent").is_err()); + } + } +} diff --git a/src/crates/assembly/core/src/plugin_hook_bridge.rs b/src/crates/assembly/core/src/plugin_hook_bridge.rs new file mode 100644 index 0000000000..44b8272495 --- /dev/null +++ b/src/crates/assembly/core/src/plugin_hook_bridge.rs @@ -0,0 +1,251 @@ +//! Bridge the provider-neutral native hook executor to the OpenCode RPC host. + +use bitfun_agent_runtime::native_hooks::{ + AgentHookMatcher, PluginHookCall, PluginHookExecutor, PluginHookResult, RuntimeHookCommitToken, + RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistration, RuntimeHookRegistry, + RuntimeHookSource, +}; +use bitfun_opencode_plugin_host::{PluginGenerationLease, PluginHostClient}; +use serde_json::Value; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Clone)] +pub(crate) struct PluginHostHookExecutor { + client: PluginHostClient, + deadline: Duration, +} + +impl PluginHostHookExecutor { + pub(crate) fn new(client: PluginHostClient) -> Self { + Self { + client, + deadline: Duration::from_secs(30), + } + } +} + +#[async_trait::async_trait] +impl PluginHookExecutor for PluginHostHookExecutor { + async fn execute(&self, call: PluginHookCall) -> Result { + let result = self + .client + .call_hook( + &PluginGenerationLease { + instance_id: call.instance_id.clone(), + generation_key: call.generation_key.clone(), + revision: call.revision.clone(), + }, + &call.hook_name, + call.input, + call.output, + self.deadline, + ) + .await + .map_err(|error| error.to_string())?; + let input = result + .get("input") + .cloned() + .ok_or_else(|| "host.hook.call response is missing input".to_string())?; + let output = result + .get("output") + .cloned() + .ok_or_else(|| "host.hook.call response is missing output".to_string())?; + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input, + output, + }) + } +} + +pub(crate) fn register_plugin_hooks( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + client: PluginHostClient, + instance_id: &str, + generation_key: &str, + revision: &str, + hook_names: &[String], +) -> Result, String> { + log::debug!( + "Plugin hook registration preparing: workspace={}, instance_id={}, hook_count={}", + workspace_scope, + instance_id, + hook_names.len() + ); + let executor: Arc = Arc::new(PluginHostHookExecutor::new(client)); + let entries = hook_names + .iter() + .map(|hook_name| { + let id = format!( + "opencode:{workspace_scope}:{instance_id}:{generation_key}:{revision}:{hook_name}" + ); + RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + id, + RuntimeHookKind::PluginHook(hook_name.clone()), + RuntimeHookSource::OpenCodePlugin, + ), + hook_name, + instance_id, + generation_key, + revision, + executor.clone(), + AgentHookMatcher::Any, + ) + .with_workspace_scope(workspace_scope) + }) + .collect::>(); + if entries.is_empty() { + log::debug!( + "Plugin hook registration prepared with no dispatch hooks: workspace={}, instance_id={}", + workspace_scope, + instance_id + ); + return Ok(None); + } + let token = match registry.register_plugin_batch(entries) { + Ok(token) => token, + Err(error) => { + log::error!( + "Plugin hook registration failed: workspace={}, instance_id={}, hook_count={}, error={}", + workspace_scope, + instance_id, + hook_names.len(), + error + ); + return Err(error.to_string()); + } + }; + log::info!( + "Plugin hook registration prepared in Rust registry: workspace={}, instance_id={}, target_id={}, generation_key={}, revision={}, hook_count={}", + workspace_scope, + instance_id, + token.target_id(), + token.generation_key(), + token.revision(), + hook_names.len() + ); + Ok(Some(token)) +} + +pub(crate) fn commit_plugin_generation( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + token: Option<&RuntimeHookCommitToken>, +) { + registry.activate_plugin_batch(workspace_scope, token); +} + +pub(crate) fn unregister_plugin_hooks( + registry: &RuntimeHookRegistry, + workspace_scope: &str, + token: RuntimeHookCommitToken, +) { + registry.rollback_plugin_batch(&token); + let _ = workspace_scope; +} + +pub(crate) fn withdraw_plugin_workspace(registry: &RuntimeHookRegistry, workspace_scope: &str) { + registry.withdraw_plugin_workspace(workspace_scope); +} + +pub(crate) fn hook_names(open_result: &Value) -> Vec { + open_result + .get("hooks") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .filter(|name| matches!(*name, "tool.execute.before" | "tool.execute.after")) + .map(str::to_string) + .collect() +} + +#[cfg(test)] +mod tests { + use super::{commit_plugin_generation, register_plugin_hooks}; + use bitfun_agent_runtime::native_hooks::{ + RuntimeHookActivation, RuntimeHookRegistry, RuntimeHookSource, + }; + use bitfun_opencode_plugin_host::JsonRpcPeer; + use tokio::net::{TcpListener, TcpStream}; + + async fn client() -> bitfun_opencode_plugin_host::PluginHostClient { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let host = tokio::spawn(async move { TcpStream::connect(address).await.unwrap() }); + let (backend, _) = listener.accept().await.unwrap(); + let _host = host.await.unwrap(); + JsonRpcPeer::start_with_capabilities( + backend, + 1, + 1024 * 1024, + bitfun_opencode_plugin_host::PluginHostCapabilities::all_supported(), + ) + .client() + } + + #[tokio::test] + async fn empty_hook_set_is_ready_without_a_commit_token() { + let registry = RuntimeHookRegistry::default(); + let token = register_plugin_hooks( + ®istry, + "C:/workspace", + client().await, + "instance-a", + "generation-a", + "revision-a", + &[], + ) + .unwrap(); + + assert!(token.is_none()); + commit_plugin_generation(®istry, "C:/workspace", token.as_ref()); + assert_eq!( + registry.source_activation_for_workspace( + RuntimeHookSource::OpenCodePlugin, + Some("C:/workspace") + ), + RuntimeHookActivation::Ready + ); + } + + #[tokio::test] + async fn duplicate_hook_registration_preserves_active_generation() { + let registry = RuntimeHookRegistry::default(); + let hooks = vec!["tool.execute.before".to_string()]; + let first = register_plugin_hooks( + ®istry, + "C:/workspace", + client().await, + "instance-a", + "generation-a", + "revision-a", + &hooks, + ) + .unwrap(); + commit_plugin_generation(®istry, "C:/workspace", first.as_ref()); + assert!(register_plugin_hooks( + ®istry, + "C:/workspace", + client().await, + "instance-a", + "generation-a", + "revision-a", + &hooks, + ) + .is_err()); + assert_eq!( + registry.source_activation_for_workspace( + RuntimeHookSource::OpenCodePlugin, + Some("C:/workspace") + ), + RuntimeHookActivation::Ready + ); + } +} diff --git a/src/crates/assembly/core/src/plugin_host.rs b/src/crates/assembly/core/src/plugin_host.rs index 58fd638f16..0db1827cea 100644 --- a/src/crates/assembly/core/src/plugin_host.rs +++ b/src/crates/assembly/core/src/plugin_host.rs @@ -1,8 +1,11 @@ +use bitfun_agent_runtime::native_hooks::RuntimeHookCommitToken; use bitfun_opencode_plugin_host::{ PluginDeclaration, PluginHost, PluginHostConfig, PluginHostShutdownPolicy, - PluginHostShutdownReport, PluginInstanceOpenRequest, PluginPrepareRequest, + PluginHostShutdownReport, PluginInstanceOpenRequest, PluginPrepareRequest, RpcHandlerError, + CONFIG_CONTRIBUTIONS_V2, CONFIG_CONTRIBUTORS_V1, GENERATION_FENCING_V1, }; -use serde_json::{Map, Value}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -29,6 +32,7 @@ static PLUGIN_HOST_INSTANCES: OnceCell OnceCell::const_new(); static PLUGIN_HOST_PTY_OWNERS: OnceCell>> = OnceCell::const_new(); static NEXT_INSTANCE_SEQUENCE: AtomicU64 = AtomicU64::new(1); +const MAX_PLUGIN_HOST_DIAGNOSTICS: usize = 100; #[derive(Debug, Clone)] pub(crate) struct PluginHostInstance { @@ -38,8 +42,38 @@ pub(crate) struct PluginHostInstance { pub(crate) project_id: String, pub(crate) created_at_ms: i64, pub(crate) instance_id: String, + pub(crate) generation_key: String, + pub(crate) revision: String, pub(crate) open_result: Value, pub(crate) ready: bool, + pub(crate) hook_commit_token: Option, + pub(crate) transformed_config_health_snapshot: Option, + pub(crate) diagnostic_health_snapshot: Vec, + pub(crate) tool_names: Vec, + pub(crate) agent_runtime_keys: Vec, + pub(crate) retirement_scheduled: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginHostDiagnostic { + severity: String, + code: String, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + plugin: Option, + #[serde(skip_serializing_if = "Option::is_none")] + method: Option, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PluginHostDiagnosticPublishParams { + #[serde(rename = "instanceID")] + instance_id: Option, + diagnostic: PluginHostDiagnostic, } impl PluginHostInstance { @@ -82,6 +116,14 @@ pub enum PluginHostLaunchPolicy { Disabled, } +pub async fn configured_plugins_present() -> crate::BitFunResult { + use crate::service::config::{get_global_config_service, GlobalConfig}; + + let config_service = get_global_config_service().await?; + let config: GlobalConfig = config_service.get_config(None).await?; + Ok(config.has_configured_plugins()) +} + pub async fn initialize_configured_plugin_host( launch_policy: PluginHostLaunchPolicy, ) -> crate::BitFunResult { @@ -140,21 +182,11 @@ pub async fn initialize_configured_plugin_host_with_log_file( log_level: config.app.logging.level.trim().to_lowercase(), }) .await - .map_err(|error| match error { - bitfun_opencode_plugin_host::PluginHostError::RuntimeNotFound(command) => { - crate::BitFunError::ProcessError(format!( - "{} executable was not found at {}. Install Bun or set {} to a valid Bun executable.", - launch_spec.runtime_name, - command.display(), - BUN_COMMAND_ENV - )) - } - error => crate::BitFunError::ProcessError(format!( + .map_err(|error| crate::BitFunError::ProcessError(format!( "Failed to initialize {} plugin host from {}: {error}", launch_spec.runtime_name, entry.display() - )), - })?; + )))?; let client = host.client(); crate::plugin_host_http::register_plugin_host_backend_handlers(client.clone()).await?; let plugins = config @@ -231,16 +263,17 @@ pub async fn ensure_configured_plugin_instance( directory: PathBuf, worktree: PathBuf, project_id: Option, - config: Map, ) -> crate::BitFunResult> { use crate::service::config::{get_global_config_service, GlobalConfig}; if launch_policy == PluginHostLaunchPolicy::Disabled { + withdraw_configured_plugin_workspace(&directory).await; return Ok(None); } let config_service = get_global_config_service().await?; let global_config: GlobalConfig = config_service.get_config(None).await?; if !global_config.has_configured_plugins() { + withdraw_configured_plugin_workspace(&directory).await; return Ok(None); } if directory.as_os_str().is_empty() || !directory.is_dir() { @@ -258,6 +291,23 @@ pub async fn ensure_configured_plugin_instance( })?; let canonical_directory_string = canonical_directory.to_string_lossy().into_owned(); let comparable_directory = comparable_instance_directory(&canonical_directory_string); + let config = serde_json::to_value( + crate::plugin_runtime::opencode_config_snapshot(&canonical_directory).map_err(|error| { + crate::BitFunError::Validation(format!( + "Failed to load OpenCode config for plugin activation: {error}" + )) + })?, + ) + .and_then(|value| match value { + Value::Object(config) => Ok(config), + _ => unreachable!("OpenCodeConfigSnapshot must serialize as an object"), + }) + .map_err(|error| { + crate::BitFunError::Validation(format!( + "Failed to serialize OpenCode plugin config snapshot: {error}" + )) + })?; + let initial_config = config.clone(); let config_fingerprint = plugin_config_fingerprint(&global_config)?; let client = { let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; @@ -272,44 +322,62 @@ pub async fn ensure_configured_plugin_instance( ) })? }; + if !client.capabilities().supports(GENERATION_FENCING_V1) { + return Err(crate::BitFunError::ProcessError( + "Configured plugin host does not support generation-fencing-v1".to_string(), + )); + } let instances = PLUGIN_HOST_INSTANCES .get_or_init(|| async { Mutex::new(HashMap::new()) }) .await; let instance_key = format!("{comparable_directory}\n{config_fingerprint}"); - if let Some(instance) = instances.lock().await.get(&instance_key).cloned() { + let reusable_instance = { + let mut state = instances.lock().await; + state.get_mut(&instance_key).map(|instance| { + instance.retirement_scheduled = false; + instance.clone() + }) + }; + if let Some(instance) = reusable_instance { + if crate::plugin_config_projection::active_generation_key(&canonical_directory).as_deref() + != Some(instance.generation_key.as_str()) + { + let projection = crate::plugin_config_projection::prepare( + &canonical_directory, + &instance.generation_key, + &initial_config, + &instance.open_result, + )?; + crate::plugin_hook_bridge::commit_plugin_generation( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + instance.hook_commit_token.as_ref(), + ); + projection.commit(); + } log::debug!( "Configured plugin host instance reused: generation={}, instance_id={}", client.generation(), instance.instance_id ); + retire_superseded_plugin_instances( + &client, + instances, + &instance_key, + &comparable_directory, + ) + .await; return Ok(Some(instance.open_result.clone())); } - let previous_keys = instances - .lock() - .await - .iter() - .filter(|(_, instance)| instance.canonical_directory == comparable_directory) - .map(|(key, instance)| (key.clone(), instance.instance_id.clone())) - .collect::>(); - for (key, instance_id) in previous_keys { - if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { - bridge.cancel_instance_streams(&instance_id).await; - } - client - .close_instance(&instance_id, std::time::Duration::from_secs(10)) - .await - .map_err(|error| { - crate::BitFunError::ProcessError(format!( - "Failed to close stale plugin host instance {instance_id}: {error}" - )) - })?; - close_plugin_host_ptys(&instance_id).await; - instances.lock().await.remove(&key); - } - let sequence = NEXT_INSTANCE_SEQUENCE.fetch_add(1, Ordering::Relaxed); let instance_id = format!("bitfun:host:{}:{sequence}", client.generation()); + let revision = format!("revision-{sequence}"); + let generation_key = format!( + "host-{}:instance-{sequence}:sha256-{}", + client.generation(), + config_fingerprint + ); let project_id = project_id .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| { @@ -326,8 +394,16 @@ pub async fn ensure_configured_plugin_instance( project_id: project_id.clone(), created_at_ms: now_ms, instance_id: instance_id.clone(), + generation_key: generation_key.clone(), + revision: revision.clone(), open_result: Value::Null, ready: false, + hook_commit_token: None, + transformed_config_health_snapshot: None, + diagnostic_health_snapshot: Vec::new(), + tool_names: Vec::new(), + agent_runtime_keys: Vec::new(), + retirement_scheduled: false, }; instances .lock() @@ -337,6 +413,8 @@ pub async fn ensure_configured_plugin_instance( .open_instance( PluginInstanceOpenRequest { instance_id: instance_id.clone(), + generation_key: generation_key.clone(), + revision: revision.clone(), project: serde_json::json!({ "id": project_id, "worktree": canonical_directory_string, @@ -358,27 +436,336 @@ pub async fn ensure_configured_plugin_instance( { Ok(result) => result, Err(error) => { - close_plugin_host_ptys(&instance_id).await; - instances.lock().await.remove(&instance_key); + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; return Err(crate::BitFunError::ProcessError(format!( "Failed to activate plugins for workspace {}: {error}", canonical_directory.display() ))); } }; + if let Err(error) = + validate_open_generation_lease(&open_result, &instance_id, &generation_key, &revision) + { + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(error); + } + if client.capabilities().supports(CONFIG_CONTRIBUTORS_V1) + && !open_result + .get("configContributors") + .is_some_and(Value::is_array) + { + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(crate::BitFunError::Validation( + "Plugin host open result is missing configContributors".to_string(), + )); + } + if client.capabilities().supports(CONFIG_CONTRIBUTIONS_V2) + && !open_result + .get("configContributions") + .is_some_and(Value::is_array) + { + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(crate::BitFunError::Validation( + "Plugin host open result is missing configContributions".to_string(), + )); + } + let config_projection = match crate::plugin_config_projection::prepare( + &canonical_directory, + &generation_key, + &initial_config, + &open_result, + ) { + Ok(projection) => projection, + Err(error) => { + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(error); + } + }; + let plugin_agent_runtime_keys = config_projection.agent_runtime_keys(); log::info!( - "Configured plugin host instance activated: generation={}, instance_id={}, plugin_count={}", + "Configured plugin host instance prepared: generation={}, instance_id={}, plugin_count={}", client.generation(), instance_id, global_config.plugin.len() ); - if let Some(instance) = instances.lock().await.get_mut(&instance_key) { + let hook_commit_token = match crate::plugin_hook_bridge::register_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + client.clone(), + &instance_id, + &generation_key, + &revision, + &crate::plugin_hook_bridge::hook_names(&open_result), + ) { + Ok(token) => token, + Err(error) => { + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(crate::BitFunError::ProcessError(format!( + "Failed to register plugin hooks for workspace {}: {error}", + canonical_directory.display() + ))); + } + }; + let tool_names = match register_plugin_tools( + &client, + &instance_id, + &comparable_directory, + &canonical_directory, + &generation_key, + &revision, + &config_fingerprint, + &open_result, + &config_projection, + ) + .await + { + Ok(names) => names, + Err(error) => { + if let Some(token) = hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + token, + ); + } + discard_opening_plugin_instance(&client, instances, &instance_key, &instance_id).await; + return Err(error); + } + }; + // Publish readiness, Hooks, and Config routes while holding the instance + // table lock. Hook dispatch cannot observe ready=true before its Registry + // generation is active, and Agent routing is published last, after the + // instance identity is available to generation-fenced dispatch. + { + let mut state = instances.lock().await; + let instance = state.get_mut(&instance_key).ok_or_else(|| { + crate::BitFunError::ProcessError( + "Plugin instance disappeared before generation publication".to_string(), + ) + })?; instance.open_result = open_result.clone(); instance.ready = true; + instance.hook_commit_token = hook_commit_token.clone(); + instance.transformed_config_health_snapshot = open_result.get("config").cloned(); + instance.tool_names = tool_names; + instance.agent_runtime_keys = plugin_agent_runtime_keys.into_iter().collect(); + crate::plugin_hook_bridge::commit_plugin_generation( + &crate::native_hooks::plugin_hook_registry(&comparable_directory), + &comparable_directory, + hook_commit_token.as_ref(), + ); + config_projection.commit(); } + retire_superseded_plugin_instances(&client, instances, &instance_key, &comparable_directory) + .await; Ok(Some(open_result)) } +async fn discard_opening_plugin_instance( + client: &bitfun_opencode_plugin_host::PluginHostClient, + instances: &Mutex>, + instance_key: &str, + instance_id: &str, +) { + if let Err(error) = client + .close_instance(instance_id, std::time::Duration::from_secs(10)) + .await + { + log::debug!( + "Plugin instance cleanup after failed prepare was incomplete: instance_id={}, error={}", + instance_id, + error + ); + } + close_plugin_host_ptys(instance_id).await; + instances.lock().await.remove(instance_key); +} + +async fn withdraw_configured_plugin_workspace(directory: &Path) { + let Ok(canonical) = dunce::canonicalize(directory) else { + return; + }; + let workspace_scope = comparable_instance_directory(&canonical.to_string_lossy()); + let registry = crate::native_hooks::plugin_hook_registry(&workspace_scope); + crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace_scope); + crate::plugin_config_projection::release_workspace(&canonical); + let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { + crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); + return; + }; + let owned = instances + .lock() + .await + .iter() + .filter(|(_, instance)| instance.canonical_directory == workspace_scope) + .map(|(key, instance)| (key.clone(), instance.clone())) + .collect::>(); + let client = PLUGIN_HOST + .get() + .and_then(|state| state.try_lock().ok()) + .and_then(|host| host.as_ref().map(PluginHost::client)); + for (key, instance) in owned { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks(®istry, &workspace_scope, token); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &workspace_scope, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance.instance_id).await; + } + if let Some(client) = client.as_ref() { + let _ = client + .close_instance(&instance.instance_id, std::time::Duration::from_secs(10)) + .await; + } + close_plugin_host_ptys(&instance.instance_id).await; + instances.lock().await.remove(&key); + } + crate::native_hooks::clear_plugin_hook_workspace(&workspace_scope); +} + +async fn retire_superseded_plugin_instances( + client: &bitfun_opencode_plugin_host::PluginHostClient, + instances: &Mutex>, + active_key: &str, + workspace_scope: &str, +) { + let stale = instances + .lock() + .await + .iter() + .filter(|(key, instance)| { + key.as_str() != active_key && instance.canonical_directory == workspace_scope + }) + .map(|(key, instance)| (key.clone(), instance.clone())) + .collect::>(); + for (key, instance) in stale { + if instance.agent_runtime_keys.iter().any(|runtime_key| { + crate::agentic::agents::get_agent_registry().check_agent_exists(runtime_key) + }) { + let should_schedule = { + let mut state = instances.lock().await; + state.get_mut(&key).is_some_and(|current| { + if current.retirement_scheduled { + false + } else { + current.retirement_scheduled = true; + true + } + }) + }; + if should_schedule { + schedule_plugin_instance_retirement(client.clone(), key.clone()); + } + continue; + } + let removed = { + let mut state = instances.lock().await; + state + .get(&key) + .filter(|current| current.instance_id == instance.instance_id) + .is_some() + .then(|| state.remove(&key)) + .flatten() + }; + if let Some(removed) = removed { + retire_plugin_instance(client, removed, workspace_scope).await; + } + } +} + +fn schedule_plugin_instance_retirement( + client: bitfun_opencode_plugin_host::PluginHostClient, + instance_key: String, +) { + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + let Some(instances) = PLUGIN_HOST_INSTANCES.get() else { + return; + }; + let snapshot = { + let state = instances.lock().await; + let Some(instance) = state.get(&instance_key) else { + return; + }; + if !instance.retirement_scheduled { + return; + } + instance.clone() + }; + if crate::plugin_config_projection::active_generation_key(&snapshot.directory) + .as_deref() + == Some(snapshot.generation_key.as_str()) + { + if let Some(instance) = instances.lock().await.get_mut(&instance_key) { + instance.retirement_scheduled = false; + } + return; + } + if snapshot.agent_runtime_keys.iter().any(|runtime_key| { + crate::agentic::agents::get_agent_registry().check_agent_exists(runtime_key) + }) { + continue; + } + let removed = { + let mut state = instances.lock().await; + let matches = state.get(&instance_key).is_some_and(|current| { + current.retirement_scheduled + && current.instance_id == snapshot.instance_id + && current.generation_key == snapshot.generation_key + }); + matches.then(|| state.remove(&instance_key)).flatten() + }; + if let Some(instance) = removed { + let workspace_scope = instance.canonical_directory.clone(); + retire_plugin_instance(&client, instance, &workspace_scope).await; + } + return; + } + }); +} + +async fn retire_plugin_instance( + client: &bitfun_opencode_plugin_host::PluginHostClient, + instance: PluginHostInstance, + workspace_scope: &str, +) { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(workspace_scope), + workspace_scope, + token, + ); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + workspace_scope, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + if let Some(bridge) = crate::plugin_host_http::plugin_host_backend_bridge() { + bridge.cancel_instance_streams(&instance.instance_id).await; + } + if let Err(error) = client + .close_instance(&instance.instance_id, std::time::Duration::from_secs(10)) + .await + { + log::warn!( + "Superseded plugin instance close failed: instance_id={}, error={}", + instance.instance_id, + error + ); + } + close_plugin_host_ptys(&instance.instance_id).await; +} + pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option { let instances = PLUGIN_HOST_INSTANCES.get()?; instances @@ -389,6 +776,92 @@ pub(crate) async fn plugin_host_instance_by_id(instance_id: &str) -> Option Option { + let instances = PLUGIN_HOST_INSTANCES.get()?; + instances + .lock() + .await + .values() + .find(|instance| { + instance.ready + && instance.canonical_directory == workspace_scope + && instance + .agent_runtime_keys + .iter() + .any(|key| key == runtime_agent_key) + }) + .map( + |instance| bitfun_agent_runtime::native_hooks::PluginHookGenerationIdentity { + instance_id: instance.instance_id.clone(), + generation_key: instance.generation_key.clone(), + revision: instance.revision.clone(), + }, + ) +} + +pub(crate) async fn publish_plugin_host_diagnostic( + params: Value, +) -> Result { + let params: PluginHostDiagnosticPublishParams = + serde_json::from_value(params).map_err(|error| { + RpcHandlerError::new( + -32602, + format!("invalid backend.diagnostic.publish params: {error}"), + ) + })?; + if !matches!( + params.diagnostic.severity.as_str(), + "debug" | "info" | "warning" | "error" + ) { + return Err(RpcHandlerError::new( + -32602, + "backend.diagnostic.publish severity is invalid", + )); + } + let diagnostic = serde_json::to_value(params.diagnostic) + .map_err(|error| RpcHandlerError::new(-32603, error.to_string()))?; + if let Some(instance_id) = params.instance_id.as_deref() { + let instances = PLUGIN_HOST_INSTANCES + .get() + .ok_or_else(|| RpcHandlerError::new(-32004, "plugin instance is unavailable"))?; + let mut instances = instances.lock().await; + let instance = instances + .values_mut() + .find(|instance| instance.instance_id == instance_id) + .ok_or_else(|| RpcHandlerError::new(-32004, "plugin instance is unavailable"))?; + push_plugin_host_diagnostic(&mut instance.diagnostic_health_snapshot, diagnostic.clone()); + } + crate::infrastructure::events::emit_global_event( + crate::infrastructure::events::BackendEvent::Custom { + event_name: "plugin-host-diagnostic".to_string(), + payload: serde_json::json!({ + "instance_id": params.instance_id, + "diagnostic": diagnostic, + "timestamp": chrono::Utc::now().timestamp_millis(), + }), + }, + ) + .await + .map_err(|error| { + RpcHandlerError::new( + -32603, + format!("failed to publish plugin host diagnostic: {error}"), + ) + })?; + Ok(serde_json::json!({})) +} + +fn push_plugin_host_diagnostic(snapshot: &mut Vec, diagnostic: Value) { + snapshot.push(diagnostic); + let overflow = snapshot.len().saturating_sub(MAX_PLUGIN_HOST_DIAGNOSTICS); + if overflow > 0 { + snapshot.drain(..overflow); + } +} + pub(crate) async fn register_plugin_host_pty(pty_id: &str, instance_id: &str) { let owners = PLUGIN_HOST_PTY_OWNERS .get_or_init(|| async { Mutex::new(HashMap::new()) }) @@ -543,7 +1016,34 @@ pub async fn shutdown_configured_plugin_host( let host_state = PLUGIN_HOST.get_or_init(|| async { Mutex::new(None) }).await; let host = host_state.lock().await.take(); if let Some(instances) = PLUGIN_HOST_INSTANCES.get() { - instances.lock().await.clear(); + let mut instances = instances.lock().await; + for instance in instances.values() { + if let Some(token) = instance.hook_commit_token.clone() { + crate::plugin_hook_bridge::unregister_plugin_hooks( + &crate::native_hooks::plugin_hook_registry(&instance.canonical_directory), + &instance.canonical_directory, + token, + ); + } + crate::agentic::tools::plugin_host_tool::unregister_workspace_tools( + &instance.canonical_directory, + &instance.directory, + &instance.tool_names, + &instance.generation_key, + ) + .await; + crate::plugin_config_projection::release_workspace(&instance.directory); + } + let workspaces = instances + .values() + .map(|instance| instance.canonical_directory.clone()) + .collect::>(); + for workspace in workspaces { + let registry = crate::native_hooks::plugin_hook_registry(&workspace); + crate::plugin_hook_bridge::withdraw_plugin_workspace(®istry, &workspace); + crate::native_hooks::clear_plugin_hook_workspace(&workspace); + } + instances.clear(); } let report = match host { Some(host) => { @@ -565,6 +1065,126 @@ pub async fn shutdown_configured_plugin_host( Ok(report) } +async fn register_plugin_tools( + client: &bitfun_opencode_plugin_host::PluginHostClient, + instance_id: &str, + workspace_scope: &str, + workspace_root: &Path, + generation_key: &str, + revision: &str, + config_fingerprint: &str, + open_result: &Value, + projection: &crate::plugin_config_projection::PluginConfigProjectionPlan, +) -> crate::BitFunResult> { + let Some(tools) = open_result.get("tools").and_then(Value::as_array) else { + log::debug!( + "Plugin tool registration completed with no tools: workspace={}, instance_id={}", + workspace_scope, + instance_id + ); + return Ok(Vec::new()); + }; + log::debug!( + "Plugin tool registration preparing: workspace={}, instance_id={}, tool_count={}", + workspace_scope, + instance_id, + tools.len() + ); + let mut prepared = Vec::new(); + let mut seen_ids = std::collections::BTreeSet::new(); + for tool in tools { + let allowed_runtime_agent_keys = projection.allowed_runtime_agent_keys_for_tool(tool)?; + if allowed_runtime_agent_keys.is_empty() { + continue; + } + let registration_id = tool + .get("registrationID") + .and_then(Value::as_str) + .ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool registrationID is missing".to_string()) + })?; + let id = tool.get("id").and_then(Value::as_str).ok_or_else(|| { + crate::BitFunError::Validation("Plugin tool id is missing".to_string()) + })?; + if !seen_ids.insert(id.to_string()) { + return Err(crate::BitFunError::Validation(format!( + "Plugin tool id is duplicated in the open result: {id}" + ))); + } + let description = tool + .get("description") + .and_then(Value::as_str) + .unwrap_or_default(); + let parameters = tool + .get("parameters") + .cloned() + .unwrap_or_else(|| serde_json::json!({"type":"object"})); + prepared.push(( + registration_id.to_string(), + id.to_string(), + description.to_string(), + parameters, + allowed_runtime_agent_keys, + )); + } + + // Validate the complete generation before mutating the Tool mux. Once + // registration starts, all remaining operations are infallible local + // publication steps, so a malformed later entry cannot leave a partial + // generation installed. + let mut names = Vec::with_capacity(prepared.len()); + for (registration_id, id, description, parameters, allowed_runtime_agent_keys) in prepared { + crate::agentic::tools::plugin_host_tool::register_workspace_tool( + workspace_scope, + workspace_root, + client.clone(), + instance_id, + generation_key, + revision, + ®istration_id, + &id, + &description, + parameters, + config_fingerprint, + allowed_runtime_agent_keys, + ) + .await; + log::debug!( + "Plugin tool registration committed to Rust registry: workspace={}, instance_id={}, tool_id={}, registration_id={}", + workspace_scope, + instance_id, + id, + registration_id + ); + names.push(id); + } + log::info!( + "Plugin tool registration completed: workspace={}, instance_id={}, tool_count={}", + workspace_scope, + instance_id, + names.len() + ); + Ok(names) +} + +fn validate_open_generation_lease( + result: &Value, + instance_id: &str, + generation_key: &str, + revision: &str, +) -> crate::BitFunResult<()> { + let valid = result.get("instanceID").and_then(Value::as_str) == Some(instance_id) + && result.get("generationKey").and_then(Value::as_str) == Some(generation_key) + && result.get("revision").and_then(Value::as_str) == Some(revision); + if valid { + Ok(()) + } else { + Err(crate::BitFunError::Validation( + "Plugin host open result generation lease does not match the request".to_string(), + )) + } +} + fn resolve_host_entry(spec: PluginHostLaunchSpec) -> crate::BitFunResult { if let Some(entry) = std::env::var_os(spec.entry_env) { return absolutize_existing_entry(PathBuf::from(entry), spec); @@ -652,6 +1272,12 @@ fn comparable_instance_directory(directory: &str) -> String { comparable } +pub(crate) fn canonical_plugin_workspace_scope(path: &Path) -> Option { + dunce::canonicalize(path) + .ok() + .map(|path| comparable_instance_directory(&path.to_string_lossy())) +} + fn absolutize_existing_entry( entry: PathBuf, spec: PluginHostLaunchSpec, @@ -678,9 +1304,9 @@ fn absolutize_existing_entry( mod tests { use super::{ development_host_entry, initialize_configured_plugin_host, instance_directories_equal, - plugin_host_pty_ids_for_instance, plugin_host_pty_owned_by, register_plugin_host_pty, - unregister_plugin_host_pty, PluginHostLaunchPolicy, PluginHostLaunchSpec, - PluginHostStartup, + plugin_host_pty_ids_for_instance, plugin_host_pty_owned_by, push_plugin_host_diagnostic, + register_plugin_host_pty, unregister_plugin_host_pty, PluginHostLaunchPolicy, + PluginHostLaunchSpec, PluginHostStartup, MAX_PLUGIN_HOST_DIAGNOSTICS, }; use std::path::Path; @@ -748,4 +1374,19 @@ mod tests { ); assert!(unregister_plugin_host_pty(&pty_id, &first).await); } + + #[test] + fn diagnostic_health_snapshot_retains_the_newest_entries() { + let mut snapshot = Vec::new(); + for index in 0..=MAX_PLUGIN_HOST_DIAGNOSTICS { + push_plugin_host_diagnostic(&mut snapshot, serde_json::json!({"index": index})); + } + + assert_eq!(snapshot.len(), MAX_PLUGIN_HOST_DIAGNOSTICS); + assert_eq!(snapshot.first().unwrap()["index"], 1); + assert_eq!( + snapshot.last().unwrap()["index"], + MAX_PLUGIN_HOST_DIAGNOSTICS + ); + } } diff --git a/src/crates/assembly/core/src/plugin_host_http.rs b/src/crates/assembly/core/src/plugin_host_http.rs index fa8455d39a..8b9d12a3cb 100644 --- a/src/crates/assembly/core/src/plugin_host_http.rs +++ b/src/crates/assembly/core/src/plugin_host_http.rs @@ -464,6 +464,24 @@ pub(crate) async fn register_plugin_host_backend_handlers( }) .await .map_err(plugin_host_handler_error)?; + client + .register_handler("backend.tool.ask", |params| async move { + crate::agentic::tools::plugin_host_tool::handle_tool_ask(params).await + }) + .await + .map_err(plugin_host_handler_error)?; + client + .register_handler("backend.tool.metadata", |params| async move { + crate::agentic::tools::plugin_host_tool::handle_tool_metadata(params).await + }) + .await + .map_err(plugin_host_handler_error)?; + client + .register_handler("backend.diagnostic.publish", |params| async move { + crate::plugin_host::publish_plugin_host_diagnostic(params).await + }) + .await + .map_err(plugin_host_handler_error)?; PLUGIN_HOST_BACKEND_BRIDGE .set(bridge.clone()) .map_err(|_| { diff --git a/src/crates/assembly/core/src/plugin_host_http_routes.rs b/src/crates/assembly/core/src/plugin_host_http_routes.rs index 8baf9d565e..5b616fe3ba 100644 --- a/src/crates/assembly/core/src/plugin_host_http_routes.rs +++ b/src/crates/assembly/core/src/plugin_host_http_routes.rs @@ -562,8 +562,16 @@ mod tests { project_id: project_id.to_string(), created_at_ms: 1, instance_id: instance_id.to_string(), + generation_key: "generation-test".to_string(), + revision: "revision-test".to_string(), open_result: json!({}), ready: true, + hook_commit_token: None, + transformed_config_health_snapshot: None, + diagnostic_health_snapshot: Vec::new(), + tool_names: Vec::new(), + agent_runtime_keys: Vec::new(), + retirement_scheduled: false, } } diff --git a/src/crates/assembly/core/src/plugin_runtime.rs b/src/crates/assembly/core/src/plugin_runtime.rs index af1bf8e1d4..5ddf1a4b19 100644 --- a/src/crates/assembly/core/src/plugin_runtime.rs +++ b/src/crates/assembly/core/src/plugin_runtime.rs @@ -27,6 +27,14 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +#[cfg(feature = "opencode-plugin-host")] +pub(crate) fn opencode_config_snapshot( + workspace: &Path, +) -> Result { + bitfun_opencode_adapter::load_opencode_config_snapshot(workspace) + .map_err(|error| error.to_string()) +} + const PREVIEW_PROJECT_ID: &str = "managed-plugin-preview"; const PREVIEW_WORKSPACE_ID: &str = "managed-plugin-preview"; const DSH_MANIFEST_ADAPTER_ID: &str = "dsh_compatible"; diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index db8bbc5d62..2693f271a8 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -481,6 +481,7 @@ fn core_agent_runtime_builder( thread_goal_management: Arc, cancellation: Arc, interaction_response: Arc, + hook_registry: bitfun_agent_runtime::native_hooks::RuntimeHookRegistry, ) -> Result { let agent_registry: Arc = crate::agentic::agents::get_agent_registry(); @@ -500,6 +501,7 @@ fn core_agent_runtime_builder( .with_cancellation_port(cancellation) .with_interaction_response_port(interaction_response) .with_permission_request_manager(crate::product_runtime::core_permission_request_manager()?) + .with_hook_registry(hook_registry) .with_agent_registry(agent_registry) .with_mode_catalog(mode_catalog)) } @@ -543,6 +545,7 @@ impl AgentModeCatalogPort for CoreAgentModeCatalogPort { .into_iter() .map(|mode| AgentModeCatalogEntry { id: mode.id, + route_key: mode.key, description: mode.description, model_id: mode.model, is_external: mode.source == crate::agentic::agents::AgentSource::External, @@ -1489,6 +1492,7 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; core_agent_runtime_builder( submission, @@ -1504,6 +1508,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .build() .map_err(|error| error.to_string()) @@ -1529,6 +1534,7 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); let lifecycle_delivery: Arc = scheduler; @@ -1546,6 +1552,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_close_port(session_close) .with_session_revert_port(session_revert) @@ -1574,6 +1581,7 @@ impl CoreServiceAgentRuntime { let thread_goal_management: Arc = coordinator.clone(); let cancellation: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let lifecycle_delivery: Arc = scheduler; core_agent_runtime_builder( @@ -1590,6 +1598,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_revert_port(session_revert) .with_lifecycle_delivery_port(lifecycle_delivery) @@ -1659,6 +1668,7 @@ impl CoreServiceAgentRuntime { coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let dialog_turn: Arc = scheduler.clone(); @@ -1677,6 +1687,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_revert_port(session_revert) .with_dialog_turn_port(dialog_turn) @@ -1779,6 +1790,7 @@ impl CoreServiceAgentRuntime { coordinator.clone(); let thread_goal_management: Arc = coordinator.clone(); let session_compaction: Arc = coordinator.clone(); + let hook_registry = coordinator.hook_registry().clone(); let interaction_response: Arc = coordinator; let cancellation: Arc = scheduler.clone(); let lifecycle_delivery: Arc = scheduler; @@ -1797,6 +1809,7 @@ impl CoreServiceAgentRuntime { thread_goal_management, cancellation, interaction_response, + hook_registry, )? .with_session_close_port(session_close) .with_session_revert_port(session_revert) diff --git a/src/crates/contracts/runtime-ports/AGENTS.md b/src/crates/contracts/runtime-ports/AGENTS.md index bbcec6650a..78ddd386e3 100644 --- a/src/crates/contracts/runtime-ports/AGENTS.md +++ b/src/crates/contracts/runtime-ports/AGENTS.md @@ -26,6 +26,17 @@ facts. It is an interface crate, not a runtime implementation crate. load/invoke/cancel/dispose requests, execution context paths, and string results. Ecosystem source parsing, approval/conflict policy, product routing, process supervision, dependency installation, and UI do not belong here. +- `HookFunctionRuntime` owns only provider-neutral availability, single-worker + start/transform-config/execute-tool/cancel/dispose requests, a complete + plugin-generation registration **notification sink** (not a load response), + a reverse-channel sink (`metadata`/`ask`/`ask_reply`), and typed config/tool + results. One worker owns the ordered plugin set and returns one final config + result for the complete config hook chain. + Plugin activation/trust, process supervision, `server(PluginInput)` to + `Hooks` construction, permission decisions, and UI do not belong here. It + deliberately diverges from `ScriptToolRuntime` (push notifications plus + reverse channel, versus synchronous load/invoke response); do not collapse + the two. - Do not put filesystem writes, process execution, network clients, Git/AI/MCP concrete behavior, product policy, permission decisions, audit outcomes, UI extension behavior, UI implementation, or UI command logic here. diff --git a/src/crates/contracts/runtime-ports/src/agent_api.rs b/src/crates/contracts/runtime-ports/src/agent_api.rs index e0cd6c8f2e..c9f017d0a8 100644 --- a/src/crates/contracts/runtime-ports/src/agent_api.rs +++ b/src/crates/contracts/runtime-ports/src/agent_api.rs @@ -6,6 +6,8 @@ use super::*; pub struct AgentSessionCreateRequest { pub session_name: String, pub agent_type: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -228,6 +230,8 @@ pub struct AgentSessionModelSelectionUpdateRequest { pub struct AgentSessionModeUpdateRequest { pub session_id: String, pub mode_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -244,6 +248,8 @@ pub struct AgentModeCatalogQuery { #[serde(rename_all = "camelCase")] pub struct AgentModeCatalogEntry { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/execution/agent-runtime/src/native_hooks/call.rs b/src/crates/execution/agent-runtime/src/native_hooks/call.rs new file mode 100644 index 0000000000..f05575e972 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/call.rs @@ -0,0 +1,32 @@ +//! Provider-neutral hook calls shared by command, builtin and function hooks. + +use super::kind::RuntimeHookKind; +use super::payload::AgentHookEventPayload; +use serde_json::Value; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct HookCall { + pub kind: RuntimeHookKind, + pub cwd: PathBuf, + pub session_id: Option, + pub turn_id: Option, + pub workspace_root: Option, + pub is_remote: bool, + pub model: Option, + pub bypass_permissions: bool, + pub payload: HookCallPayload, +} + +#[derive(Debug, Clone)] +pub enum HookCallPayload { + Lifecycle(AgentHookEventPayload), + Config(Value), + ToolUse { + name: String, + input: Value, + custom_data: HashMap, + agent_type: Option, + }, +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/engine.rs b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs index 1d406000a3..4bd8b20253 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/engine.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/engine.rs @@ -9,12 +9,18 @@ //! - Exit code 2: the event is blocked; stderr provides the blocking reason. //! - Any other exit code, spawn failure, or timeout: a non-blocking warning. +use super::call::{HookCall, HookCallPayload}; +use super::handler::{HookHandler, HookHandlerResult, PluginHookCall}; +use super::kind::RuntimeHookKind; use super::output::{non_empty, AgentHookOutcome, RawHookOutput}; use super::payload::AgentHookPayload; +use super::registry::RuntimeHookRegistry; use super::settings::{AgentHookEvent, AgentHookHandler, AgentHookSettings}; use log::{debug, warn}; +use serde_json::Value; use std::path::{Path, PathBuf}; use std::process::Stdio; +use std::sync::Arc; use std::time::Duration; use tokio::io::AsyncWriteExt; use tokio::process::Command; @@ -27,57 +33,244 @@ pub const MAX_HOOK_MODEL_OUTPUT_BYTES: usize = 10_000; const MAX_CAPTURED_OUTPUT_BYTES: usize = 1024 * 1024; /// Executes configured hooks for agent lifecycle events. -#[derive(Debug, Default)] +#[derive(Debug, Clone, Default)] pub struct AgentHookEngine { - settings: AgentHookSettings, + registry: RuntimeHookRegistry, + settings: Option>, } impl AgentHookEngine { pub fn new(settings: AgentHookSettings) -> Self { - Self { settings } + let registry = RuntimeHookRegistry::default(); + registry + .register_batch(settings.registrations()) + .expect("parsed hook settings must produce valid registrations"); + Self { + registry, + settings: Some(Arc::new(settings)), + } + } + + pub fn with_registry(registry: RuntimeHookRegistry) -> Self { + Self { + registry, + settings: None, + } } pub fn is_empty(&self) -> bool { - self.settings.is_empty() + self.registry.plans().is_empty() } pub fn has_rules(&self, event: AgentHookEvent) -> bool { - self.settings.has_rules(event) + self.has_rules_for_workspace(event, None) + } + + pub fn has_rules_for_workspace( + &self, + event: AgentHookEvent, + workspace_scope: Option<&str>, + ) -> bool { + !self + .registry + .registrations_for_workspace(RuntimeHookKind::Lifecycle(event), workspace_scope) + .is_empty() } pub fn settings(&self) -> &AgentHookSettings { - &self.settings + self.settings + .as_deref() + .expect("engine was constructed from a runtime hook registry") + } + + pub fn registry(&self) -> &RuntimeHookRegistry { + &self.registry } /// Run every matching handler for the payload's event, sequentially in /// configuration order (user layers before project layers), and fold /// their results into one [`AgentHookOutcome`]. pub async fn dispatch(&self, payload: &AgentHookPayload, cwd: &Path) -> AgentHookOutcome { + self.dispatch_for_workspace(payload, cwd, None).await + } + + pub async fn dispatch_for_workspace( + &self, + payload: &AgentHookPayload, + cwd: &Path, + workspace_scope: Option<&str>, + ) -> AgentHookOutcome { let event = payload.event(); let mut outcome = AgentHookOutcome::default(); - let rules = self.settings.rules_for(event); - if rules.is_empty() { + let registrations = self + .registry + .registrations_for_workspace(RuntimeHookKind::Lifecycle(event), workspace_scope); + if registrations.is_empty() { return outcome; } let matcher_value = payload.event.matcher_value(); let payload_json = payload.to_json().to_string(); - 'rules: for rule in rules { - if !rule.matcher.matches(matcher_value) { + let call = lifecycle_call(payload, cwd); + for registration in registrations.iter() { + if !registration.matcher.matches(matcher_value) { continue; } - for handler in &rule.handlers { - outcome.executed_handlers += 1; - let finalized = self - .run_and_apply(event, handler, &payload_json, cwd, &mut outcome) + outcome.executed_handlers += 1; + let finalized = match ®istration.handler { + HookHandler::Command(handler) => { + self.run_and_apply(event, handler, &payload_json, cwd, &mut outcome) + .await + } + HookHandler::Builtin { executor } => { + apply_handler_result(executor.execute(&call).await, &mut outcome) + } + HookHandler::Plugin { + executor, + instance_id, + hook_name, + generation_key, + revision, + .. + } => { + let result = tokio::time::timeout( + Duration::from_millis(registration.plan.timeout_millis()), + executor.execute(PluginHookCall { + instance_id: instance_id.clone(), + workspace_scope: registration + .workspace_scope + .clone() + .unwrap_or_default(), + generation_key: generation_key.clone(), + revision: revision.clone(), + hook_name: hook_name.clone(), + input: payload.to_json(), + output: Value::Object(Default::default()), + }), + ) .await; - if finalized { - break 'rules; + if let Err(error) = result.unwrap_or_else(|_| Err("timed out".to_string())) { + outcome.warnings.push(format!( + "Plugin hook '{}' for {event} failed: {error}", + registration.plan.id() + )); + } + false } + }; + if finalized { + break; } } outcome } + /// Execute one provider hook snapshot and carry input/output mutations + /// forward in registry order. + pub async fn dispatch_plugin_hook( + &self, + workspace_scope: Option<&str>, + hook_name: &str, + input: Value, + output: Value, + ) -> PluginHookDispatchResult { + self.dispatch_plugin_hook_for_generation(workspace_scope, None, hook_name, input, output) + .await + } + + pub async fn dispatch_plugin_hook_for_generation( + &self, + workspace_scope: Option<&str>, + generation: Option<&super::handler::PluginHookGenerationIdentity>, + hook_name: &str, + mut input: Value, + mut output: Value, + ) -> PluginHookDispatchResult { + let kind = RuntimeHookKind::PluginHook(hook_name.to_string()); + let registrations = match (workspace_scope, generation) { + (Some(workspace_scope), Some(generation)) => self + .registry + .registrations_for_plugin_generation(kind, workspace_scope, generation), + _ => self + .registry + .registrations_for_workspace(kind, workspace_scope), + }; + let mut result = PluginHookDispatchResult::default(); + for registration in registrations.iter() { + let HookHandler::Plugin { + executor, + instance_id, + hook_name, + generation_key, + revision, + .. + } = ®istration.handler + else { + continue; + }; + result.executed_handlers += 1; + let invocation = executor.execute(PluginHookCall { + instance_id: instance_id.clone(), + workspace_scope: registration.workspace_scope.clone().unwrap_or_default(), + generation_key: generation_key.clone(), + revision: revision.clone(), + hook_name: hook_name.clone(), + input: input.clone(), + output: output.clone(), + }); + match tokio::time::timeout( + Duration::from_millis(registration.plan.timeout_millis()), + invocation, + ) + .await + { + Ok(Ok(transformed)) => { + if transformed.instance_id != *instance_id + || transformed.generation_key != *generation_key + || transformed.revision != *revision + || transformed.hook_name != *hook_name + { + result.warnings.push(format!( + "Plugin hook '{}' returned a mismatched generation lease", + registration.plan.id() + )); + continue; + } + input = transformed.input; + output = transformed.output; + } + Ok(Err(error)) => result.warnings.push(format!( + "Plugin hook '{}' failed: {error}", + registration.plan.id() + )), + Err(_) => result.warnings.push(format!( + "Plugin hook '{}' timed out after {}ms", + registration.plan.id(), + registration.plan.timeout_millis() + )), + } + } + result.input = input; + result.output = output; + result + } + + pub async fn dispatch_call( + &self, + workspace_scope: Option<&str>, + call: &HookCall, + ) -> HookHandlerResult { + let registrations = self + .registry + .registrations_for_workspace(call.kind.clone(), workspace_scope); + let mut result = HookHandlerResult::default(); + for registration in registrations.iter() { + if let HookHandler::Builtin { executor } = ®istration.handler { + merge_handler_result(executor.execute(call).await, &mut result); + } + } + result + } + /// Run one handler and fold its result into `outcome`. Returns `true` /// when the dispatch is finalized (blocked or denied) and remaining /// handlers must not run. @@ -153,6 +346,59 @@ impl AgentHookEngine { } } +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookDispatchResult { + pub input: Value, + pub output: Value, + pub warnings: Vec, + pub executed_handlers: usize, +} + +impl Default for PluginHookDispatchResult { + fn default() -> Self { + Self { + input: Value::Null, + output: Value::Null, + warnings: Vec::new(), + executed_handlers: 0, + } + } +} + +fn lifecycle_call(payload: &AgentHookPayload, cwd: &Path) -> HookCall { + HookCall { + kind: RuntimeHookKind::Lifecycle(payload.event()), + cwd: cwd.to_path_buf(), + session_id: Some(payload.common.session_id.clone()), + turn_id: payload.common.turn_id.clone(), + workspace_root: Some(cwd.to_path_buf()), + is_remote: false, + model: Some(payload.common.model.clone()), + bypass_permissions: matches!( + payload.common.permission_mode, + super::payload::AgentHookPermissionMode::BypassPermissions + ), + payload: HookCallPayload::Lifecycle(payload.event.clone()), + } +} + +fn apply_handler_result(result: HookHandlerResult, outcome: &mut AgentHookOutcome) -> bool { + outcome.warnings.extend(result.warnings); + outcome.additional_context.extend(result.additional_context); + if outcome.block_reason.is_none() { + outcome.block_reason = result.block_reason; + } + outcome.is_blocked() +} + +fn merge_handler_result(result: HookHandlerResult, outcome: &mut HookHandlerResult) { + outcome.warnings.extend(result.warnings); + outcome.additional_context.extend(result.additional_context); + if outcome.block_reason.is_none() { + outcome.block_reason = result.block_reason; + } +} + enum HookCommandRun { Completed { exit_code: Option, diff --git a/src/crates/execution/agent-runtime/src/native_hooks/handler.rs b/src/crates/execution/agent-runtime/src/native_hooks/handler.rs new file mode 100644 index 0000000000..5253c57079 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/handler.rs @@ -0,0 +1,151 @@ +//! Executable hook handler variants. + +use super::call::HookCall; +use super::kind::{RuntimeHookKind, RuntimeHookSource}; +use super::registry::RuntimeHookPlan; +use super::settings::{AgentHookHandler, AgentHookMatcher}; +use async_trait::async_trait; +use serde_json::Value; +use std::sync::Arc; + +#[derive(Clone)] +pub enum HookHandler { + Command(AgentHookHandler), + Plugin { + executor: Arc, + hook_name: String, + instance_id: String, + generation_key: String, + revision: String, + }, + Builtin { + executor: Arc, + }, +} + +impl std::fmt::Debug for HookHandler { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Command(handler) => f.debug_tuple("Command").field(handler).finish(), + Self::Plugin { + hook_name, + instance_id, + generation_key, + revision, + .. + } => f + .debug_struct("Plugin") + .field("hook_name", hook_name) + .field("instance_id", instance_id) + .field("generation_key", generation_key) + .field("revision", revision) + .finish_non_exhaustive(), + Self::Builtin { .. } => f.debug_struct("Builtin").finish_non_exhaustive(), + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct HookHandlerResult { + pub warnings: Vec, + pub block_reason: Option, + pub additional_context: Vec, +} + +#[async_trait] +pub trait BuiltinHookExecutor: Send + Sync { + async fn execute(&self, call: &HookCall) -> HookHandlerResult; +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookCall { + pub instance_id: String, + pub workspace_scope: String, + pub generation_key: String, + pub revision: String, + pub hook_name: String, + pub input: Value, + pub output: Value, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookResult { + pub instance_id: String, + pub generation_key: String, + pub revision: String, + pub hook_name: String, + pub input: Value, + pub output: Value, +} + +#[async_trait] +pub trait PluginHookExecutor: Send + Sync { + async fn execute(&self, call: PluginHookCall) -> Result; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginHookGenerationIdentity { + pub instance_id: String, + pub generation_key: String, + pub revision: String, +} + +#[derive(Clone, Debug)] +pub struct RuntimeHookRegistration { + pub plan: RuntimeHookPlan, + pub handler: HookHandler, + pub matcher: AgentHookMatcher, + pub workspace_scope: Option, +} + +impl RuntimeHookRegistration { + pub fn new(plan: RuntimeHookPlan, handler: HookHandler, matcher: AgentHookMatcher) -> Self { + Self { + plan, + handler, + matcher, + workspace_scope: None, + } + } + + pub fn with_workspace_scope(mut self, workspace_scope: impl Into) -> Self { + self.workspace_scope = Some(workspace_scope.into()); + self + } + + pub fn command( + id: impl Into, + kind: RuntimeHookKind, + source: RuntimeHookSource, + handler: AgentHookHandler, + matcher: AgentHookMatcher, + ) -> Self { + Self::new( + RuntimeHookPlan::new(id, kind, source), + HookHandler::Command(handler), + matcher, + ) + } + + pub fn plugin( + plan: RuntimeHookPlan, + hook_name: impl Into, + instance_id: impl Into, + generation_key: impl Into, + revision: impl Into, + executor: Arc, + matcher: AgentHookMatcher, + ) -> Self { + Self::new( + plan, + HookHandler::Plugin { + executor, + hook_name: hook_name.into(), + instance_id: instance_id.into(), + generation_key: generation_key.into(), + revision: revision.into(), + }, + matcher, + ) + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/kind.rs b/src/crates/execution/agent-runtime/src/native_hooks/kind.rs new file mode 100644 index 0000000000..5174c6b315 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/kind.rs @@ -0,0 +1,43 @@ +//! Stable hook categories and sources used by the portable hook registry. + +use super::settings::AgentHookEvent; +use std::fmt; + +/// The Codex lifecycle events plus BitFun/OpenCode execution categories. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RuntimeHookKind { + Lifecycle(AgentHookEvent), + SuccessfulToolPostCall, + PluginHook(String), +} + +/// Origin of a registered hook. The declaration order is the stable source +/// precedence used when snapshots are sorted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[non_exhaustive] +pub enum RuntimeHookSource { + Builtin { priority: u16 }, + UserCommand, + ProjectCommand, + ImportedCommand, + OpenCodePlugin, +} + +impl RuntimeHookSource { + pub const fn is_open_code_plugin(self) -> bool { + matches!(self, Self::OpenCodePlugin) + } +} + +impl fmt::Display for RuntimeHookSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Builtin { .. } => f.write_str("builtin"), + Self::UserCommand => f.write_str("user-command"), + Self::ProjectCommand => f.write_str("project-command"), + Self::ImportedCommand => f.write_str("imported-command"), + Self::OpenCodePlugin => f.write_str("opencode-plugin"), + } + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs index c3f0a2cc70..839004b79b 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/mod.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/mod.rs @@ -16,22 +16,31 @@ //! - the external hook catalog (`bitfun-product-domains`): read-only //! inspection of other AI applications' hook configuration. -#[cfg(feature = "native-hook-runtime")] +mod call; mod engine; -#[cfg(feature = "native-hook-runtime")] +mod handler; +mod kind; mod output; -#[cfg(feature = "native-hook-runtime")] mod payload; +mod registry; mod settings; -#[cfg(feature = "native-hook-runtime")] -pub use engine::{AgentHookEngine, MAX_HOOK_MODEL_OUTPUT_BYTES}; -#[cfg(feature = "native-hook-runtime")] +pub use call::{HookCall, HookCallPayload}; +pub use engine::{AgentHookEngine, PluginHookDispatchResult, MAX_HOOK_MODEL_OUTPUT_BYTES}; +pub use handler::{ + BuiltinHookExecutor, HookHandler, HookHandlerResult, PluginHookCall, PluginHookExecutor, + PluginHookGenerationIdentity, PluginHookResult, RuntimeHookRegistration, +}; +pub use kind::{RuntimeHookKind, RuntimeHookSource}; pub use output::{AgentHookOutcome, AgentHookPermissionOutcome}; -#[cfg(feature = "native-hook-runtime")] pub use payload::{ AgentHookEventPayload, AgentHookPayload, AgentHookPayloadCommon, AgentHookPermissionMode, }; +pub use registry::{ + RuntimeHookActivation, RuntimeHookCommitToken, RuntimeHookErrorPolicy, RuntimeHookPlan, + RuntimeHookRegistry, RuntimeHookRegistryBuildError, RuntimeHookRegistryBuilder, + RuntimeHookRegistryError, +}; pub use settings::{ AgentHookEvent, AgentHookHandler, AgentHookMatcher, AgentHookRule, AgentHookScope, AgentHookSettings, AgentHookSettingsIssue, AgentHookSettingsLayer, MAX_HOOKS_FILE_BYTES, diff --git a/src/crates/execution/agent-runtime/src/native_hooks/registry.rs b/src/crates/execution/agent-runtime/src/native_hooks/registry.rs new file mode 100644 index 0000000000..00ee668543 --- /dev/null +++ b/src/crates/execution/agent-runtime/src/native_hooks/registry.rs @@ -0,0 +1,627 @@ +//! Shared runtime hook registry. + +use super::handler::{HookHandler, RuntimeHookRegistration}; +use super::kind::{RuntimeHookKind, RuntimeHookSource}; +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::sync::{Arc, RwLock}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RuntimeHookActivation { + Preparing, + Ready, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RuntimeHookErrorPolicy { + FailTurn, + SkipHook, + DenyTool, + RecordWarning, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeHookPlan { + id: String, + kind: RuntimeHookKind, + source: RuntimeHookSource, + order: u16, + timeout_millis: u64, + error_policy: RuntimeHookErrorPolicy, +} + +impl RuntimeHookPlan { + pub fn new(id: impl Into, kind: RuntimeHookKind, source: RuntimeHookSource) -> Self { + Self { + id: id.into(), + kind, + source, + order: 100, + timeout_millis: 1_000, + error_policy: RuntimeHookErrorPolicy::RecordWarning, + } + } + + pub fn with_order(mut self, order: u16) -> Self { + self.order = order; + self + } + + pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self { + self.timeout_millis = timeout_millis; + self + } + + pub fn with_error_policy(mut self, error_policy: RuntimeHookErrorPolicy) -> Self { + self.error_policy = error_policy; + self + } + + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = id.into(); + self + } + + pub fn with_source(mut self, source: RuntimeHookSource) -> Self { + self.source = source; + self + } + + pub fn id(&self) -> &str { + &self.id + } + pub const fn kind(&self) -> &RuntimeHookKind { + &self.kind + } + pub const fn source(&self) -> RuntimeHookSource { + self.source + } + pub const fn order(&self) -> u16 { + self.order + } + pub const fn timeout_millis(&self) -> u64 { + self.timeout_millis + } + pub const fn error_policy(&self) -> RuntimeHookErrorPolicy { + self.error_policy + } +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RuntimeHookRegistryBuildError { + #[error("runtime hook id must not be empty")] + EmptyHookId, + #[error("runtime hook {hook_id} must declare a non-zero timeout")] + InvalidTimeoutMillis { hook_id: String }, + #[error("duplicate runtime hook id {hook_id}")] + DuplicateHookId { hook_id: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RuntimeHookRegistryError { + #[error(transparent)] + Validation(#[from] RuntimeHookRegistryBuildError), + #[error("runtime hook source {hook_source} cannot be replaced")] + InvalidReplacementSource { hook_source: RuntimeHookSource }, + #[error("OpenCode plugin hook batch must contain one target and revision")] + InvalidPluginBatch, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeHookCommitToken { + workspace_scope: String, + target_id: String, + generation_key: String, + revision: String, + hook_ids: Arc<[String]>, +} + +impl RuntimeHookCommitToken { + pub fn workspace_scope(&self) -> &str { + &self.workspace_scope + } + + pub fn target_id(&self) -> &str { + &self.target_id + } + pub fn revision(&self) -> &str { + &self.revision + } + + pub fn generation_key(&self) -> &str { + &self.generation_key + } +} + +#[derive(Debug, Clone, Default)] +pub struct RuntimeHookRegistryBuilder { + hooks: Vec, +} + +impl RuntimeHookRegistryBuilder { + pub fn register(mut self, hook: RuntimeHookRegistration) -> Self { + self.hooks.push(hook); + self + } + + pub fn build(self) -> Result { + validate_entries(&self.hooks).map_err(|error| match error { + RuntimeHookRegistryError::Validation(error) => error, + RuntimeHookRegistryError::InvalidReplacementSource { .. } => { + RuntimeHookRegistryBuildError::EmptyHookId + } + RuntimeHookRegistryError::InvalidPluginBatch => { + RuntimeHookRegistryBuildError::EmptyHookId + } + })?; + let registry = RuntimeHookRegistry::default(); + registry + .replace_state(self.hooks) + .map_err(|error| match error { + RuntimeHookRegistryError::Validation(error) => error, + RuntimeHookRegistryError::InvalidReplacementSource { .. } => { + RuntimeHookRegistryBuildError::EmptyHookId + } + RuntimeHookRegistryError::InvalidPluginBatch => { + RuntimeHookRegistryBuildError::EmptyHookId + } + })?; + Ok(registry) + } +} + +struct RuntimeHookRegistryState { + entries: BTreeMap>, + source_activation: BTreeMap<(RuntimeHookSource, Option), RuntimeHookActivation>, + active_plugin_generations: BTreeMap, +} + +impl Default for RuntimeHookRegistryState { + fn default() -> Self { + Self { + entries: BTreeMap::new(), + source_activation: BTreeMap::new(), + active_plugin_generations: BTreeMap::new(), + } + } +} + +#[derive(Clone, Default)] +pub struct RuntimeHookRegistry { + inner: Arc>, +} + +impl fmt::Debug for RuntimeHookRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let plans = self.plans(); + f.debug_struct("RuntimeHookRegistry") + .field("plans", &plans) + .field("count", &plans.len()) + .finish() + } +} + +impl RuntimeHookRegistry { + pub fn builder() -> RuntimeHookRegistryBuilder { + RuntimeHookRegistryBuilder::default() + } + + pub fn register_batch( + &self, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + validate_entries(&entries)?; + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let mut merged = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .collect::>(); + merged.extend(entries); + validate_entries(&merged)?; + rebuild_entries(&mut state.entries, merged); + Ok(()) + } + + pub fn register_plugin_batch( + &self, + entries: Vec, + ) -> Result { + let (workspace_scope, target_id, generation_key, revision) = + plugin_batch_identity(&entries)?; + let hook_ids = entries + .iter() + .map(|entry| entry.plan.id().to_string()) + .collect::>(); + self.register_batch(entries)?; + Ok(RuntimeHookCommitToken { + workspace_scope, + target_id, + generation_key, + revision, + hook_ids: Arc::from(hook_ids), + }) + } + + pub fn rollback_plugin_batch(&self, token: &RuntimeHookCommitToken) { + let hook_ids = token + .hook_ids + .iter() + .map(String::as_str) + .collect::>(); + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let retained = state.entries.values().flat_map(|items| items.iter().cloned()).filter(|entry| { + if !hook_ids.contains(entry.plan.id()) { return true; } + !matches!(&entry.handler, HookHandler::Plugin { instance_id, generation_key, revision, .. } + if entry.workspace_scope.as_deref() == Some(token.workspace_scope.as_str()) + && instance_id == &token.target_id + && generation_key == &token.generation_key + && revision == &token.revision) + }).collect::>(); + rebuild_entries(&mut state.entries, retained); + } + + pub fn replace_command_source( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + if !matches!( + source, + RuntimeHookSource::UserCommand + | RuntimeHookSource::ProjectCommand + | RuntimeHookSource::ImportedCommand + ) { + return Err(RuntimeHookRegistryError::InvalidReplacementSource { + hook_source: source, + }); + } + validate_entries(&entries)?; + if entries.iter().any(|entry| entry.plan.source() != source) { + return Err(RuntimeHookRegistryError::InvalidReplacementSource { + hook_source: source, + }); + } + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let mut merged = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .filter(|entry| { + entry.plan.source() != source || entry.workspace_scope.as_deref() != workspace_scope + }) + .collect::>(); + merged.extend(entries); + validate_entries(&merged)?; + rebuild_entries(&mut state.entries, merged); + Ok(()) + } + + pub fn plans(&self) -> Vec { + let state = self.inner.read().expect("hook registry lock poisoned"); + state + .entries + .values() + .flat_map(|items| items.iter().map(|entry| entry.plan.clone())) + .collect() + } + + /// Compatibility spelling for internal callers while migration completes. + pub fn hooks(&self) -> Vec { + self.plans() + } + + pub fn registrations_for(&self, kind: RuntimeHookKind) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace(kind, None) + } + + pub fn registrations_for_workspace( + &self, + kind: RuntimeHookKind, + workspace_scope: Option<&str>, + ) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace_generation(kind, workspace_scope, None) + } + + pub fn registrations_for_plugin_generation( + &self, + kind: RuntimeHookKind, + workspace_scope: &str, + generation: &super::handler::PluginHookGenerationIdentity, + ) -> Arc<[RuntimeHookRegistration]> { + self.registrations_for_workspace_generation(kind, Some(workspace_scope), Some(generation)) + } + + fn registrations_for_workspace_generation( + &self, + kind: RuntimeHookKind, + workspace_scope: Option<&str>, + requested_plugin_generation: Option<&super::handler::PluginHookGenerationIdentity>, + ) -> Arc<[RuntimeHookRegistration]> { + let state = self.inner.read().expect("hook registry lock poisoned"); + let entries = state + .entries + .get(&kind) + .cloned() + .unwrap_or_else(|| Arc::from([])); + let plugin_activation = state + .source_activation + .get(&( + RuntimeHookSource::OpenCodePlugin, + workspace_scope.map(str::to_string), + )) + .or_else(|| { + state + .source_activation + .get(&(RuntimeHookSource::OpenCodePlugin, None)) + }) + .copied() + .unwrap_or(RuntimeHookActivation::Unavailable); + let active_plugin_generation = + workspace_scope.and_then(|workspace| state.active_plugin_generations.get(workspace)); + Arc::from( + entries + .iter() + .filter(|entry| { + let workspace_matches = match (&entry.workspace_scope, workspace_scope) { + (Some(expected), Some(actual)) => expected == actual, + (None, _) => true, + (Some(_), None) => false, + }; + let plugin_generation_matches = if !entry.plan.source().is_open_code_plugin() { + true + } else if plugin_activation != RuntimeHookActivation::Ready { + false + } else { + match (&entry.handler, requested_plugin_generation) { + ( + HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + }, + Some(requested), + ) => { + instance_id == &requested.instance_id + && generation_key == &requested.generation_key + && revision == &requested.revision + } + ( + HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + }, + None, + ) => { + matches!(active_plugin_generation, + Some((active_instance, active_generation, active_revision)) + if instance_id == active_instance + && generation_key == active_generation + && revision == active_revision) + } + _ => false, + } + }; + workspace_matches && plugin_generation_matches + }) + .cloned() + .collect::>(), + ) + } + + pub fn source_activation(&self, source: RuntimeHookSource) -> RuntimeHookActivation { + self.source_activation_for_workspace(source, None) + } + + pub fn source_activation_for_workspace( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + ) -> RuntimeHookActivation { + let state = self.inner.read().expect("hook registry lock poisoned"); + state + .source_activation + .get(&(source, workspace_scope.map(str::to_string))) + .or_else(|| state.source_activation.get(&(source, None))) + .copied() + .unwrap_or_else(|| { + if source.is_open_code_plugin() { + RuntimeHookActivation::Unavailable + } else { + RuntimeHookActivation::Ready + } + }) + } + + pub fn set_source_activation( + &self, + source: RuntimeHookSource, + activation: RuntimeHookActivation, + ) { + self.set_source_activation_for_workspace(source, None, activation); + } + + pub fn set_source_activation_for_workspace( + &self, + source: RuntimeHookSource, + workspace_scope: Option<&str>, + activation: RuntimeHookActivation, + ) { + self.inner + .write() + .expect("hook registry lock poisoned") + .source_activation + .insert((source, workspace_scope.map(str::to_string)), activation); + } + + pub fn activate_plugin_batch( + &self, + workspace_scope: &str, + token: Option<&RuntimeHookCommitToken>, + ) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + if let Some(token) = token { + debug_assert_eq!(token.workspace_scope(), workspace_scope); + state.active_plugin_generations.insert( + workspace_scope.to_string(), + ( + token.target_id().to_string(), + token.generation_key().to_string(), + token.revision().to_string(), + ), + ); + } else { + state.active_plugin_generations.remove(workspace_scope); + } + state.source_activation.insert( + ( + RuntimeHookSource::OpenCodePlugin, + Some(workspace_scope.to_string()), + ), + RuntimeHookActivation::Ready, + ); + } + + pub fn withdraw_plugin_workspace(&self, workspace_scope: &str) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + state.active_plugin_generations.remove(workspace_scope); + state.source_activation.insert( + ( + RuntimeHookSource::OpenCodePlugin, + Some(workspace_scope.to_string()), + ), + RuntimeHookActivation::Unavailable, + ); + } + + pub fn clear_source_workspace(&self, source: RuntimeHookSource, workspace_scope: &str) { + self.clear_source_partition(source, Some(workspace_scope)); + } + + pub fn clear_source_partition(&self, source: RuntimeHookSource, workspace_scope: Option<&str>) { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + let retained = state + .entries + .values() + .flat_map(|items| items.iter().cloned()) + .filter(|entry| { + entry.plan.source() != source || entry.workspace_scope.as_deref() != workspace_scope + }) + .collect::>(); + rebuild_entries(&mut state.entries, retained); + state + .source_activation + .remove(&(source, workspace_scope.map(str::to_string))); + if source == RuntimeHookSource::OpenCodePlugin { + if let Some(workspace_scope) = workspace_scope { + state.active_plugin_generations.remove(workspace_scope); + } + } + } + + fn replace_state( + &self, + entries: Vec, + ) -> Result<(), RuntimeHookRegistryError> { + let mut state = self.inner.write().expect("hook registry lock poisoned"); + rebuild_entries(&mut state.entries, entries); + Ok(()) + } +} + +fn validate_entries(entries: &[RuntimeHookRegistration]) -> Result<(), RuntimeHookRegistryError> { + let mut ids = HashSet::with_capacity(entries.len()); + for entry in entries { + let plan = &entry.plan; + if plan.id().trim().is_empty() { + return Err(RuntimeHookRegistryBuildError::EmptyHookId.into()); + } + if plan.timeout_millis() == 0 { + return Err(RuntimeHookRegistryBuildError::InvalidTimeoutMillis { + hook_id: plan.id().to_string(), + } + .into()); + } + if !ids.insert(plan.id().to_string()) { + return Err(RuntimeHookRegistryBuildError::DuplicateHookId { + hook_id: plan.id().to_string(), + } + .into()); + } + } + Ok(()) +} + +fn plugin_batch_identity( + entries: &[RuntimeHookRegistration], +) -> Result<(String, String, String, String), RuntimeHookRegistryError> { + let mut identity = None::<(String, String, String, String)>; + for entry in entries { + if entry.plan.source() != RuntimeHookSource::OpenCodePlugin { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + } + let HookHandler::Plugin { + instance_id, + generation_key, + revision, + .. + } = &entry.handler + else { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + }; + let Some(workspace_scope) = entry.workspace_scope.as_ref() else { + return Err(RuntimeHookRegistryError::InvalidPluginBatch); + }; + match &identity { + Some((expected_workspace, expected_target, expected_generation, expected_revision)) + if expected_workspace != workspace_scope + || expected_target != instance_id + || expected_generation != generation_key + || expected_revision != revision => + { + return Err(RuntimeHookRegistryError::InvalidPluginBatch) + } + None => { + identity = Some(( + workspace_scope.clone(), + instance_id.clone(), + generation_key.clone(), + revision.clone(), + )) + } + Some(_) => {} + } + } + identity.ok_or(RuntimeHookRegistryError::InvalidPluginBatch) +} + +fn rebuild_entries( + map: &mut BTreeMap>, + entries: Vec, +) { + map.clear(); + let mut by_kind = BTreeMap::>::new(); + for entry in entries { + by_kind + .entry(entry.plan.kind().clone()) + .or_default() + .push(entry); + } + for (kind, mut entries) in by_kind { + entries.sort_by(|left, right| { + left.plan + .source() + .cmp(&right.plan.source()) + .then_with(|| left.plan.order().cmp(&right.plan.order())) + .then_with(|| left.plan.id().cmp(right.plan.id())) + }); + map.insert(kind, Arc::from(entries)); + } +} diff --git a/src/crates/execution/agent-runtime/src/native_hooks/settings.rs b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs index 5f5d185c9e..add2790ec5 100644 --- a/src/crates/execution/agent-runtime/src/native_hooks/settings.rs +++ b/src/crates/execution/agent-runtime/src/native_hooks/settings.rs @@ -412,6 +412,39 @@ impl AgentHookSettings { .map(|rule| rule.handlers.len()) .sum() } + + /// Converts parsed command handlers into the executable registrations used + /// by the shared runtime registry. Parsing remains independent from + /// registry publication so callers can inspect issues before publishing. + pub fn registrations(&self) -> Vec { + let mut registrations = Vec::new(); + for (event, rules) in &self.rules { + for (rule_index, rule) in rules.iter().enumerate() { + let source = match rule.scope { + AgentHookScope::User => crate::native_hooks::RuntimeHookSource::UserCommand, + AgentHookScope::Project => { + crate::native_hooks::RuntimeHookSource::ProjectCommand + } + }; + for (handler_index, handler) in rule.handlers.iter().enumerate() { + let id = format!( + "command.{}.{}.{}", + event.as_str(), + rule_index, + handler_index + ); + registrations.push(crate::native_hooks::RuntimeHookRegistration::command( + id, + crate::native_hooks::RuntimeHookKind::Lifecycle(*event), + source, + handler.clone(), + rule.matcher.clone(), + )); + } + } + } + registrations + } } fn parse_layer( diff --git a/src/crates/execution/agent-runtime/src/post_call_hooks.rs b/src/crates/execution/agent-runtime/src/post_call_hooks.rs index a8ae2ac199..e057b743ed 100644 --- a/src/crates/execution/agent-runtime/src/post_call_hooks.rs +++ b/src/crates/execution/agent-runtime/src/post_call_hooks.rs @@ -1,149 +1,13 @@ //! Portable post-call hook routing decisions. +pub use crate::native_hooks::{ + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, + RuntimeHookRegistryBuildError, RuntimeHookRegistryBuilder, +}; use serde_json::Value; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; -/// Hook categories that concrete runtime integrations may execute after a -/// successful tool call. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -pub enum RuntimeHookKind { - SuccessfulToolPostCall, - DeepReviewSharedContextToolUse, -} - -pub const fn successful_tool_post_call_hooks() -> [RuntimeHookKind; 1] { - [RuntimeHookKind::DeepReviewSharedContextToolUse] -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum RuntimeHookErrorPolicy { - FailTurn, - SkipHook, - DenyTool, - RecordWarning, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeHookPlan { - id: String, - kind: RuntimeHookKind, - order: u16, - timeout_millis: u64, - error_policy: RuntimeHookErrorPolicy, -} - -impl RuntimeHookPlan { - pub fn new(id: impl Into, kind: RuntimeHookKind) -> Self { - Self { - id: id.into(), - kind, - order: 100, - timeout_millis: 1_000, - error_policy: RuntimeHookErrorPolicy::RecordWarning, - } - } - - pub fn with_order(mut self, order: u16) -> Self { - self.order = order; - self - } - - pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self { - self.timeout_millis = timeout_millis; - self - } - - pub fn with_error_policy(mut self, error_policy: RuntimeHookErrorPolicy) -> Self { - self.error_policy = error_policy; - self - } - - pub fn id(&self) -> &str { - &self.id - } - - pub const fn kind(&self) -> RuntimeHookKind { - self.kind - } - - pub const fn order(&self) -> u16 { - self.order - } - - pub const fn timeout_millis(&self) -> u64 { - self.timeout_millis - } - - pub const fn error_policy(&self) -> RuntimeHookErrorPolicy { - self.error_policy - } -} - -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -pub enum RuntimeHookRegistryBuildError { - #[error("runtime hook id must not be empty")] - EmptyHookId, - #[error("runtime hook {hook_id} must declare a non-zero timeout")] - InvalidTimeoutMillis { hook_id: String }, - #[error("duplicate runtime hook id {hook_id}")] - DuplicateHookId { hook_id: String }, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeHookRegistryBuilder { - hooks: Vec, -} - -impl RuntimeHookRegistryBuilder { - pub fn register(mut self, hook: RuntimeHookPlan) -> Self { - self.hooks.push(hook); - self - } - - pub fn build(mut self) -> Result { - let mut hook_ids = HashSet::new(); - for hook in &self.hooks { - if hook.id.trim().is_empty() { - return Err(RuntimeHookRegistryBuildError::EmptyHookId); - } - if hook.timeout_millis == 0 { - return Err(RuntimeHookRegistryBuildError::InvalidTimeoutMillis { - hook_id: hook.id.clone(), - }); - } - if !hook_ids.insert(hook.id.clone()) { - return Err(RuntimeHookRegistryBuildError::DuplicateHookId { - hook_id: hook.id.clone(), - }); - } - } - self.hooks.sort_by(|left, right| { - left.order - .cmp(&right.order) - .then_with(|| left.id.cmp(&right.id)) - }); - Ok(RuntimeHookRegistry { hooks: self.hooks }) - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct RuntimeHookRegistry { - hooks: Vec, -} - -impl RuntimeHookRegistry { - pub fn builder() -> RuntimeHookRegistryBuilder { - RuntimeHookRegistryBuilder::default() - } - - pub fn hooks(&self) -> &[RuntimeHookPlan] { - &self.hooks - } -} - pub trait SuccessfulToolPostCallHookExecutor { fn record_deep_review_shared_context_tool_use( &mut self, @@ -161,14 +25,7 @@ pub fn run_successful_tool_post_call_hooks( ) where E: SuccessfulToolPostCallHookExecutor, { - for hook in successful_tool_post_call_hooks() { - match hook { - RuntimeHookKind::DeepReviewSharedContextToolUse => { - executor.record_deep_review_shared_context_tool_use(tool_name, input, context); - } - RuntimeHookKind::SuccessfulToolPostCall => {} - } - } + executor.record_deep_review_shared_context_tool_use(tool_name, input, context); } #[derive(Debug, Clone, Copy)] diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 9a5b27ce99..26afef789e 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -1813,6 +1813,7 @@ impl AgentRuntime { .create_session(AgentSessionCreateRequest { session_name, agent_type, + agent_route_key: None, workspace_path, project_workspace_path: None, execution_target: None, diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index 94f66f785a..3390a9de56 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -243,6 +243,10 @@ pub struct SessionConfig { /// revalidated for every turn and never falls back by name alone. #[serde(default, skip_serializing_if = "is_local_agent_route_owner")] pub agent_route_owner: SessionAgentRouteOwner, + /// Stable identity of the selected Agent route. This is distinct from a + /// process-local generation key and survives plugin reloads. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_route_key: Option, } fn deserialize_legacy_minimal_agent<'de, D>(deserializer: D) -> Result @@ -293,6 +297,7 @@ impl Default for SessionConfig { model_binding_fingerprint: None, prompt_cache_lineage_id: None, agent_route_owner: SessionAgentRouteOwner::Local, + agent_route_key: None, } } } @@ -475,24 +480,32 @@ mod tests { SessionModelBindingPolicy::Mutable ); assert_eq!(config.agent_route_owner, SessionAgentRouteOwner::Local); + assert!(config.agent_route_key.is_none()); } #[test] fn external_agent_route_owner_persists_and_legacy_sessions_default_local() { let config = SessionConfig { agent_route_owner: SessionAgentRouteOwner::External, + agent_route_key: Some("opencode:plugin:build".to_string()), ..SessionConfig::default() }; let mut serialized = serde_json::to_value(&config).expect("serialize session config"); assert_eq!(serialized["agent_route_owner"], "external"); + assert_eq!(serialized["agent_route_key"], "opencode:plugin:build"); serialized .as_object_mut() .expect("session config object") .remove("agent_route_owner"); + serialized + .as_object_mut() + .expect("session config object") + .remove("agent_route_key"); let restored: SessionConfig = serde_json::from_value(serialized).expect("deserialize legacy session config"); assert_eq!(restored.agent_route_owner, SessionAgentRouteOwner::Local); + assert!(restored.agent_route_key.is_none()); } #[test] diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs index 7667b7ecf7..0aaa2d0735 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts.rs @@ -1,8 +1,11 @@ //! Permission, question, and hook interaction contracts. -#![cfg(feature = "agent-runtime")] #[path = "agent_interaction_contracts/native_hook_payload_contracts.rs"] mod native_hook_payload_contracts; +#[path = "agent_interaction_contracts/native_hook_registry_contracts.rs"] +mod native_hook_registry_contracts; +#[path = "agent_interaction_contracts/native_hook_settings_contracts.rs"] +mod native_hook_settings_contracts; #[path = "agent_interaction_contracts/permission_contracts.rs"] mod permission_contracts; #[path = "agent_interaction_contracts/post_call_hook_contracts.rs"] diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs new file mode 100644 index 0000000000..7e2969a296 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/native_hook_registry_contracts.rs @@ -0,0 +1,265 @@ +use async_trait::async_trait; +use bitfun_agent_runtime::native_hooks::{ + AgentHookEngine, AgentHookMatcher, PluginHookCall, PluginHookExecutor, + PluginHookGenerationIdentity, PluginHookResult, RuntimeHookActivation, RuntimeHookKind, + RuntimeHookPlan, RuntimeHookRegistration, RuntimeHookRegistry, RuntimeHookSource, +}; +use std::sync::{Arc, Mutex}; + +#[derive(Default)] +struct FakePluginExecutor { + calls: Mutex>, +} + +#[async_trait] +impl PluginHookExecutor for FakePluginExecutor { + async fn execute(&self, mut call: PluginHookCall) -> Result { + self.calls.lock().unwrap().push(call.clone()); + call.output + .as_array_mut() + .expect("array output") + .push(serde_json::Value::String(call.instance_id.clone())); + Ok(PluginHookResult { + instance_id: call.instance_id, + generation_key: call.generation_key, + revision: call.revision, + hook_name: call.hook_name, + input: call.input, + output: call.output, + }) + } +} + +fn plugin_registration( + executor: Arc, + id: &str, + instance_id: &str, +) -> RuntimeHookRegistration { + plugin_registration_for_generation(executor, id, instance_id, "generation-1", "rev-1") +} + +fn plugin_registration_for_generation( + executor: Arc, + id: &str, + instance_id: &str, + generation_key: &str, + revision: &str, +) -> RuntimeHookRegistration { + RuntimeHookRegistration::plugin( + RuntimeHookPlan::new( + id, + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + RuntimeHookSource::OpenCodePlugin, + ), + "tool.execute.before", + instance_id, + generation_key, + revision, + executor, + AgentHookMatcher::Any, + ) + .with_workspace_scope("C:/workspace") +} + +#[test] +fn activation_gate_hides_plugin_snapshot_until_ready() { + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![plugin_registration( + Arc::new(FakePluginExecutor::default()), + "hook.a", + "plugin-a", + )]) + .unwrap(); + registry.set_source_activation( + RuntimeHookSource::OpenCodePlugin, + RuntimeHookActivation::Preparing, + ); + assert!(registry + .registrations_for_workspace( + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + Some("C:/workspace"), + ) + .is_empty()); + registry.activate_plugin_batch("C:/workspace", Some(&token)); + assert_eq!( + registry + .registrations_for_workspace( + RuntimeHookKind::PluginHook("tool.execute.before".to_string()), + Some("C:/workspace"), + ) + .len(), + 1 + ); +} + +#[test] +fn plugin_batch_commit_token_rolls_back_only_its_instance() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook.a", + "plugin-a", + )]) + .unwrap(); + registry + .register_plugin_batch(vec![plugin_registration(executor, "hook.b", "plugin-b")]) + .unwrap(); + + registry.rollback_plugin_batch(&token); + + assert_eq!( + registry + .plans() + .iter() + .map(|plan| plan.id()) + .collect::>(), + vec!["hook.b"] + ); +} + +#[tokio::test] +async fn plugin_dispatch_invokes_each_handler_and_carries_output_forward() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token = registry + .register_plugin_batch(vec![ + plugin_registration(executor.clone(), "hook-a", "plugin-a"), + plugin_registration(executor.clone(), "hook-b", "plugin-a"), + plugin_registration(executor.clone(), "hook-c", "plugin-a"), + ]) + .unwrap(); + registry.activate_plugin_batch("C:/workspace", Some(&token)); + + let result = AgentHookEngine::with_registry(registry) + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({"tool": "read"}), + serde_json::json!([]), + ) + .await; + + assert_eq!(result.executed_handlers, 3); + assert!(result.warnings.is_empty()); + assert_eq!( + result.output, + serde_json::json!(["plugin-a", "plugin-a", "plugin-a"]) + ); + let calls = executor.calls.lock().unwrap(); + assert_eq!(calls.len(), 3); + assert_eq!(calls[1].output, serde_json::json!(["plugin-a"])); + assert_eq!(calls[2].output, serde_json::json!(["plugin-a", "plugin-a"])); +} + +#[tokio::test] +async fn plugin_dispatch_isolated_by_canonical_workspace_scope() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let token_a = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook-a", + "plugin-a", + )]) + .unwrap(); + let token_b = registry + .register_plugin_batch(vec![plugin_registration( + executor.clone(), + "hook-b", + "plugin-b", + ) + .with_workspace_scope("D:/workspace")]) + .unwrap(); + registry.activate_plugin_batch("C:/workspace", Some(&token_a)); + registry.activate_plugin_batch("D:/workspace", Some(&token_b)); + + let engine = AgentHookEngine::with_registry(registry); + let a = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + let b = engine + .dispatch_plugin_hook( + Some("D:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + + assert_eq!(a.executed_handlers, 1); + assert_eq!(a.output, serde_json::json!(["plugin-a"])); + assert_eq!(b.executed_handlers, 1); + assert_eq!(b.output, serde_json::json!(["plugin-b"])); +} + +#[tokio::test] +async fn plugin_dispatch_uses_only_the_active_generation() { + let executor = Arc::new(FakePluginExecutor::default()); + let registry = RuntimeHookRegistry::default(); + let old_token = registry + .register_plugin_batch(vec![plugin_registration_for_generation( + executor.clone(), + "hook-old", + "plugin-old", + "generation-old", + "rev-old", + )]) + .unwrap(); + let new_token = registry + .register_plugin_batch(vec![plugin_registration_for_generation( + executor, + "hook-new", + "plugin-new", + "generation-new", + "rev-new", + )]) + .unwrap(); + + registry.activate_plugin_batch("C:/workspace", Some(&old_token)); + let engine = AgentHookEngine::with_registry(registry.clone()); + let old = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(old.output, serde_json::json!(["plugin-old"])); + + registry.activate_plugin_batch("C:/workspace", Some(&new_token)); + let old_generation = PluginHookGenerationIdentity { + instance_id: "plugin-old".to_string(), + generation_key: "generation-old".to_string(), + revision: "rev-old".to_string(), + }; + let old_turn = engine + .dispatch_plugin_hook_for_generation( + Some("C:/workspace"), + Some(&old_generation), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(old_turn.output, serde_json::json!(["plugin-old"])); + + registry.rollback_plugin_batch(&old_token); + let new = engine + .dispatch_plugin_hook( + Some("C:/workspace"), + "tool.execute.before", + serde_json::json!({}), + serde_json::json!([]), + ) + .await; + assert_eq!(new.output, serde_json::json!(["plugin-new"])); +} diff --git a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs index bbdefba3e2..571cdc140d 100644 --- a/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_interaction_contracts/post_call_hook_contracts.rs @@ -1,48 +1,90 @@ -use bitfun_agent_runtime::post_call_hooks::{ - successful_tool_post_call_hooks, RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, - RuntimeHookRegistry, RuntimeHookRegistryBuildError, +use async_trait::async_trait; +use bitfun_agent_runtime::native_hooks::{ + AgentHookMatcher, BuiltinHookExecutor, HookCall, HookHandler, HookHandlerResult, + RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistration, + RuntimeHookRegistry, RuntimeHookRegistryBuildError, RuntimeHookSource, }; +use std::sync::Arc; + +struct NoopBuiltin; + +#[async_trait] +impl BuiltinHookExecutor for NoopBuiltin { + async fn execute(&self, _call: &HookCall) -> HookHandlerResult { + Default::default() + } +} + +fn builtin(plan: RuntimeHookPlan) -> RuntimeHookRegistration { + RuntimeHookRegistration::new( + plan, + HookHandler::Builtin { + executor: Arc::new(NoopBuiltin), + }, + AgentHookMatcher::Any, + ) +} + +fn plan(id: &str, source: RuntimeHookSource) -> RuntimeHookPlan { + RuntimeHookPlan::new(id, RuntimeHookKind::SuccessfulToolPostCall, source) +} #[test] -fn successful_tool_call_routes_to_shared_context_measurement_hook() { +fn successful_tool_call_uses_stable_builtin_registration_id() { + let registry = RuntimeHookRegistry::builder() + .register(builtin(plan( + "deep-review.shared-context", + RuntimeHookSource::Builtin { priority: 0 }, + ))) + .build() + .expect("builtin registration should build"); + + assert_eq!(registry.plans()[0].id(), "deep-review.shared-context"); assert_eq!( - successful_tool_post_call_hooks(), - [RuntimeHookKind::DeepReviewSharedContextToolUse] + registry.plans()[0].kind(), + &RuntimeHookKind::SuccessfulToolPostCall ); } #[test] -fn runtime_hook_registry_preserves_order_timeout_and_error_policy() { +fn runtime_hook_registry_preserves_source_order_timeout_and_error_policy() { let registry = RuntimeHookRegistry::builder() - .register( - RuntimeHookPlan::new( + .register(builtin( + plan("project.post-call", RuntimeHookSource::ProjectCommand) + .with_order(20) + .with_timeout_millis(750), + )) + .register(builtin( + plan( "deep-review.shared-context", - RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookSource::Builtin { priority: 0 }, ) - .with_order(20) - .with_timeout_millis(750) - .with_error_policy(RuntimeHookErrorPolicy::RecordWarning), - ) - .register( - RuntimeHookPlan::new("audit.post-call", RuntimeHookKind::SuccessfulToolPostCall) + .with_order(20), + )) + .register(builtin( + plan("user.post-call", RuntimeHookSource::UserCommand) .with_order(10) .with_timeout_millis(250) .with_error_policy(RuntimeHookErrorPolicy::SkipHook), - ) + )) .build() .expect("hook registry should build"); assert_eq!( registry - .hooks() + .plans() .iter() .map(|hook| hook.id()) .collect::>(), - vec!["audit.post-call", "deep-review.shared-context"] + vec![ + "deep-review.shared-context", + "user.post-call", + "project.post-call" + ] ); - assert_eq!(registry.hooks()[0].timeout_millis(), 250); + assert_eq!(registry.plans()[1].timeout_millis(), 250); assert_eq!( - registry.hooks()[0].error_policy(), + registry.plans()[1].error_policy(), RuntimeHookErrorPolicy::SkipHook ); } @@ -50,14 +92,11 @@ fn runtime_hook_registry_preserves_order_timeout_and_error_policy() { #[test] fn runtime_hook_registry_rejects_duplicate_ids() { let error = RuntimeHookRegistry::builder() - .register(RuntimeHookPlan::new( - "duplicate", - RuntimeHookKind::SuccessfulToolPostCall, - )) - .register(RuntimeHookPlan::new( + .register(builtin(plan( "duplicate", - RuntimeHookKind::DeepReviewSharedContextToolUse, - )) + RuntimeHookSource::Builtin { priority: 0 }, + ))) + .register(builtin(plan("duplicate", RuntimeHookSource::UserCommand))) .build() .expect_err("duplicate hook ids must not be silently accepted"); @@ -72,26 +111,24 @@ fn runtime_hook_registry_rejects_duplicate_ids() { #[test] fn runtime_hook_registry_rejects_unstable_ids_and_zero_timeouts() { let empty_id_error = RuntimeHookRegistry::builder() - .register(RuntimeHookPlan::new( + .register(builtin(plan( " ", - RuntimeHookKind::SuccessfulToolPostCall, - )) + RuntimeHookSource::Builtin { priority: 0 }, + ))) .build() .expect_err("blank hook ids must not become registry keys"); - assert_eq!(empty_id_error, RuntimeHookRegistryBuildError::EmptyHookId); let zero_timeout_error = RuntimeHookRegistry::builder() - .register( - RuntimeHookPlan::new( + .register(builtin( + plan( "deep-review.shared-context", - RuntimeHookKind::DeepReviewSharedContextToolUse, + RuntimeHookSource::Builtin { priority: 0 }, ) .with_timeout_millis(0), - ) + )) .build() .expect_err("hook timeouts must remain explicit and non-zero"); - assert_eq!( zero_timeout_error, RuntimeHookRegistryBuildError::InvalidTimeoutMillis { diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs index 5b19081d37..127fb4ddd6 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/sdk_smoke.rs @@ -63,6 +63,7 @@ impl AgentModeCatalogPort for FakeModeCatalog { self.queries.lock().unwrap().push(query); Ok(vec![AgentModeCatalogEntry { id: "Explore".to_string(), + route_key: "Explore".to_string(), description: "Inspect the workspace".to_string(), model_id: Some("model-a".to_string()), is_external: false, diff --git a/src/crates/interfaces/acp/src/runtime/session.rs b/src/crates/interfaces/acp/src/runtime/session.rs index 9488411c6b..2301e00d01 100644 --- a/src/crates/interfaces/acp/src/runtime/session.rs +++ b/src/crates/interfaces/acp/src/runtime/session.rs @@ -57,6 +57,7 @@ impl BitfunAcpRuntime { chrono::Local::now().format("%Y-%m-%d %H:%M:%S") ), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: Some(cwd.clone()), project_workspace_path: None, execution_target: None, @@ -510,6 +511,7 @@ impl BitfunAcpRuntime { .update_session_mode(AgentSessionModeUpdateRequest { session_id: session.bitfun_session_id.clone(), mode_id: mode_id.to_string(), + agent_route_key: None, }) .await .map_err(|error| Self::session_runtime_error(&session.acp_session_id, error))?; diff --git a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs index 52bf602364..200ced16d0 100644 --- a/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs +++ b/src/crates/interfaces/app-server-protocol/src/schemas/agent.rs @@ -42,6 +42,8 @@ pub struct ListAgentModesResponse { #[serde(rename_all = "camelCase")] pub struct AgentModeSummary { pub id: String, + #[serde(default)] + pub route_key: String, pub description: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, diff --git a/src/crates/interfaces/app-server/src/management/owner.rs b/src/crates/interfaces/app-server/src/management/owner.rs index 6cb2a8f45b..52dad2b336 100644 --- a/src/crates/interfaces/app-server/src/management/owner.rs +++ b/src/crates/interfaces/app-server/src/management/owner.rs @@ -1184,6 +1184,7 @@ impl AppManagementService { .into_iter() .map(|mode| AgentModeSummary { id: mode.id, + route_key: mode.key, description: mode.description, model_id: mode.model, is_external: mode.source == bitfun_core::agentic::agents::AgentSource::External, diff --git a/src/crates/interfaces/app-server/tests/agent_kernel.rs b/src/crates/interfaces/app-server/tests/agent_kernel.rs index c34bd11fe5..8ce00514e8 100644 --- a/src/crates/interfaces/app-server/tests/agent_kernel.rs +++ b/src/crates/interfaces/app-server/tests/agent_kernel.rs @@ -301,6 +301,7 @@ struct Phase2Provider { steers: Mutex>, shell_commands: Mutex>, answers: Mutex>, + local_commands: Mutex>, compactions: Mutex>, settlements: Mutex>, reloads: Mutex>, @@ -467,6 +468,7 @@ impl ports::AgentLocalCommandTurnPort for Phase2Provider { &self, request: ports::AgentLocalCommandTurnRecordRequest, ) -> PortResult { + self.local_commands.lock().unwrap().push(request.clone()); Ok(ports::AgentLocalCommandTurnRecordResult { turn_id: request .turn_id @@ -648,11 +650,6 @@ fn revert_result(session_id: String, text: &str) -> ports::AgentSessionRevertRes retired_turn_ids: vec!["turn-active".to_string()], changed: true, hidden_turn_count: 1, - boundary_storage_turn_index: None, - target_turn_id: None, - restored_files: Vec::new(), - reload_required: false, - reload_reason: None, } } @@ -836,8 +833,6 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { turn_id: "turn-active".to_string(), content: "keep going".to_string(), display_content: None, - attachments: Vec::new(), - metadata: serde_json::Map::new(), }, )) .await @@ -863,6 +858,20 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { }) .await .expect("submit user answers"); + let local_turn = client + .record_local_command_turn(protocol_session::RecordLocalCommandTurnRequest( + ports::AgentLocalCommandTurnRecordRequest { + session_id: "session-1".to_string(), + content: "usage: 12 tokens".to_string(), + turn_id: Some("local-turn".to_string()), + timestamp_ms: Some(100), + metadata: serde_json::Map::new(), + }, + )) + .await + .expect("record local command turn"); + assert_eq!(local_turn.0.turn_id, "local-turn"); + client .compact_session(protocol_session::CompactSessionRequest( ports::AgentSessionCompactionRequest { @@ -911,6 +920,7 @@ async fn phase2_mutations_route_through_runtime_owner_ports() { "cargo test" ); assert_eq!(provider.answers.lock().unwrap().len(), 1); + assert_eq!(provider.local_commands.lock().unwrap().len(), 1); assert_eq!(provider.compactions.lock().unwrap().len(), 1); assert_eq!(provider.reloads.lock().unwrap().len(), 1); client.shutdown().await; @@ -1192,6 +1202,7 @@ async fn session_control_methods_forward_exact_owner_dtos() { UpdateSessionModeMessage(AgentSessionModeUpdateRequest { session_id: "session-1".to_string(), mode_id: "plan".to_string(), + agent_route_key: None, }), )) .await?; @@ -1381,6 +1392,7 @@ async fn create_session_returns_provider_session_id() { AgentSessionCreateRequest { session_name: "direct create".to_string(), agent_type: "agentic".to_string(), + agent_route_key: None, workspace_path: None, project_workspace_path: None, execution_target: None, diff --git a/src/crates/interfaces/sdk-host/src/host.rs b/src/crates/interfaces/sdk-host/src/host.rs index 9f359b2d3e..fb670d1e98 100644 --- a/src/crates/interfaces/sdk-host/src/host.rs +++ b/src/crates/interfaces/sdk-host/src/host.rs @@ -1006,6 +1006,7 @@ impl SdkHostConnection { .session_name .unwrap_or_else(|| DEFAULT_SESSION_NAME.to_string()), agent_type: params.agent.unwrap_or_else(|| DEFAULT_AGENT.to_string()), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: None, execution_target: None, @@ -1211,6 +1212,7 @@ impl SdkHostConnection { .agent .clone() .unwrap_or_else(|| DEFAULT_AGENT.to_string()), + agent_route_key: None, workspace_path: Some(workspace_path.clone()), project_workspace_path: None, execution_target: None, diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 4b49761982..75573bc0eb 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -126,6 +126,7 @@ pub fn build_remote_session_create_request( AgentSessionCreateRequest { session_name: session_name.into(), agent_type: agent_type.into(), + agent_route_key: None, workspace_path: workspace_path.map(Into::into), project_workspace_path: None, execution_target: None,