From 54e4a725e1fa3ac579d8942087ff28b0a5e48c9e Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 3 Jul 2026 17:09:47 +0800 Subject: [PATCH] refactor: establish tool and event ABI contracts --- docs/plans/core-decomposition-completed.md | 5 +- docs/plans/core-decomposition-plan.md | 38 +- .../rules/source/forbidden-rules.mjs | 5 + .../rules/source/required-rules.mjs | 66 ++++ scripts/core-boundaries/self-test.mjs | 23 ++ .../transport/src/adapters/websocket.rs | 22 +- .../core/src/agentic/tools/tool_adapter.rs | 12 + .../events/src/agentic_projection_manifest.rs | 359 ++++++++++++++++++ src/crates/contracts/events/src/lib.rs | 8 + .../execution/tool-contracts/src/framework.rs | 111 ++++-- .../execution/tool-contracts/src/lib.rs | 6 + .../tool-contracts/src/tool_snapshot.rs | 263 +++++++++++++ .../tool-contracts/tests/tool_contracts.rs | 233 +++++++++++- 13 files changed, 1065 insertions(+), 86 deletions(-) create mode 100644 src/crates/contracts/events/src/agentic_projection_manifest.rs create mode 100644 src/crates/execution/tool-contracts/src/tool_snapshot.rs diff --git a/docs/plans/core-decomposition-completed.md b/docs/plans/core-decomposition-completed.md index 6bfa33727b..5234c3d27a 100644 --- a/docs/plans/core-decomposition-completed.md +++ b/docs/plans/core-decomposition-completed.md @@ -18,9 +18,9 @@ - `services-core` 已承接 session layout、metadata store CRUD / index rebuild、metadata pagination、metadata construction / mutation、lineage / branch shaping、JSON file store、filesystem primitives、managed runtime command resolution / PATH merge、LSP plugin registry / extension matching / command-target mapping、diagnostic redaction、session usage/token usage 基础服务。 - `services-core` 已承接 workspace-runtime legacy session-store merge、metadata 冲突选择、index rebuild 和 legacy path copy/move fallback;core workspace-runtime 只保留路径计算、runtime layout ensure 和错误兼容映射。 - `runtime-services` 已承接 typed runtime service assembly、capability availability、provider registry、capability validation、无副作用 capability marker ports 和 backend event delivery;core backend event system 只保留兼容 re-export。 -- `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 +- `bitfun-events` 已承接 backend event DTO、agentic event DTO、framework-neutral Agentic frontend event projection、AgenticEvent projection manifest、event version / aggregate / replay / retention facts、legacy WebSocket event allowlist 和 platform-neutral `EventEmitter` trait;Tauri/WebSocket transport 只负责 delivery。 - `services-integrations` 已承接 remote-connect primitives、wire command routing / response assembly、LAN IP/URL 探测、ngrok 进程/tunnel lifecycle、mobile-web relay upload manifest / incremental upload / fallback upload、IM bot provider-neutral config / persistence / file auto-push / locale / menu / state / command parsing、Weixin provider client、workspace search concrete owner、remote SSH/SFTP/PTY owner、Remote SSH disabled runtime surface、Remote SSH workspace/session identity helper、remote workspace-search disabled surface、DeepResearch report IO / display-map sidecar、MiniApp host dispatch / storage / worker / import IO、announcement remote fetch/cache、browser CDP endpoint HTTP probing / page creation、WebFetch / WebSearch concrete HTTP provider、debug-log HTTP ingest posting、review-platform HTTP transport,以及 MCP server registry、connection pool、catalog cache、reconnect retry state、runtime-only config overlay、local command resolution helper 和 lifecycle status policy。 -- `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy 和 provider-entry materialization;core 只保留 Computer Use 旧 public path re-export / compatibility shim 与产品执行入口。 +- `tool-contracts` 已承接 provider-neutral tool DTO、manifest/catalog/admission/result presentation、Computer Use DTO/input parser/screenshot payload、confirmation facts、truncation recovery presentation、runtime restriction policy、provider-entry materialization、materialized tool snapshot、provider identity、permission/effect filter、cancellation contract 和 stale-call guard;core 只保留 Computer Use 旧 public path re-export / compatibility shim、产品 Tool trait 适配与产品执行入口。 - `tool-execution` 已承接 local / remote IO helper、Bash shell helper、batching plan、retry policy、state counting、tool state event payload shaping / result redaction、cancellation-state/token-store policy、background exec output capture、ExecCommand provider-neutral 呈现 / 输入默认值 / 结果 shape / shell metadata / shell argv / remote shell probe / remote env snapshot 解析、cache 与 capture policy / lifecycle facts / control facts / completion shape、prompt-safe tool context facts / custom-data materialization、Computer Use loop detection / screenshot hash / verification / retry policy,以及 File tool 的 provider-neutral 结果展示、写入 mode/status/line-count 规则、Edit guardrail 分类和 Delete success 文本;core 只保留 ToolResult 包装、权限、checkpoint、runtime handles、host adapter 调用、read-state adapter、remote FS 调用和旧工具入口。 - `runtime-ports` / `terminal-core` / `services-integrations` 已承接 ExecCommand 会话执行端口和 concrete provider:`TerminalPort` 暴露本地命令执行、stdin 写入、会话控制和生命周期事件边界,`RemoteExecPort` 暴露远端 SSH 命令执行、bounded one-shot command、stdin、会话控制和生命周期事件边界;`TerminalRuntimePort` 复用原本地 `ExecProcessManager` 行为,`RemoteExecRuntimePort` 复用原 remote exec manager 与旧 SSH one-shot 行为,当前 desktop / CLI 产品入口和保留 server bootstrap 初始化路径通过 `CoreRuntimeServicesProvider` 构造 provider 并显式注入 `ConversationCoordinator` / 执行上下文 / `ToolRuntimeHandles`;core `ExecCommand` / `WriteStdin` / `ExecControl` 只消费端口,不再直接调用全局本地或远端进程 manager。 - `agent-runtime` 已承接 scheduler/background delivery 纯决策、dialog lifecycle port contracts、runtime event queue/router、session management/cancellation port contracts、session/config/summary facts、persisted session state sidecar / processing-state sanitization、session state facts / event-label projection、session state manager / event emission owner、dialog-turn id / stats facts、side-question runtime-only tracking、thread-goal facts、context profile / model capability policy、prompt markup / prompt / prompt-cache facts 与持久化写入决策、remote file delivery prompt facts、turn skill/agent snapshot DTO/diff/render/store、file-read session state / prior-read guardrail / freshness 决策、session evidence ledger 与 compression-contract projection、dialog-turn cancellation token store、tool confirmation gate / wait channel state、user-question wait channel state、custom agent / mode / subagent schema、默认值、discovery/loading、markdown IO、validation、review 工具过滤、skill catalog/root specs、mode policy、selection/shadow/mode-info 规则、assistant payload rendering、post-call hook routing、DeepReview provider-neutral policy/queue/retry/diagnostics shaping 与 queue event payload shaping、DeepResearch citation renumber 与 report post-process gate,并建立不暴露 `bitfun-core` / `product-full` / concrete manager 的内部 SDK facade。SDK facade 已支持注入 fake runtime services、tool registry、harness registry、hook registry 和 agent registry。 @@ -41,6 +41,7 @@ - focused tests 覆盖当前 delivery profile 能力裁剪、ProductAssembler 缺失 service 报告、无直接 core 入口的空 capability plan、SDK fake provider / services / tool / harness / hook / workspace-scoped agent registry 闭环,以及 runtime hook 顺序、timeout、错误策略和重复 id 拦截。 - focused baseline 覆盖 tool manifest、GetToolSpec、execution admission、workspace search、remote workspace fallback、MCP config/catalog、prompt cache、custom agent / mode / subagent、thread-goal tools、AskUserQuestion、DeepReview policy、tool confirmation、session restore、MiniApp storage/builtin/import、function-agent Git、scheduled-job state 等路径。 - H4 已完成 Agent Runtime SDK 发布准备的 workspace 内收口:`sdk` facade 暴露 v1 preview 兼容元数据、空默认 feature、稳定注入 registry/service 类型、最小外部 embedder 示例,以及 boundary required rules / self-test 保护。 +- Public API / Tool ABI / Event Manifest 基础闭环已建立:Agent Runtime SDK 继续只暴露 preview facade;`bitfun-agent-tools` 暴露 materialized snapshot / default effect facts / stale-call guard;`bitfun-events` 暴露 AgenticEvent projection manifest;core compatibility path 和 WebSocket adapter 不再拥有对应 contract 清单。 ## 4. Adapter 边界与后续专项 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index bec133ab2f..1d099f5c3f 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -22,7 +22,8 @@ - workspace 已按六层物理目录展开:`interfaces -> assembly -> adapters -> services -> execution -> contracts`。 - Runtime Services、Agent Runtime、Tool Contracts、Tool Execution、Harness、Product Domains、Services Core、Services Integrations 等 owner crate 已建立。 - `bitfun-core --no-default-features` 已裁掉多批 concrete provider 和 direct provider 依赖;Desktop、CLI、ACP 仍通过 `bitfun-core/product-full` 获取完整产品能力。 -- Agentic frontend event projection 已进入 `bitfun-events`;Tauri/WebSocket transport 不再内联事件字段映射。 +- Agentic frontend event projection 和 AgenticEvent projection manifest 已进入 `bitfun-events`;Tauri/WebSocket transport 不再内联事件字段映射或 legacy event allowlist。 +- Tool ABI 基础合同已进入 `tool-contracts`:materialized snapshot、provider identity、default permission/effect filter、cancellation contract 和 stale-call guard 由 owner crate 提供,core 只投射现有产品 Tool 元数据。 - Terminal / ExecCommand、remote SSH concrete execution、workspace search、debug ingest、AI provider adapter runtime、browser CDP、WebFetch/WebSearch、review platform transport 等多批 owner 已迁出或收口到 port/provider。 - Boundary scripts 已覆盖核心 owner 防回流、six-layer path 解析、facade-only 文件、custom agent owner / custom subagent wrapper 保护和重点 feature gate。 @@ -30,8 +31,6 @@ | 差距 | 影响 | 收敛要求 | |---|---|---| -| Public API 面仍有 preview、compatibility 和内部导出混用 | SDK、插件和产品入口可能误依赖旧路径 | 标记 stable/workspace-internal/compatibility,删除或封闭旧 core public path | -| Tool ABI、Event Manifest、Permission/Effect 仍未完全成为插件可消费合同 | built-in、MCP、ACP、plugin tool 语义可能分裂 | 建立 materialized snapshot、provider identity、stale guard、public event manifest | | Plugin Runtime Host 仍是设计合同,未形成主进程窄接口和 disabled/projection stub | 主体进程可能继续感知具体 adapter 或运行单元 | 落地 `PluginRuntimeClient`、binding、capability matrix 和 Host facade 白名单 | | UI Extension Contract 与产品形态矩阵仍需实现 | Desktop/Web/CLI/SDK/ACP 的插件 UI 行为可能不一致 | 建立 descriptor round-trip、fallback、unsupported/unavailable 和只读 state view | | OpenCode compatibility adapter 仍缺少真实消费路径 | OpenCode 插件能力无法受控进入 BitFun | 先支持 discovery/read-only,再逐步开放 tool provider 和 UI contribution | @@ -40,39 +39,6 @@ ## 4. 后续大型阶段 -### Stage A:Public API Closure 与 Core Facade 缩面 - -目标:冻结可对外承诺的 API 面,阻止新功能继续绑定旧 core 路径。 - -范围: - -- 梳理 `bitfun-core`、`bitfun-agent-runtime`、`bitfun-runtime-ports`、`bitfun-agent-tools`、`bitfun-runtime-services` 的 public surface。 -- 将导出标为 stable external、workspace-internal、compatibility 三类。 -- 收敛 `bitfun-core::agentic::*`、`bitfun-core::service::*` 等新调用方路径。 -- 将 Agent Runtime SDK-facing facade 与内部模块导出分开。 -- 对仍需保留的 compatibility re-export 增加 owner 注释和 boundary guard。 - -准出: - -- 至少删除、迁移或显著简化一组旧 core public path。 -- `cargo check -p bitfun-core --no-default-features`、`cargo test -p bitfun-agent-runtime`、boundary self-test 和 `git diff --check` 通过。 - -### Stage B:Tool ABI、Event Manifest 与 Security Control Plane - -目标:让 built-in tool、MCP tool、ACP external tool、plugin tool 和 hook 使用同一能力/效果语义。 - -范围: - -- 建立 materialized tool snapshot、provider identity、permission/effect filter、stale call guard 和 cancellation contract。 -- 建立 public event manifest、event version、aggregate identity、replay/retention 和 UI projection contract。 -- 将 collapsed tool、MiniApp headless restriction、delegation restriction 等产品策略改为 decorator / policy provider。 -- 收敛 core 中的 tool manifest、tool result presentation 和部分产品文案旧路径。 - -准出: - -- prompt-visible manifest、`GetToolSpec`、expanded/collapsed exposure、MCP/ACP catalog、remote/local path containment 和 public event projection 行为等价。 -- tool、MCP/ACP、event manifest、product-full 和 boundary focused checks 通过。 - ### Stage C:Plugin Runtime Host Foundation 目标:建立主体进程的插件运行时窄接口,并将 OpenCode 等生态适配器限制在 Host 内部。 diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 85b21a8fd7..b6d7ccc374 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -19,6 +19,11 @@ export const forbiddenContentRules = [ message: 'WebSocket transport adapter must not match agentic event variants directly; use bitfun-events frontend projection', }, + { + regex: /\bfn\s+is_legacy_websocket_agentic_event_type\b/, + message: + 'WebSocket transport adapter must not own the agentic event allowlist; use bitfun-events event manifest', + }, ], }, { diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 60049b1971..6d1225382b 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -32,6 +32,29 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/contracts/events/src/agentic_projection_manifest.rs', + reason: + 'events contract must own the public AgenticEvent projection manifest, event versions, aggregate classification, and legacy transport allowlist', + patterns: [ + { + regex: /\bpub const AGENTIC_EVENT_PROJECTION_MANIFEST\b/, + message: 'missing public AgenticEvent projection manifest', + }, + { + regex: /\bpub fn public_agentic_event_projection_manifest\b/, + message: 'missing public AgenticEvent projection manifest accessor', + }, + { + regex: /\bpub fn is_legacy_websocket_agentic_event_type\b/, + message: 'missing manifest-owned legacy WebSocket event allowlist helper', + }, + { + regex: /\bpublic_event_projection_manifest_describes_projected_events_and_websocket_allowlist\b/, + message: 'missing public projection manifest regression test', + }, + ], + }, { path: 'src/crates/adapters/transport/src/adapters/tauri.rs', reason: @@ -6706,6 +6729,10 @@ export const requiredContentRules = [ regex: /\bpub struct ToolRuntimeAssembly\b/, message: 'missing generic runtime assembly contract', }, + { + regex: /\bpub async fn materialized_tool_snapshot\b/, + message: 'missing registry materialized snapshot accessor', + }, { regex: /\bpub type ToolDecoratorRef\b/, message: 'missing generic decorator ref contract', @@ -6768,6 +6795,45 @@ export const requiredContentRules = [ }, ], }, + { + path: 'src/crates/execution/tool-contracts/src/tool_snapshot.rs', + reason: + 'agent-tools must own materialized tool snapshots, provider identity, effect facts, cancellation facts, and stale-call guards', + patterns: [ + { + regex: /\bpub struct MaterializedToolSnapshot\b/, + message: 'missing materialized tool snapshot contract', + }, + { + regex: /\bpub struct ToolProviderIdentity\b/, + message: 'missing provider-neutral tool identity contract', + }, + { + regex: /\bpub struct ToolEffectFacts\b/, + message: 'missing provider-neutral tool effect facts contract', + }, + { + regex: /\bpub enum ToolEffectFactsSource\b/, + message: 'missing tool effect facts source contract', + }, + { + regex: /\bpub struct ToolEffectFilter\b/, + message: 'missing provider-neutral tool effect filter contract', + }, + { + regex: /\bpub struct ToolCallSnapshotGuard\b/, + message: 'missing stale-call snapshot guard contract', + }, + { + regex: /\bpub enum ToolSnapshotCallError\b/, + message: 'missing stale-call snapshot error contract', + }, + { + regex: /\bpub async fn materialize_tool_snapshot\b/, + message: 'missing materialized tool snapshot builder', + }, + ], + }, { path: 'src/crates/execution/tool-provider-groups/src/lib.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 6726bd2c95..855d6ee30c 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -1220,6 +1220,15 @@ export function runManifestParserSelfTest({ 'legacy_flat_dialog_turn_started_preserves_existing_shape', ], }, + { + path: 'src/crates/contracts/events/src/agentic_projection_manifest.rs', + contracts: [ + 'AGENTIC_EVENT_PROJECTION_MANIFEST', + 'public_agentic_event_projection_manifest', + 'is_legacy_websocket_agentic_event_type', + 'public_event_projection_manifest_describes_projected_events_and_websocket_allowlist', + ], + }, { path: 'src/crates/adapters/transport/src/adapters/tauri.rs', contracts: ['project_agentic_frontend_event', 'projected.event_name.as_str()'], @@ -2630,6 +2639,7 @@ export function runManifestParserSelfTest({ 'StaticToolMaterializationError', 'materialize_static_tool_provider_groups', 'ToolRuntimeAssembly', + 'materialized_tool_snapshot', 'create_registry_from_static_provider_plans', 'create_registry_from_static_provider_entries', 'ToolCatalogRuntime', @@ -2650,6 +2660,19 @@ export function runManifestParserSelfTest({ 'call_results', ], }, + { + path: 'src/crates/execution/tool-contracts/src/tool_snapshot.rs', + contracts: [ + 'MaterializedToolSnapshot', + 'ToolProviderIdentity', + 'ToolEffectFacts', + 'ToolEffectFactsSource', + 'ToolEffectFilter', + 'ToolCallSnapshotGuard', + 'ToolSnapshotCallError', + 'materialize_tool_snapshot', + ], + }, { path: 'src/crates/execution/tool-contracts/src/mcp_tool_bridge.rs', contracts: [ diff --git a/src/crates/adapters/transport/src/adapters/websocket.rs b/src/crates/adapters/transport/src/adapters/websocket.rs index a82a032ee9..b06026e799 100644 --- a/src/crates/adapters/transport/src/adapters/websocket.rs +++ b/src/crates/adapters/transport/src/adapters/websocket.rs @@ -5,7 +5,9 @@ /// event set to text messages. use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; -use bitfun_events::{project_agentic_frontend_event, AgenticEvent}; +use bitfun_events::{ + is_legacy_websocket_agentic_event_type, project_agentic_frontend_event, AgenticEvent, +}; use serde_json::json; use std::fmt; use tokio::sync::mpsc; @@ -44,24 +46,6 @@ impl fmt::Debug for WebSocketTransportAdapter { } } -fn is_legacy_websocket_agentic_event_type(event_type: &str) -> bool { - matches!( - event_type, - "image-analysis-started" - | "image-analysis-completed" - | "dialog-turn-started" - | "subagent-session-linked" - | "model-round-started" - | "text-chunk" - | "tool-event" - | "token-usage-updated" - | "model-round-completed" - | "dialog-turn-completed" - | "deep-review-queue-state-changed" - | "thread-goal-updated" - ) -} - #[async_trait] impl TransportAdapter for WebSocketTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { diff --git a/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs b/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs index 21ac8d1147..e66a6c0acf 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs @@ -37,6 +37,18 @@ impl ToolRegistryItem for dyn Tool { Tool::is_readonly(self) } + fn is_concurrency_safe(&self, input: Option<&Value>) -> bool { + Tool::is_concurrency_safe(self, input) + } + + fn needs_permissions(&self, input: Option<&Value>) -> bool { + Tool::needs_permissions(self, input) + } + + fn manages_own_execution_timeout(&self) -> bool { + Tool::manages_own_execution_timeout(self) + } + async fn is_enabled(&self) -> bool { Tool::is_enabled(self).await } diff --git a/src/crates/contracts/events/src/agentic_projection_manifest.rs b/src/crates/contracts/events/src/agentic_projection_manifest.rs new file mode 100644 index 0000000000..569b928837 --- /dev/null +++ b/src/crates/contracts/events/src/agentic_projection_manifest.rs @@ -0,0 +1,359 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticEventProjectionAggregate { + Session, + Turn, + ModelRound, + Tool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticEventProjectionReplayPolicy { + LiveOnly, + Replayable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticEventProjectionRetentionPolicy { + Ephemeral, + Session, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AgenticEventProjectionUiShape { + LegacyFlat, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgenticEventProjectionManifestEntry { + pub event_name: &'static str, + pub event_type: &'static str, + pub version: u16, + pub aggregate: AgenticEventProjectionAggregate, + pub replay: AgenticEventProjectionReplayPolicy, + pub retention: AgenticEventProjectionRetentionPolicy, + pub ui_shape: AgenticEventProjectionUiShape, + pub legacy_websocket: bool, +} + +const fn projection_manifest_entry( + event_name: &'static str, + event_type: &'static str, + aggregate: AgenticEventProjectionAggregate, + replay: AgenticEventProjectionReplayPolicy, + legacy_websocket: bool, +) -> AgenticEventProjectionManifestEntry { + AgenticEventProjectionManifestEntry { + event_name, + event_type, + version: 1, + aggregate, + replay, + retention: match replay { + AgenticEventProjectionReplayPolicy::LiveOnly => { + AgenticEventProjectionRetentionPolicy::Ephemeral + } + AgenticEventProjectionReplayPolicy::Replayable => { + AgenticEventProjectionRetentionPolicy::Session + } + }, + ui_shape: AgenticEventProjectionUiShape::LegacyFlat, + legacy_websocket, + } +} + +pub const AGENTIC_EVENT_PROJECTION_MANIFEST: &[AgenticEventProjectionManifestEntry] = &[ + projection_manifest_entry( + "agentic://session-created", + "session-created", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://session-deleted", + "session-deleted", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::LiveOnly, + false, + ), + projection_manifest_entry( + "agentic://image-analysis-started", + "image-analysis-started", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + true, + ), + projection_manifest_entry( + "agentic://image-analysis-completed", + "image-analysis-completed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + true, + ), + projection_manifest_entry( + "agentic://dialog-turn-started", + "dialog-turn-started", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://subagent-session-linked", + "subagent-session-linked", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://model-round-started", + "model-round-started", + AgenticEventProjectionAggregate::ModelRound, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://text-chunk", + "text-chunk", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + true, + ), + projection_manifest_entry( + "agentic://tool-event", + "tool-event", + AgenticEventProjectionAggregate::Tool, + AgenticEventProjectionReplayPolicy::LiveOnly, + true, + ), + projection_manifest_entry( + "agentic://dialog-turn-completed", + "dialog-turn-completed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "session_title_generated", + "session_title_generated", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::LiveOnly, + false, + ), + projection_manifest_entry( + "agentic://dialog-turn-cancelled", + "dialog-turn-cancelled", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://dialog-turn-failed", + "dialog-turn-failed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://token-usage-updated", + "token-usage-updated", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://context-compression-started", + "context-compression-started", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + false, + ), + projection_manifest_entry( + "agentic://context-compression-completed", + "context-compression-completed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://context-compression-failed", + "context-compression-failed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://thread-goal-updated", + "thread-goal-updated", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://session-state-changed", + "session-state-changed", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://session-model-auto-migrated", + "session-model-auto-migrated", + AgenticEventProjectionAggregate::Session, + AgenticEventProjectionReplayPolicy::Replayable, + false, + ), + projection_manifest_entry( + "agentic://deep-review-queue-state-changed", + "deep-review-queue-state-changed", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + true, + ), + projection_manifest_entry( + "agentic://model-round-completed", + "model-round-completed", + AgenticEventProjectionAggregate::ModelRound, + AgenticEventProjectionReplayPolicy::Replayable, + true, + ), + projection_manifest_entry( + "agentic://user-steering-injected", + "user-steering-injected", + AgenticEventProjectionAggregate::Turn, + AgenticEventProjectionReplayPolicy::LiveOnly, + false, + ), +]; + +pub fn public_agentic_event_projection_manifest() -> &'static [AgenticEventProjectionManifestEntry] +{ + AGENTIC_EVENT_PROJECTION_MANIFEST +} + +pub fn agentic_event_projection_manifest_entry( + event_type: &str, +) -> Option<&'static AgenticEventProjectionManifestEntry> { + AGENTIC_EVENT_PROJECTION_MANIFEST + .iter() + .find(|entry| entry.event_type == event_type) +} + +pub fn is_legacy_websocket_agentic_event_type(event_type: &str) -> bool { + agentic_event_projection_manifest_entry(event_type).is_some_and(|entry| entry.legacy_websocket) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn public_event_projection_manifest_describes_projected_events_and_websocket_allowlist() { + let manifest = public_agentic_event_projection_manifest(); + let text_chunk = agentic_event_projection_manifest_entry("text-chunk") + .expect("text chunk manifest entry"); + + assert!(manifest.len() >= 20); + assert_eq!(text_chunk.event_name, "agentic://text-chunk"); + assert_eq!(text_chunk.version, 1); + assert_eq!(text_chunk.aggregate, AgenticEventProjectionAggregate::Turn); + assert_eq!( + text_chunk.retention, + AgenticEventProjectionRetentionPolicy::Ephemeral + ); + assert_eq!( + text_chunk.ui_shape, + AgenticEventProjectionUiShape::LegacyFlat + ); + assert!(is_legacy_websocket_agentic_event_type("text-chunk")); + assert!(!is_legacy_websocket_agentic_event_type("session-deleted")); + } + + #[test] + fn public_event_projection_manifest_covers_current_frontend_projection_types() { + let mut projected_event_types = [ + "session-created", + "session-deleted", + "image-analysis-started", + "image-analysis-completed", + "dialog-turn-started", + "subagent-session-linked", + "model-round-started", + "text-chunk", + "tool-event", + "dialog-turn-completed", + "session_title_generated", + "dialog-turn-cancelled", + "dialog-turn-failed", + "token-usage-updated", + "context-compression-started", + "context-compression-completed", + "context-compression-failed", + "thread-goal-updated", + "session-state-changed", + "session-model-auto-migrated", + "deep-review-queue-state-changed", + "model-round-completed", + "user-steering-injected", + ]; + + for event_type in projected_event_types.iter().copied() { + assert!( + agentic_event_projection_manifest_entry(event_type).is_some(), + "missing projection manifest entry for projected event type {event_type}" + ); + } + + let mut manifest_event_types = public_agentic_event_projection_manifest() + .iter() + .map(|entry| entry.event_type) + .collect::>(); + manifest_event_types.sort_unstable(); + projected_event_types.sort_unstable(); + + assert_eq!(manifest_event_types, projected_event_types); + } + + #[test] + fn public_event_projection_manifest_has_unique_event_types_and_exact_legacy_websocket_allowlist( + ) { + let manifest = public_agentic_event_projection_manifest(); + let mut event_types = manifest + .iter() + .map(|entry| entry.event_type) + .collect::>(); + event_types.sort_unstable(); + event_types.dedup(); + + assert_eq!(event_types.len(), manifest.len()); + + let legacy_websocket_event_types = manifest + .iter() + .filter_map(|entry| entry.legacy_websocket.then_some(entry.event_type)) + .collect::>(); + + assert_eq!( + legacy_websocket_event_types, + vec![ + "image-analysis-started", + "image-analysis-completed", + "dialog-turn-started", + "subagent-session-linked", + "model-round-started", + "text-chunk", + "tool-event", + "dialog-turn-completed", + "token-usage-updated", + "thread-goal-updated", + "deep-review-queue-state-changed", + "model-round-completed", + ] + ); + } +} diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index c650f0f44e..80efb64911 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -1,4 +1,5 @@ pub mod agentic; +pub mod agentic_projection_manifest; /// Events Layer /// /// Independent event definition layer, providing: @@ -14,6 +15,13 @@ pub use agentic::{ AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus, SubagentParentInfo, ToolEventData, }; +pub use agentic_projection_manifest::{ + agentic_event_projection_manifest_entry, is_legacy_websocket_agentic_event_type, + public_agentic_event_projection_manifest, AgenticEventProjectionAggregate, + AgenticEventProjectionManifestEntry, AgenticEventProjectionReplayPolicy, + AgenticEventProjectionRetentionPolicy, AgenticEventProjectionUiShape, + AGENTIC_EVENT_PROJECTION_MANIFEST, +}; pub use backend::{ BackgroundCommandLifecycleInfo, ToolExecutionCompletedInfo, ToolExecutionErrorInfo, ToolExecutionProgressInfo, ToolExecutionStartedInfo, ToolTerminalReadyInfo, diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index fe4a8af923..f2db209be9 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -1,3 +1,6 @@ +use crate::tool_snapshot::{ + materialize_tool_snapshot, MaterializedToolSnapshot, ToolProviderIdentity, +}; use crate::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; @@ -623,6 +626,18 @@ pub trait ToolRegistryItem: Send + Sync { false } + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + self.is_readonly() + } + + fn needs_permissions(&self, _input: Option<&Value>) -> bool { + !self.is_readonly() + } + + fn manages_own_execution_timeout(&self) -> bool { + false + } + async fn is_enabled(&self) -> bool { true } @@ -1117,7 +1132,6 @@ where #[derive(Debug, Clone)] struct DynamicToolMetadata { - provider_id: String, info: DynamicToolInfo, } @@ -1327,7 +1341,9 @@ impl ToolRuntimeAssembly { pub struct ToolRegistry { tools: IndexMap>, dynamic_tools: IndexMap, + static_tool_providers: IndexMap, tool_decorator: ToolDecoratorRef, + snapshot_generation: u64, } impl Default for ToolRegistry { @@ -1345,11 +1361,21 @@ impl ToolRegistry { Self { tools: IndexMap::new(), dynamic_tools: IndexMap::new(), + static_tool_providers: IndexMap::new(), tool_decorator, + snapshot_generation: 0, } } pub fn register_tool(&mut self, tool: ToolRef) { + self.register_tool_with_static_provider(tool, None); + } + + fn register_tool_with_static_provider( + &mut self, + tool: ToolRef, + static_provider_id: Option<&str>, + ) { let tool = self.tool_decorator.decorate(tool); let name = tool.name().to_string(); let dynamic_info = tool.dynamic_tool_info().and_then(|info| { @@ -1361,25 +1387,32 @@ impl ToolRegistry { }); if let Some(info) = dynamic_info { - self.dynamic_tools.insert( - name.clone(), - DynamicToolMetadata { - provider_id: info.provider_id.clone(), - info, - }, - ); + self.dynamic_tools + .insert(name.clone(), DynamicToolMetadata { info }); + self.static_tool_providers.shift_remove(&name); } else { self.dynamic_tools.shift_remove(&name); + match static_provider_id.filter(|provider_id| !provider_id.trim().is_empty()) { + Some(provider_id) => { + self.static_tool_providers + .insert(name.clone(), provider_id.to_string()); + } + None => { + self.static_tool_providers.shift_remove(&name); + } + } } self.tools.insert(name, tool); + self.snapshot_generation = self.snapshot_generation.saturating_add(1); } pub fn install_static_provider(&mut self, provider: &Provider) where Provider: StaticToolProvider + ?Sized, { + let provider_id = provider.provider_id(); for tool in provider.tools() { - self.register_tool(tool); + self.register_tool_with_static_provider(tool, Some(provider_id)); } } @@ -1396,10 +1429,15 @@ impl ToolRegistry { }) .map(|(tool_name, _)| tool_name.clone()) .collect::>(); + let removed_count = to_remove.len(); for key in to_remove { self.tools.shift_remove(&key); self.dynamic_tools.shift_remove(&key); + self.static_tool_providers.shift_remove(&key); + } + if removed_count > 0 { + self.snapshot_generation = self.snapshot_generation.saturating_add(1); } } @@ -1415,6 +1453,10 @@ impl ToolRegistry { for key in to_remove { self.tools.shift_remove(&key); self.dynamic_tools.shift_remove(&key); + self.static_tool_providers.shift_remove(&key); + } + if count > 0 { + self.snapshot_generation = self.snapshot_generation.saturating_add(1); } count @@ -1452,31 +1494,48 @@ impl ToolRegistry { pub fn get_all_tools(&self) -> Vec> { self.tools.values().cloned().collect() } + + pub fn current_snapshot_generation(&self) -> u64 { + self.snapshot_generation + } + + pub async fn materialized_tool_snapshot(&self) -> Result { + materialize_tool_snapshot( + &self.get_all_tools(), + self.snapshot_generation, + |tool_name| { + self.static_tool_providers + .get(tool_name) + .map(|provider_id| ToolProviderIdentity::static_provider(provider_id.clone())) + }, + ) + .await + } } #[async_trait] impl DynamicToolProvider for ToolRegistry { async fn list_dynamic_tools(&self) -> PortResult> { - let mut descriptors = Vec::new(); - - for (name, tool) in self.tools.iter() { - let Some(metadata) = self.dynamic_tools.get(name) else { - continue; - }; - let description = tool - .description() + let dynamic_tools = self + .tools + .iter() + .filter_map(|(name, tool)| self.dynamic_tools.contains_key(name).then(|| tool.clone())) + .collect::>(); + let snapshot = + materialize_tool_snapshot(&dynamic_tools, self.snapshot_generation, |_| None) .await .map_err(|error| PortError::new(PortErrorKind::Backend, error))?; - descriptors.push(DynamicToolDescriptor { - name: tool.name().to_string(), - description, - input_schema: tool.input_schema_for_model().await, - provider_id: Some(metadata.provider_id.clone()), - }); - } - - Ok(descriptors) + Ok(snapshot + .dynamic_tools() + .into_iter() + .map(|tool| DynamicToolDescriptor { + name: tool.name.clone(), + description: tool.description.clone(), + input_schema: tool.input_schema.clone(), + provider_id: tool.provider.provider_id.clone(), + }) + .collect()) } } diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index 4f35dcb3ac..ef84038f3f 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -14,6 +14,7 @@ pub mod input_validator; pub mod mcp_tool_bridge; pub mod tool_execution_presentation; pub mod tool_result_storage; +pub mod tool_snapshot; pub use acp_tool_bridge::{ acp_external_agent_tool_input_schema, build_acp_external_agent_tool_definition, @@ -107,3 +108,8 @@ pub use tool_result_storage::{ ToolResultStoragePolicy, DEFAULT_MAX_TOOL_RESULT_CHARS, MAX_TOOL_RESULTS_PER_ROUND_CHARS, PERSISTED_OUTPUT_CLOSING_TAG, PERSISTED_OUTPUT_TAG, TOOL_RESULT_PREVIEW_CHARS, }; +pub use tool_snapshot::{ + materialize_tool_snapshot, MaterializedToolSnapshot, ToolCallSnapshotGuard, + ToolCancellationContract, ToolEffectFacts, ToolEffectFactsSource, ToolEffectFilter, + ToolProviderIdentity, ToolSnapshotCallError, ToolSnapshotItem, +}; diff --git a/src/crates/execution/tool-contracts/src/tool_snapshot.rs b/src/crates/execution/tool-contracts/src/tool_snapshot.rs new file mode 100644 index 0000000000..f34fe83593 --- /dev/null +++ b/src/crates/execution/tool-contracts/src/tool_snapshot.rs @@ -0,0 +1,263 @@ +use crate::framework::{DynamicToolInfo, ToolExposure, ToolRef, ToolRegistryItem}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolProviderIdentity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_kind: Option, +} + +impl ToolProviderIdentity { + pub fn builtin() -> Self { + Self { + provider_id: None, + provider_kind: Some("builtin".to_string()), + } + } + + pub fn static_provider(provider_id: impl Into) -> Self { + let provider_id = provider_id.into(); + if provider_id.trim().is_empty() { + return Self::builtin(); + } + Self { + provider_id: Some(provider_id), + provider_kind: Some("static".to_string()), + } + } + + pub fn from_dynamic_tool_info(info: Option<&DynamicToolInfo>) -> Self { + match info { + Some(info) if !info.provider_id.trim().is_empty() => Self { + provider_id: Some(info.provider_id.clone()), + provider_kind: info + .provider_kind + .clone() + .or_else(|| Some("dynamic".to_string())), + }, + _ => Self::builtin(), + } + } + + pub fn is_dynamic(&self) -> bool { + self.provider_id.is_some() + && !matches!(self.provider_kind.as_deref(), Some("builtin" | "static")) + } + + pub fn is_static(&self) -> bool { + matches!(self.provider_kind.as_deref(), Some("static")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolEffectFactsSource { + NoInputDefault, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolEffectFacts { + pub source: ToolEffectFactsSource, + pub readonly_by_default: bool, + pub needs_permissions_by_default: bool, + pub concurrency_safe_by_default: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCancellationContract { + pub cooperative: bool, + pub timeout_managed_by_tool: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ToolEffectFilter { + pub readonly_default_only: bool, + pub include_default_permissioned: bool, +} + +impl ToolEffectFilter { + pub fn readonly_only() -> Self { + Self { + readonly_default_only: true, + include_default_permissioned: false, + } + } + + pub fn matches_default_effects(&self, effects: ToolEffectFacts) -> bool { + if self.readonly_default_only && !effects.readonly_by_default { + return false; + } + if !self.include_default_permissioned && effects.needs_permissions_by_default { + return false; + } + true + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolSnapshotItem { + pub name: String, + pub description: String, + pub input_schema: Value, + pub short_description: String, + pub provider: ToolProviderIdentity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_info: Option, + pub exposure: ToolExposure, + pub effects: ToolEffectFacts, + pub cancellation: ToolCancellationContract, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MaterializedToolSnapshot { + pub generation: u64, + pub tools: Vec, +} + +impl MaterializedToolSnapshot { + pub fn tool(&self, name: &str) -> Option<&ToolSnapshotItem> { + self.tools.iter().find(|tool| tool.name == name) + } + + pub fn filter_tools_by_default_effects( + &self, + filter: ToolEffectFilter, + ) -> Vec<&ToolSnapshotItem> { + self.tools + .iter() + .filter(|tool| filter.matches_default_effects(tool.effects)) + .collect() + } + + pub fn dynamic_tools(&self) -> Vec<&ToolSnapshotItem> { + self.tools + .iter() + .filter(|tool| tool.provider.is_dynamic()) + .collect() + } + + pub fn validate_call( + &self, + guard: &ToolCallSnapshotGuard, + ) -> Result<(), ToolSnapshotCallError> { + if guard.generation != self.generation { + return Err(ToolSnapshotCallError::StaleSnapshot { + tool_name: guard.tool_name.clone(), + expected_generation: self.generation, + actual_generation: guard.generation, + }); + } + if self.tool(&guard.tool_name).is_none() { + return Err(ToolSnapshotCallError::UnknownTool { + tool_name: guard.tool_name.clone(), + generation: self.generation, + }); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolCallSnapshotGuard { + pub tool_name: String, + pub generation: u64, +} + +impl ToolCallSnapshotGuard { + pub fn new(tool_name: impl Into, generation: u64) -> Self { + Self { + tool_name: tool_name.into(), + generation, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolSnapshotCallError { + StaleSnapshot { + tool_name: String, + expected_generation: u64, + actual_generation: u64, + }, + UnknownTool { + tool_name: String, + generation: u64, + }, +} + +impl fmt::Display for ToolSnapshotCallError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::StaleSnapshot { + tool_name, + expected_generation, + actual_generation, + } => write!( + formatter, + "tool '{tool_name}' was selected from stale snapshot generation {actual_generation}; current generation is {expected_generation}" + ), + Self::UnknownTool { + tool_name, + generation, + } => write!( + formatter, + "tool '{tool_name}' is not present in snapshot generation {generation}" + ), + } + } +} + +impl std::error::Error for ToolSnapshotCallError {} + +pub async fn materialize_tool_snapshot( + tools: &[ToolRef], + generation: u64, + static_provider_identity: impl Fn(&str) -> Option, +) -> Result { + let mut snapshot_tools = Vec::with_capacity(tools.len()); + + for tool in tools { + let dynamic_info = tool + .dynamic_tool_info() + .filter(|info| !info.provider_id.trim().is_empty()); + let provider = dynamic_info + .as_ref() + .map(|info| ToolProviderIdentity::from_dynamic_tool_info(Some(info))) + .or_else(|| static_provider_identity(tool.name())) + .unwrap_or_else(ToolProviderIdentity::builtin); + snapshot_tools.push(ToolSnapshotItem { + name: tool.name().to_string(), + description: tool.description().await?, + input_schema: tool.input_schema_for_model().await, + short_description: tool.short_description(), + provider, + dynamic_info, + exposure: tool.default_exposure(), + effects: ToolEffectFacts { + source: ToolEffectFactsSource::NoInputDefault, + readonly_by_default: tool.is_readonly(), + needs_permissions_by_default: tool.needs_permissions(None), + concurrency_safe_by_default: tool.is_concurrency_safe(None), + }, + cancellation: ToolCancellationContract { + cooperative: true, + timeout_managed_by_tool: tool.manages_own_execution_timeout(), + }, + }); + } + + Ok(MaterializedToolSnapshot { + generation, + tools: snapshot_tools, + }) +} diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index 8eb5aec491..c8aaa6e0ec 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -65,9 +65,9 @@ use bitfun_agent_tools::{ materialize_static_tool_provider_groups, ContextualToolManifestItem, DynamicToolDescriptor, DynamicToolProvider, GetToolSpecCatalogProvider, PortResult, PortableToolContextProvider, StaticToolMaterializationError, StaticToolProvider, StaticToolProviderFactory, - StaticToolProviderGroup, StaticToolProviderPlan, ToolCatalogRuntime, - ToolCatalogSnapshotProvider, ToolDecorator, ToolDecoratorRef, ToolRegistry, ToolRegistryItem, - ToolRuntimeAssembly, + StaticToolProviderGroup, StaticToolProviderPlan, ToolCallSnapshotGuard, ToolCatalogRuntime, + ToolCatalogSnapshotProvider, ToolDecorator, ToolDecoratorRef, ToolEffectFactsSource, + ToolEffectFilter, ToolRegistry, ToolRegistryItem, ToolRuntimeAssembly, ToolSnapshotCallError, }; use serde_json::json; use std::path::PathBuf; @@ -1825,6 +1825,10 @@ struct RegistryMarkerTool { enabled: bool, } +struct StaticToolWithFailingDescription; + +struct InputSensitiveEffectTool; + #[async_trait::async_trait] impl ToolRegistryItem for RegistryMarkerTool { fn name(&self) -> &str { @@ -1866,6 +1870,65 @@ impl ToolRegistryItem for RegistryMarkerTool { } } +#[async_trait::async_trait] +impl ToolRegistryItem for StaticToolWithFailingDescription { + fn name(&self) -> &str { + "static_failing_description" + } + + async fn description(&self) -> Result { + Err("static description should not be materialized".to_string()) + } + + fn input_schema(&self) -> serde_json::Value { + json!({ "type": "object" }) + } +} + +#[async_trait::async_trait] +impl ToolRegistryItem for InputSensitiveEffectTool { + fn name(&self) -> &str { + "input_sensitive" + } + + async fn description(&self) -> Result { + Ok("input-sensitive effect tool".to_string()) + } + + fn input_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "action": { + "type": "string" + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn needs_permissions(&self, input: Option<&serde_json::Value>) -> bool { + !matches!( + input + .and_then(|value| value.get("action")) + .and_then(serde_json::Value::as_str), + Some("status") + ) + } + + fn is_concurrency_safe(&self, input: Option<&serde_json::Value>) -> bool { + matches!( + input + .and_then(|value| value.get("action")) + .and_then(serde_json::Value::as_str), + Some("status") + ) + } +} + #[derive(Debug, Clone, Copy)] struct ManifestTestContext { agent: &'static str, @@ -2987,6 +3050,170 @@ async fn generic_tool_registry_preserves_dynamic_descriptor_contract() { assert_eq!(descriptors[0].input_schema, json!({ "type": "object" })); } +#[tokio::test] +async fn generic_tool_registry_dynamic_listing_does_not_materialize_static_tools() { + let mut registry: ToolRegistry = ToolRegistry::new(); + registry.register_tool(registry_marker_tool("external_search", Some("provider-a"))); + registry.register_tool(Arc::new(StaticToolWithFailingDescription)); + + let descriptors = registry + .list_dynamic_tools() + .await + .expect("list dynamic tools"); + + assert_eq!(descriptors.len(), 1); + assert_eq!(descriptors[0].name, "external_search"); +} + +#[tokio::test] +async fn generic_tool_registry_materializes_provider_effect_and_stale_call_contract() { + let mut registry = ToolRegistry::new(); + registry.register_tool(registry_marker_tool_with_access( + "external_search", + Some("provider-a"), + ToolExposure::Collapsed, + true, + true, + )); + registry.register_tool(registry_marker_tool_with_access( + "write_file", + None, + ToolExposure::Expanded, + false, + true, + )); + + let snapshot = registry + .materialized_tool_snapshot() + .await + .expect("materialize registry snapshot"); + + assert_eq!(snapshot.generation, registry.current_snapshot_generation()); + assert_eq!( + snapshot + .tool("external_search") + .expect("external tool") + .provider + .provider_id + .as_deref(), + Some("provider-a") + ); + assert_eq!( + snapshot + .tool("external_search") + .expect("external tool") + .exposure, + ToolExposure::Collapsed + ); + assert!( + snapshot + .tool("external_search") + .expect("external tool") + .effects + .readonly_by_default + ); + assert!( + !snapshot + .tool("write_file") + .expect("write tool") + .effects + .readonly_by_default + ); + + let readonly_names = snapshot + .filter_tools_by_default_effects(ToolEffectFilter::readonly_only()) + .into_iter() + .map(|tool| tool.name.as_str()) + .collect::>(); + assert_eq!(readonly_names, vec!["external_search"]); + + let guard = ToolCallSnapshotGuard::new("external_search", snapshot.generation); + assert!(snapshot.validate_call(&guard).is_ok()); + + registry.unregister_tools_by_prefix("external_"); + let current_snapshot = registry + .materialized_tool_snapshot() + .await + .expect("materialize current snapshot"); + assert_eq!( + current_snapshot.validate_call(&guard), + Err(ToolSnapshotCallError::StaleSnapshot { + tool_name: "external_search".to_string(), + expected_generation: current_snapshot.generation, + actual_generation: snapshot.generation, + }) + ); +} + +#[tokio::test] +async fn generic_tool_registry_snapshot_preserves_static_provider_identity_after_decoration() { + let mut registry = ToolRegistry::with_tool_decorator(Arc::new(RegistryMarkerDecorator)); + let provider = RegistryMarkerProvider { + provider_id: "core.basic", + tools: vec![registry_marker_tool("Read", None)], + }; + + registry.install_static_provider(&provider); + + let snapshot = registry + .materialized_tool_snapshot() + .await + .expect("materialize registry snapshot"); + let tool = snapshot.tool("decorated_Read").expect("decorated tool"); + + assert_eq!(tool.provider.provider_id.as_deref(), Some("core.basic")); + assert_eq!(tool.provider.provider_kind.as_deref(), Some("static")); + assert!(tool.provider.is_static()); + assert!(snapshot.dynamic_tools().is_empty()); +} + +#[tokio::test] +async fn generic_tool_registry_snapshot_labels_effects_as_no_input_defaults() { + let mut registry: ToolRegistry = ToolRegistry::new(); + let tool = Arc::new(InputSensitiveEffectTool); + assert!(!tool.needs_permissions(Some(&json!({ "action": "status" })))); + assert!(tool.is_concurrency_safe(Some(&json!({ "action": "status" })))); + + registry.register_tool(tool); + + let snapshot = registry + .materialized_tool_snapshot() + .await + .expect("materialize registry snapshot"); + let tool = snapshot + .tool("input_sensitive") + .expect("input-sensitive tool"); + + assert_eq!(tool.effects.source, ToolEffectFactsSource::NoInputDefault); + assert!(!tool.effects.readonly_by_default); + assert!(tool.effects.needs_permissions_by_default); + assert!(!tool.effects.concurrency_safe_by_default); +} + +#[tokio::test] +async fn generic_tool_registry_snapshot_treats_blank_provider_id_as_builtin() { + let mut registry = ToolRegistry::new(); + registry.register_tool(registry_marker_tool("blank_provider", Some(" "))); + + let snapshot = registry + .materialized_tool_snapshot() + .await + .expect("materialize registry snapshot"); + let tool = snapshot + .tool("blank_provider") + .expect("blank provider tool"); + + assert_eq!(tool.provider.provider_id, None); + assert_eq!(tool.provider.provider_kind.as_deref(), Some("builtin")); + assert_eq!(tool.dynamic_info, None); + assert!(snapshot.dynamic_tools().is_empty()); + assert!(registry + .list_dynamic_tools() + .await + .expect("list dynamic tools") + .is_empty()); +} + #[tokio::test] async fn generic_tool_registry_clears_stale_dynamic_metadata_on_overwrite() { let mut registry = ToolRegistry::new();