diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index d56e47e7f5..057976a136 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # OpenBitFun 功能与设置目录 / OpenBitFun Features & Settings -OpenBitFun Playbook 当前包含 **23 个功能**和 **21 个设置页**,共 **44 个**用户可理解的条目、**332 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 +OpenBitFun Playbook 当前包含 **23 个功能**和 **21 个设置页**,共 **44 个**用户可理解的条目、**333 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、OpenBitFun 全局搜索和 `OpenBitFunControl` 智能体工具。 -OpenBitFun Playbook currently contains **23 features**, **21 settings pages**, and **332** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. +OpenBitFun Playbook currently contains **23 features**, **21 settings pages**, and **333** source-backed sub-capabilities across **44** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `OpenBitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -33,14 +33,14 @@ Docs, website, search, and agents see only features, settings, and documented su ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **51**、委托 **65**、需交互 **216**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用智能体工具、需交互打开或不支持;“打开页面”不会再被统计成“智能体已控制”。当前覆盖:直接 **52**、委托 **65**、需交互 **216**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;智能体不接触原始 Tauri Command。 -- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 332 项子能力都不会写入 system prompt。 +- `OpenBitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 333 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读智能体只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **51 direct**, **65 delegated**, **216 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **52 direct**, **65 delegated**, **216 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 332 documented items enters the system prompt. +- `OpenBitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 333 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 99af228476..5fde780727 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", - "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", + "digest": "65f1a11c6d994c382da25f2b9ad995ad0f443a5d352c2eafa10a8b9bcbf849e5", + "ownerDigest": "4e1ace270088acecbd333d2c8aced0547dddb0fd6bae778bec24f2b0fb48ea91", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -147,9 +147,9 @@ "features": 23, "settings": 21, "userFacing": 44, - "documentedItems": 332, + "documentedItems": 333, "controlCoverage": { - "direct": 51, + "direct": 52, "delegated": 65, "interactive": 216, "unsupported": 0 @@ -16039,6 +16039,7 @@ "global-project-rules", "remembered-grants", "deferred-tools", + "tool-presentation", "parallelism", "timeouts", "review-capacity", @@ -16175,6 +16176,54 @@ "pageId": "tools.execution" } }, + { + "id": "setting.tools.execution:option:tool-presentation-mode", + "capabilityId": "setting.tools.execution", + "itemIds": [ + "tool-presentation" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "tools.execution" + } + }, { "id": "setting.tools.execution:option:subagent-batch-policy", "capabilityId": "setting.tools.execution", @@ -28523,13 +28572,15 @@ "categoryId": "assistant", "titleZh": "执行与权限控制", "titleEn": "Execution & permissions", - "summaryZh": "管理智能体工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。", - "summaryEn": "Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control.", + "summaryZh": "管理智能体工具权限、呈现模式、并行度、超时、延迟加载、Computer Use 与浏览器控制。", + "summaryEn": "Manage agent tool permissions, presentation mode, concurrency, timeouts, deferred loading, Computer Use, and browser control.", "keywordsZh": [ "执行控制", "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -28541,6 +28592,8 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", + "PTC", "approval", "parallel", "timeout", @@ -28645,6 +28698,25 @@ ] } }, + { + "id": "tool-presentation", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", + "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "destination": { + "kind": "settings", + "pageId": "tools.execution", + "viewId": "advanced" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "tool-presentation-mode" + } + ] + } + }, { "id": "parallelism", "titleZh": "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", @@ -28821,6 +28893,20 @@ "type": "boolean" } }, + { + "id": "tool-presentation-mode", + "titleZh": "工具呈现模式", + "titleEn": "Tool presentation mode", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "valueSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + } + }, { "id": "subagent-batch-policy", "titleZh": "子智能体批量执行策略", @@ -28914,6 +29000,8 @@ "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -28923,6 +29011,7 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", "approval", "parallel", "timeout", @@ -28945,6 +29034,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", + "选择模型使用原生模式还是 PTC TypeScript 模式", + "Choose native mode or PTC TypeScript mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/docs/interactive-capabilities/capabilities/setting.tools.execution.md b/docs/interactive-capabilities/capabilities/setting.tools.execution.md index a9d4489e53..e8c2c03973 100644 --- a/docs/interactive-capabilities/capabilities/setting.tools.execution.md +++ b/docs/interactive-capabilities/capabilities/setting.tools.execution.md @@ -11,9 +11,9 @@ title_en: "Execution & permissions" > 设置 / Setting -管理智能体工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。 +管理智能体工具权限、呈现模式、并行度、超时、延迟加载、Computer Use 与浏览器控制。 -Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control. +Manage agent tool permissions, presentation mode, concurrency, timeouts, deferred loading, Computer Use, and browser control. ## 完整功能清单 / Everything included @@ -27,6 +27,8 @@ Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer - Review, revoke, or clear remembered project permission grants and audit records - **智能体可直接控制 / Direct Agent control** · 按需延迟加载工具定义以减少智能体初始上下文 - Deferred tool loading on demand to reduce initial agent context +- **智能体可直接控制 / Direct Agent control** · 选择模型使用原生模式还是 PTC TypeScript 模式 + - Choose native mode or PTC TypeScript mode for the model - **智能体可直接控制 / Direct Agent control** · 设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略 - Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy - **智能体可直接控制 / Direct Agent control** · 设置或取消单次工具调用超时 @@ -67,6 +69,7 @@ Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer | --- | --- | --- | --- | | 显示权限模式选择器 / Show permission-mode selector | `boolean` | 在聊天输入框下方显示或隐藏权限模式快捷选择器。 | Show or hide the permission-mode shortcut below the chat composer. | | 延迟加载工具 / Deferred tool loading | `boolean` | 按需加载大型工具定义,减少智能体初始上下文。 | Load large tool definitions on demand to reduce initial agent context. | +| 工具呈现模式 / Tool presentation mode | `native` / `ptc` | 选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。 | Choose whether the model calls native tools directly or uses PTC TypeScript. | | 子智能体批量执行策略 / Subagent batch policy | `safe_only` / `force_parallel` / `serial` | 选择仅安全任务并行、强制并行或串行执行。 | Choose safe-only parallelism, forced parallelism, or serial execution. | | 子智能体最大并发 / Maximum subagent concurrency | `integer` (1–32) | 限制同时运行的子智能体数量。 | Limit the number of subagents that may run at once. | | Swarm 最大并发 / Maximum swarm concurrency | `integer` (1–64) | 限制 Swarm 中同时执行的工作单元。 | Limit concurrent work units inside a swarm. | diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index ff011f8b44..602eccb317 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", + "catalogDigest": "65f1a11c6d994c382da25f2b9ad995ad0f443a5d352c2eafa10a8b9bcbf849e5", "count": 216, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index f83100f17c..8e7c9ceeea 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,7 +1,7 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", + "catalogDigest": "65f1a11c6d994c382da25f2b9ad995ad0f443a5d352c2eafa10a8b9bcbf849e5", "commandCount": 701, "coverage": { "commandCount": 701, diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 1299a23776..c2c521db49 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -557,6 +557,7 @@ test('portable contract crates expose only capability-local feature slices', asy 'permission', 'plugin-runtime', 'product-search', + 'ptc-runtime', 'remote-exec-port', 'remote-workspace-ports', 'runtime-event-port', @@ -573,6 +574,7 @@ test('portable contract crates expose only capability-local feature slices', asy assert.deepEqual(runtimePortFeatures['plugin-runtime'], []); assert.deepEqual(runtimePortFeatures['product-search'], ['dep:openbitfun-product-domains']); assert.deepEqual(runtimePortFeatures['script-tool-runtime'], []); + assert.deepEqual(runtimePortFeatures['ptc-runtime'], []); assert.deepEqual(runtimePortFeatures['web-search-port'], []); // Workspace byte readers expose AsyncRead/AsyncSeek without selecting a // process, network, scheduler, or full Tokio runtime at the contract layer. @@ -783,6 +785,7 @@ const RUNTIME_PORT_FEATURE_PROFILES = { permission: ['dep:openbitfun-product-domains'], 'plugin-runtime': [], 'product-search': ['dep:openbitfun-product-domains'], + 'ptc-runtime': [], 'remote-exec-port': ['dep:tokio'], 'remote-workspace-ports': [], 'runtime-event-port': [], @@ -1034,6 +1037,11 @@ test('contract and AI adapter tests keep reviewed feature and failure-domain top path: 'tests/script_tool_port_contracts.rs', requiredFeatures: ['script-tool-runtime'], }, + { + name: 'ptc_tool_port_contracts', + path: 'tests/ptc_tool_port_contracts.rs', + requiredFeatures: ['ptc-runtime'], + }, { name: 'session_store_contracts', path: 'tests/session_store_contracts.rs', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 03a92898ee..4503bbf61d 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -168,6 +168,7 @@ const SERVICES_INTEGRATIONS_TOKIO_FEATURES = new Map([ ['speech-realtime', ['fs', 'io-util', 'macros', 'net', 'rt', 'sync', 'time']], ['workspace-search', ['io-util', 'rt', 'sync', 'time']], ['script-tool-runtime', ['io-util', 'process', 'rt', 'sync', 'time']], + ['ptc-runtime', ['io-util', 'process', 'rt', 'sync', 'time']], ]); const SERVICES_CORE_TOKIO_FEATURES = new Map([ @@ -198,6 +199,8 @@ const CORE_TOKIO_FEATURES = new Map([ ['browser-control', ['net', 'rt', 'time']], ]); const CORE_TOKIO_AGGREGATES = new Set([ + // PTC composes the Agent Runtime baseline and the service-owned Node runtime. + 'ptc-runtime', 'external-sources', 'plugin-runtime', 'product-search', diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 6702a6e30f..2cf1f4f7f9 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -163,6 +163,11 @@ export const runtimePortsIntegrationTestTargets = [ path: 'tests/script_tool_port_contracts.rs', requiredFeatures: ['script-tool-runtime'], }, + { + name: 'ptc_tool_port_contracts', + path: 'tests/ptc_tool_port_contracts.rs', + requiredFeatures: ['ptc-runtime'], + }, { name: 'session_store_contracts', path: 'tests/session_store_contracts.rs', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index e87c471332..615a352a3a 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -208,6 +208,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'mcp-runtime', 'model-catalog', 'plugin-source', + 'ptc-runtime', 'remote-connect', 'remote-workspace', 'review-platform', @@ -276,7 +277,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'anyhow', ownerFeatures: ['browser-control', 'deep-research', 'mcp', 'remote-connect', 'remote-persistence', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['deep-research', 'git', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], + ownerFeatures: ['deep-research', 'git', 'mcp', 'ptc-runtime', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'web-tools', 'workspace-search'], }, { depName: 'base64', @@ -289,7 +290,7 @@ export const optionalDependencyFeatureOwnerRules = [ ownerFeatures: ['deep-research', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, { depName: 'openbitfun-product-domains', ownerFeatures: ['account-identity', 'canvas-runtime', 'function-agents', 'hook-import', 'miniapp-market', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect'] }, - { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, + { depName: 'openbitfun-runtime-ports', ownerFeatures: ['deep-research', 'git', 'ptc-runtime', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime', 'web-tools'] }, { depName: 'openbitfun-services-core', ownerFeatures: [ @@ -356,7 +357,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'miniapp-market', 'remote-connect', 'review-platform'] }, { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'hook-import', 'miniapp-runtime', 'miniapp-storage', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, - { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, + { depName: 'which', ownerFeatures: ['miniapp-runtime', 'ptc-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['models-dev', 'plugin-source', 'remote-connect', 'remote-persistence', 'remote-ssh-concrete', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, ], @@ -406,6 +407,7 @@ export const capabilityContractDependencyRules = [ permission: ['dep:openbitfun-product-domains'], 'plugin-runtime': [], 'product-search': ['dep:openbitfun-product-domains'], + 'ptc-runtime': [], 'remote-exec-port': ['dep:tokio'], 'remote-workspace-ports': [], 'runtime-event-port': [], @@ -463,12 +465,13 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('opencode-plugin-host', 'hook-function-runtime'), capabilityForwarder('plugin-runtime', 'plugin-runtime'), capabilityForwarder('product-search', 'product-search'), + capabilityForwarder('ptc-runtime', 'ptc-runtime'), capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), capabilityForwarder('ts', 'ts'), capabilityForwarder('web-tools', 'web-search-port'), ], [], - ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'product-full', 'remote-connect', 'tools-browser-web', 'tools-mcp'], + ['external-sources', 'mcp-runtime', 'opencode-plugin-host', 'product-full', 'ptc-runtime', 'remote-connect', 'tools-browser-web', 'tools-mcp'], )], ['openbitfun-desktop', capabilityConsumer([ capabilityEdge(['agent-api', 'permission', 'workspace-ports']), @@ -527,6 +530,7 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('remote-ssh', 'remote-exec-port'), capabilityForwarder('remote-ssh', 'remote-workspace-ports'), capabilityForwarder('remote-ssh', 'workspace-ports'), + capabilityForwarder('ptc-runtime', 'ptc-runtime'), capabilityForwarder('script-tool-runtime', 'script-tool-runtime'), capabilityForwarder('web-tools', 'web-search-port'), ], @@ -579,6 +583,7 @@ export const capabilityContractDependencyRules = [ 'plugin-runtime', 'product-search', 'product-full', + 'ptc-runtime', 'remote-connect', 'remote-workspace', 'ssh-remote', @@ -686,7 +691,7 @@ export const capabilityContractDependencyRules = [ capabilityForwarder('ai-adapter-runtime', 'account-routing'), ], ['agent-runtime', 'ai-adapter-runtime'], - ['external-sources', 'function-agents', 'model-catalog', 'mcp-runtime', 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', 'remote-connect', 'tools-mcp'], + ['external-sources', 'function-agents', 'model-catalog', 'mcp-runtime', 'opencode-plugin-host', 'plugin-runtime', 'product-search', 'product-full', 'ptc-runtime', 'remote-connect', 'tools-mcp'], )], ['openbitfun-desktop', capabilityConsumer([ capabilityEdge(['agent-runtime']), @@ -1236,6 +1241,33 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'script-tool-runtime must own only external script tool execution support', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'ptc-runtime', + requiredFeatureRefs: [ + 'agent-runtime', + 'git', + 'openbitfun-runtime-ports/ptc-runtime', + 'openbitfun-services-integrations/ptc-runtime', + ], + allowedTransitiveFeatureRefs: [ + 'ai-adapter-runtime', + 'filesystem', + 'local-storage', + 'process-runtime', + 'terminal', + 'workspace-runtime', + 'product-capabilities', + 'runtime-services', + 'tool-packs', + 'tools-basic', + 'tools-agent-control', + 'workspace-search', + 'scheduled-jobs', + ], + exact: true, + reason: 'ptc-runtime composes the tool pipeline and service-owned Node execution through the portable PTC port', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'scheduled-jobs', @@ -1799,6 +1831,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'function-agents', 'mcp', 'miniapp-market', + 'ptc-runtime', 'remote-ssh-concrete', 'script-tool-runtime', ], diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index cb1d48cdc1..c575b66548 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -4199,7 +4199,7 @@ export const requiredContentRules = [ message: 'missing ExecControl input/default wait regression', }, { - regex: /\bexec_command_result_builder_preserves_existing_wire_shape\b/, + regex: /\bexec_command_result_builder_exposes_only_model_result_fields\b/, message: 'missing ExecCommand result shape regression', }, { diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index d3c07dc8c0..bf30084e0d 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -2992,7 +2992,7 @@ export function runManifestParserSelfTest({ 'exec_command_input_policy_applies_defaults_without_trimming_command', 'write_stdin_input_policy_applies_poll_defaults', 'exec_control_input_policy_keeps_wait_optional', - 'exec_command_result_builder_preserves_existing_wire_shape', + 'exec_command_result_builder_exposes_only_model_result_fields', 'write_stdin_and_control_result_builders_preserve_remote_shape', 'remote_env_snapshot_capture_policy_keeps_existing_bounds', 'remote_env_snapshot_cache_owns_key_and_ttl_policy', diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 0d3eb6027c..b3783ef8a0 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1944,6 +1944,7 @@ pub async fn create_session( let config = request .config .map(|c| SessionConfig { + tool_presentation_mode: Default::default(), max_context_tokens: c.max_context_tokens.unwrap_or(128128), auto_compact: c.auto_compact.unwrap_or(true), enable_tools: c.enable_tools.unwrap_or(true), @@ -1961,6 +1962,7 @@ pub async fn create_session( ..Default::default() }) .unwrap_or(SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(request.workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), execution_target: Some(resolved_execution_target), diff --git a/src/apps/desktop/src/api/miniapp_agent_api.rs b/src/apps/desktop/src/api/miniapp_agent_api.rs index af8951ff97..3d90093308 100644 --- a/src/apps/desktop/src/api/miniapp_agent_api.rs +++ b/src/apps/desktop/src/api/miniapp_agent_api.rs @@ -305,6 +305,7 @@ async fn create_miniapp_agent_session( requested_model: Option, ) -> Result { let config = SessionConfig { + tool_presentation_mode: Default::default(), enable_tools: submission_plan.enable_tools, safe_mode: true, auto_compact: true, diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index 60714c1431..ce7b6df1dc 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -391,6 +391,7 @@ pub async fn dispatch( .map(|s| s.to_string()); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.clone()), ..Default::default() }; diff --git a/src/crates/assembly/core/AGENTS.md b/src/crates/assembly/core/AGENTS.md index d2f4902524..9ac6311a69 100644 --- a/src/crates/assembly/core/AGENTS.md +++ b/src/crates/assembly/core/AGENTS.md @@ -190,6 +190,14 @@ Narrower local guides already exist for some subtrees: ## Verification +For tool catalog feature gates, loaded-spec collection, and native/PTC +presentation, check both the native baseline and the optional PTC owner: + +```bash +cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git --lib agentic::tools::product_runtime:: +cargo test --locked -p openbitfun-core --no-default-features --features ptc-runtime --lib agentic::tools::product_runtime:: +``` + For config-backed product-control option defaults, typed binding, and persisted readback: ```bash diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 2ce72005f1..573dd19d73 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -184,6 +184,7 @@ product-full = [ "workspace-watch", "ssh-remote", "dispatch-store", + "ptc-runtime", "filesystem", "git", "local-storage", @@ -336,6 +337,12 @@ script-tool-runtime = [ "openbitfun-runtime-ports/script-tool-runtime", "openbitfun-services-integrations/script-tool-runtime", ] +ptc-runtime = [ + "agent-runtime", + "git", + "openbitfun-runtime-ports/ptc-runtime", + "openbitfun-services-integrations/ptc-runtime", +] scheduled-jobs = [ "dep:chrono-tz", "dep:cron", diff --git a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs index c672c05082..ec01aec38e 100644 --- a/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs +++ b/src/crates/assembly/core/src/agentic/agents/prompt_builder/prompt_builder_impl.rs @@ -478,6 +478,11 @@ impl PromptBuilder { ) -> PrependedPromptReminders { PrependedPromptReminders { deferred_tool_listing: self.build_deferred_tool_listing_reminder(), + ptc_tool_calling_guide: self + .context + .tool_listing_sections + .ptc_tool_calling_guide + .clone(), skill_listing: self.build_skill_listing_reminder(), agent_listing: self.build_agent_listing_reminder(), runtime_context: self.build_runtime_context_reminder().await, @@ -680,6 +685,7 @@ mod tests { deferred_tool_listing: Some( "\n- WebFetch\n".to_string(), ), + ptc_tool_calling_guide: None, }; let context = PromptBuilderContext::new(r"workspace\root", None, None) .with_tool_listing_sections(tool_sections) 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 e851021bb2..49c3dd3a2b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -2238,6 +2238,7 @@ fn external_primary_route_follows_the_session_execution_worktree() { ); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(worktree.clone()), project_workspace_id: Some(project.clone()), workspace_path: Some("/same/execution-path".into()), diff --git a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs index b6d951fc45..3145f7a3ca 100644 --- a/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs +++ b/src/crates/assembly/core/src/agentic/coordination/background_outcomes.rs @@ -762,6 +762,7 @@ mod tests { }, )); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }; diff --git a/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs b/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs index af5ad7b26f..4c1794ee93 100644 --- a/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs +++ b/src/crates/assembly/core/src/agentic/coordination/control_conversation.rs @@ -148,6 +148,7 @@ impl ConversationCoordinator { "OpenBitFun".to_string(), "OpenBitFun".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(workspace_record.id.clone()), workspace_path: Some(workspace_path.clone()), ..SessionConfig::default() diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 8e24a6ef7a..f587b4bf02 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -103,6 +103,7 @@ use openbitfun_agent_runtime::remote_file_delivery::{ }; use openbitfun_agent_runtime::sdk::PermissionReply; use openbitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; +use openbitfun_agent_tools::{PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE}; use openbitfun_config_contracts::model_pool::AgentModelSelection; use openbitfun_config_contracts::model_pool::AgentModelSelections; use openbitfun_core_types::model_pool::ModelSelection; @@ -1428,6 +1429,7 @@ impl ConversationCoordinator { model_id: Option, ) -> OpenBitFunResult { let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(workspace_id), model_id, ..Default::default() @@ -7143,6 +7145,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Pass turn_index (for operation history/rollback) context_vars.insert("turn_index".to_string(), turn_index.to_string()); + if session.config.tool_presentation_mode.is_ptc() { + context_vars.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + PTC_TOOL_PRESENTATION_MODE.to_string(), + ); + } let review_agent = is_review_agent_type(&effective_agent_type); let turn_review_manifest = turn_review_manifest_for_agent(user_message_metadata.as_ref(), &effective_agent_type); @@ -7749,6 +7757,12 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet if let Some(snapshot_id) = &session.snapshot_session_id { context_vars.insert("snapshot_session_id".to_string(), snapshot_id.clone()); } + if session.config.tool_presentation_mode.is_ptc() { + context_vars.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + PTC_TOOL_PRESENTATION_MODE.to_string(), + ); + } if let Some(user_input_available) = metadata_bool( plan.user_message_metadata.as_ref(), USER_INPUT_AVAILABLE_CONTEXT_KEY, @@ -14031,6 +14045,7 @@ async fn create_agent_session_from_runtime_request( session_name, request.agent_type, SessionConfig { + tool_presentation_mode: Default::default(), agent_route_key: request.agent_route_key, workspace_path: Some(workspace_path.clone()), project_workspace_path: request.project_workspace_path, @@ -16361,6 +16376,7 @@ pub(crate) mod tests { #[tokio::test] async fn remote_session_metadata_never_falls_back_to_a_local_workspace_binding() { let binding = ConversationCoordinator::build_workspace_binding(&SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/srv/remote-project".to_string()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), remote_ssh_host: Some("example.test".to_string()), @@ -16380,11 +16396,13 @@ pub(crate) mod tests { for incomplete in [ SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/srv/remote-project".to_string()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), ..SessionConfig::default() }, SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/srv/remote-project".to_string()), remote_ssh_host: Some("example.test".to_string()), ..SessionConfig::default() @@ -16408,6 +16426,7 @@ pub(crate) mod tests { ) .await; let binding = ConversationCoordinator::build_workspace_binding(&SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/srv/remote-project".to_string()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), remote_ssh_host: Some("example.test".to_string()), @@ -16475,6 +16494,7 @@ pub(crate) mod tests { assert_eq!(omitted.model_id.as_deref(), Some("provider/profile-model")); let mut defaulted = SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("default".to_string()), ..SessionConfig::default() }; @@ -16485,6 +16505,7 @@ pub(crate) mod tests { ); let mut legacy_auto = SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("auto".to_string()), ..SessionConfig::default() }; @@ -16495,6 +16516,7 @@ pub(crate) mod tests { ); let mut explicit = SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("provider/user-model".to_string()), ..SessionConfig::default() }; @@ -16560,6 +16582,7 @@ pub(crate) mod tests { #[test] fn retired_auto_model_input_is_not_snapshotted_into_new_sessions() { let mut config = SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("auto".to_string()), ..SessionConfig::default() }; @@ -16721,6 +16744,7 @@ pub(crate) mod tests { "Cancellation fault".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -16912,6 +16936,7 @@ pub(crate) mod tests { "External compaction".to_string(), external_agent_id.clone(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -16958,6 +16983,7 @@ pub(crate) mod tests { "External to local".to_string(), external_agent_id.clone(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -17206,6 +17232,7 @@ pub(crate) mod tests { "Delegated child".to_string(), agent_type.to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.clone()), model_id: Some("child-model".to_string()), continuation_policy: policy, @@ -17463,6 +17490,7 @@ pub(crate) mod tests { "Cancelled compaction".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..SessionConfig::default() }, @@ -17543,6 +17571,7 @@ pub(crate) mod tests { "Persistence failure".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -18010,6 +18039,7 @@ pub(crate) mod tests { "Runtime mode validation".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), ..Default::default() }, @@ -18057,6 +18087,7 @@ pub(crate) mod tests { "Runtime mode validation".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), ..Default::default() }, @@ -18105,6 +18136,7 @@ pub(crate) mod tests { "Runtime mode update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), ..Default::default() }, @@ -18159,6 +18191,7 @@ pub(crate) mod tests { "Runtime model update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), model_id: Some("primary".to_string()), ..Default::default() @@ -18531,6 +18564,7 @@ pub(crate) mod tests { "Durable completion".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -18605,6 +18639,7 @@ pub(crate) mod tests { "Durable completion".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -18686,6 +18721,7 @@ pub(crate) mod tests { "Host stream observer".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -18787,6 +18823,7 @@ pub(crate) mod tests { "Transient completion".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -18912,6 +18949,7 @@ pub(crate) mod tests { "Reverted".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -19384,6 +19422,7 @@ pub(crate) mod tests { "Transient transcript".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -19451,6 +19490,7 @@ pub(crate) mod tests { "blocked".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19477,6 +19517,7 @@ pub(crate) mod tests { format!("Review child: {agent_type}"), agent_type.to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -19501,6 +19542,7 @@ pub(crate) mod tests { "Deep review remediation".to_string(), "DeepReview".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.clone()), model_id: Some("review-model".to_string()), enable_tools: true, @@ -19944,6 +19986,7 @@ pub(crate) mod tests { "Lifecycle child".into(), "GeneralPurpose".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), continuation_policy: policy, ..Default::default() @@ -20240,6 +20283,7 @@ pub(crate) mod tests { "Parent".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -20310,6 +20354,7 @@ pub(crate) mod tests { "Parent".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -20495,6 +20540,7 @@ pub(crate) mod tests { "Shell turn".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -20592,6 +20638,7 @@ pub(crate) mod tests { "Denied shell turn".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -20651,6 +20698,7 @@ pub(crate) mod tests { "Failed shell turn".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -20707,6 +20755,7 @@ pub(crate) mod tests { "Cancelled shell turn".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -21951,6 +22000,7 @@ pub(crate) mod tests { "Loaded remote A".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(logical_workspace_path.to_string()), remote_connection_id: Some(remote_identities[0].0.clone()), remote_ssh_host: Some(remote_identities[0].1.clone()), @@ -22072,6 +22122,7 @@ pub(crate) mod tests { "Remote goal mutation".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(logical_workspace_path.clone()), remote_connection_id: Some(remote_connection_id), remote_ssh_host: Some(remote_ssh_host), @@ -22202,6 +22253,7 @@ pub(crate) mod tests { "First".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), ..Default::default() }, @@ -22224,6 +22276,7 @@ pub(crate) mod tests { "Second".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), ..Default::default() }, @@ -22261,6 +22314,7 @@ pub(crate) mod tests { "Explicit".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path_string.clone()), model_id: Some("explicit-model".to_string()), ..Default::default() @@ -22406,6 +22460,7 @@ pub(crate) mod tests { "Transient child".to_string(), "Explore".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.clone()), ..Default::default() }, @@ -22420,6 +22475,7 @@ pub(crate) mod tests { "Transient grandchild".to_string(), "Explore".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.clone()), ..Default::default() }, @@ -22572,6 +22628,7 @@ pub(crate) mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() @@ -22693,6 +22750,7 @@ pub(crate) mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace_path.clone()), project_workspace_path: Some(project_workspace_path.clone()), @@ -22777,6 +22835,7 @@ pub(crate) mod tests { "Transient parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace.clone()), workspace_id: Some(workspace_record.id.clone()), @@ -22906,6 +22965,7 @@ pub(crate) mod tests { std::fs::create_dir_all(&workspace).unwrap(); crate::service::workspace::legacy_compat::register_local_fixture_blocking(&workspace); let config = SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".into()), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() @@ -23009,6 +23069,7 @@ pub(crate) mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() @@ -23023,6 +23084,7 @@ pub(crate) mod tests { "Reusable subagent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("parent-model".to_string()), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() @@ -23174,6 +23236,7 @@ pub(crate) mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() @@ -23286,6 +23349,7 @@ pub(crate) mod tests { "Internal worker".to_string(), "MemoryPhase2".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() }, @@ -23328,6 +23392,7 @@ pub(crate) mod tests { "Reusable subagent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.to_string_lossy().into_owned()), ..Default::default() }, @@ -23402,6 +23467,7 @@ pub(crate) mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(remote_workspace.id.clone()), workspace_path: Some(remote_workspace.root_path.to_string_lossy().into_owned()), remote_connection_id: Some("ssh-user@example.test:22".to_string()), diff --git a/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs b/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs index 83b7d6775b..328601ad9d 100644 --- a/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs +++ b/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs @@ -54,6 +54,7 @@ async fn host_queue_subagent_conversation_admission_precedes_receipts_and_queue_ "Child".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), continuation_policy: policy, ..Default::default() @@ -738,6 +739,7 @@ fn host_queue_new_prompt_after_stop_supersedes_interruption() { "Stopped".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -935,6 +937,7 @@ async fn stopped_host_queue_fixture( "Stopped".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -1298,6 +1301,7 @@ fn steering_creates_persisted_user_turns_in_order_without_a_connected_caller() { "Steering history".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 5b81532152..081a4ef1a2 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -3808,6 +3808,7 @@ mod tests { "Reverted".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -3869,6 +3870,7 @@ mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -3928,6 +3930,7 @@ mod tests { "External parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), model_id: Some("model-original".to_string()), ..Default::default() @@ -3983,6 +3986,7 @@ mod tests { "Interrupted".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -4059,6 +4063,7 @@ mod tests { "Goal parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -4190,6 +4195,7 @@ mod tests { "Interrupted queue hold".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -4245,6 +4251,7 @@ mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4316,6 +4323,7 @@ mod tests { "Owner".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -4417,6 +4425,7 @@ mod tests { "Queued".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4493,6 +4502,7 @@ mod tests { "Delegated".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4552,6 +4562,7 @@ mod tests { "Delegated".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4647,6 +4658,7 @@ mod tests { "ACP".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4717,6 +4729,7 @@ mod tests { "Duplicate".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4775,6 +4788,7 @@ mod tests { "Workspace".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_a.to_string_lossy().to_string()), ..Default::default() }, @@ -4825,6 +4839,7 @@ mod tests { "Invalid agent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4869,6 +4884,7 @@ mod tests { "Known turn".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, @@ -4934,6 +4950,7 @@ mod tests { "Steering".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), ..Default::default() }, @@ -5548,6 +5565,7 @@ mod tests { "Maintenance retire".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().to_string()), ..Default::default() }, diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index 7bec24c4f0..f9bc200b36 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -85,6 +85,11 @@ pub struct MessageMetadata { /// Execution identity for provider-private replay data produced by a pool member. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_pool_replay_fingerprint: Option, + /// Deferred tool definitions loaded from inside a PTC RunCode call. + /// This is history-scoped: compaction drops it together with the outer + /// RunCode message, requiring the model to load the definition again. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub ptc_loaded_deferred_tool_specs: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/crates/assembly/core/src/agentic/core/mod.rs b/src/crates/assembly/core/src/agentic/core/mod.rs index 971cdc3891..f9ec33425d 100644 --- a/src/crates/assembly/core/src/agentic/core/mod.rs +++ b/src/crates/assembly/core/src/agentic/core/mod.rs @@ -22,6 +22,6 @@ pub use openbitfun_agent_runtime::prompt_markup::{ pub use session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, SessionKind, - SessionModelBindingPolicy, SessionSummary, + SessionModelBindingPolicy, SessionSummary, ToolPresentationMode, }; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/assembly/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs index 1fb291c435..aa67e9aa01 100644 --- a/src/crates/assembly/core/src/agentic/core/session.rs +++ b/src/crates/assembly/core/src/agentic/core/session.rs @@ -3,5 +3,5 @@ pub use openbitfun_agent_runtime::session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, SessionAgentRouteOwner, SessionConfig, SessionContinuationPolicy, SessionKind, - SessionModelBindingPolicy, SessionSummary, + SessionModelBindingPolicy, SessionSummary, ToolPresentationMode, }; diff --git a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs index e31cc53d0f..1d1a49ee8a 100644 --- a/src/crates/assembly/core/src/agentic/execution/compression_tests.rs +++ b/src/crates/assembly/core/src/agentic/execution/compression_tests.rs @@ -101,6 +101,7 @@ async fn prefetch_session( "prefetch test".into(), "Standard".into(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(path.to_string_lossy().into_owned()), ..Default::default() }, @@ -541,6 +542,7 @@ async fn compression_failure_preserves_context_and_success_count() { "compression test".into(), "Standard".into(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(_temp.path().to_string_lossy().into_owned()), ..Default::default() }, 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 d9dd5a2c3c..a431935d51 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -77,6 +77,11 @@ use openbitfun_agent_runtime::prompt_suggestion::{ PROMPT_SUGGESTION_ELIGIBLE_CONTEXT_KEY, }; use openbitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; +#[cfg(feature = "ptc-runtime")] +use openbitfun_agent_tools::{ + PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, PTC_TOOL_PRESENTATION_CONTEXT_KEY, + PTC_TOOL_PRESENTATION_MODE, +}; use openbitfun_ai_adapters::ModelExchangeTraceConfig; use openbitfun_core_types::{ModelRequestContext, SessionModelBindingPolicy}; use openbitfun_runtime_ports::{resolve_permission_mode, PermissionMode, PermissionModeLayers}; @@ -1457,6 +1462,16 @@ impl ExecutionEngine { } else { None }, + ptc_tool_calling_guide: { + #[cfg(feature = "ptc-runtime")] + { + manifest.ptc_tool_calling_guide.clone() + } + #[cfg(not(feature = "ptc-runtime"))] + { + None + } + }, } } @@ -1670,6 +1685,7 @@ impl ExecutionEngine { ¤t_tool_sections, baseline_tool_sections.as_ref(), ), + ptc_tool_calling_guide: current_tool_sections.ptc_tool_calling_guide.clone(), skill_listing, agent_listing, runtime_context, @@ -3439,6 +3455,20 @@ impl ExecutionEngine { deferred_tool_loading_enabled.to_string(), ); execution_context_vars.insert("turn_index".to_string(), context.turn_index.to_string()); + #[cfg(feature = "ptc-runtime")] + if context + .context + .get(PTC_TOOL_PRESENTATION_CONTEXT_KEY) + .is_some_and(|mode| mode.eq_ignore_ascii_case(PTC_TOOL_PRESENTATION_MODE)) + { + // `resolve_tool_manifest` changes the model-facing list to only + // `RunCode`. Preserve the original Agent policy separately so the + // PTC host can authorize nested SDK calls against that policy. + execution_context_vars.insert( + PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY.to_string(), + serde_json::to_string(&allowed_tools).unwrap_or_else(|_| "[]".to_string()), + ); + } let tool_manifest_context_vars = execution_context_vars.clone(); let tool_description_context = tool_context_runtime::build_tool_description_context( @@ -5916,6 +5946,8 @@ mod tests { .collect(), deferred_tool_names: Vec::new(), deferred_tool_summaries: Vec::new(), + #[cfg(feature = "ptc-runtime")] + ptc_tool_calling_guide: None, catalog_generation: 0, } } @@ -5927,6 +5959,7 @@ mod tests { agent_listing: Some("Explore".to_string()), direct_tool_listing: None, deferred_tool_listing: None, + ptc_tool_calling_guide: None, }; let (skill_listing, agent_listing) = skill_agent_listing_reminders(Some(§ions)); diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 285d5ed68b..aa55244fab 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -46,6 +46,7 @@ use openbitfun_ai_adapters::{ use openbitfun_core_types::errors::{AiProviderError, ErrorCategory}; use openbitfun_core_types::ModelResponseReplay; use openbitfun_runtime_ports::PermissionRule; +use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio_util::sync::CancellationToken; @@ -1983,6 +1984,7 @@ impl RoundExecutor { ); let tool_phase_started_at = Instant::now(); + let mut ptc_loaded_deferred_tool_specs_by_tool_id = HashMap::new(); let tool_results = if let Some(tool_pipeline) = &self.tool_pipeline { // Create tool execution context let allowed_tools = context.available_tools.clone(); @@ -2114,16 +2116,26 @@ impl RoundExecutor { duration_ms: None, image_attachments: None, }, + ptc_loaded_deferred_tool_specs: Vec::new(), execution_time_ms: 0, }) .collect() } }; + // Keep PTC's loaded-definition receipt alongside the model result. It is + // projected onto the persisted outer RunCode message below, while the + // assistant-facing result remains unchanged. // Convert to ToolResult, then enforce the aggregate budget for this model round. let tool_results = execution_results .into_iter() .map(|mut execution_result| { + if !execution_result.ptc_loaded_deferred_tool_specs.is_empty() { + ptc_loaded_deferred_tool_specs_by_tool_id.insert( + execution_result.tool_id.clone(), + execution_result.ptc_loaded_deferred_tool_specs.clone(), + ); + } execution_result.result.effective_tool_name = (execution_result.tool_name != execution_result.effective_tool_name) .then_some(execution_result.effective_tool_name); @@ -2153,9 +2165,15 @@ impl RoundExecutor { let tool_result_messages: Vec = tool_results .iter() .map(|result| { - Message::tool_result(result.clone()) + let mut message = Message::tool_result(result.clone()) .with_turn_id(dialog_turn_id.clone()) - .with_round_id(round_id_clone.clone()) + .with_round_id(round_id_clone.clone()); + if let Some(loaded_specs) = + ptc_loaded_deferred_tool_specs_by_tool_id.get(&result.tool_id) + { + message.metadata.ptc_loaded_deferred_tool_specs = loaded_specs.clone(); + } + message }) .collect(); @@ -4704,6 +4722,7 @@ pub(super) mod tests { "Restart subscription session".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.to_string_lossy().into_owned()), workspace_id: Some(workspace_record.id.clone()), model_id: Some(model.id.clone()), diff --git a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs index 06693817e7..f69835c267 100644 --- a/src/crates/assembly/core/src/agentic/fork_agent/mod.rs +++ b/src/crates/assembly/core/src/agentic/fork_agent/mod.rs @@ -85,6 +85,7 @@ mod tests { fn parent_session() -> Session { let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/workspace/project".to_string()), remote_connection_id: Some("remote-1".to_string()), remote_ssh_host: Some("prod-box".to_string()), diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index 28a1d26ba1..67d661d920 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3311,6 +3311,7 @@ impl PersistenceManager { .unwrap_or(StoredSessionStateFile { schema_version: SESSION_STORAGE_SCHEMA_VERSION, config: SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: None, ..Default::default() }, @@ -5501,6 +5502,7 @@ mod tests { "Model summary".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), model_id: Some("fast".to_string()), reasoning_preset: Some("high".to_string()), @@ -5533,6 +5535,7 @@ mod tests { ); let session_id = format!("concurrent-session-{}", Uuid::new_v4()); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -6056,6 +6059,7 @@ mod tests { "Load once".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6173,6 +6177,7 @@ mod tests { "Observer history".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6386,6 +6391,7 @@ mod tests { "Catalog persistence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6466,6 +6472,7 @@ mod tests { "Catalog restore".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6557,6 +6564,7 @@ mod tests { "Legacy catalog window".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6647,6 +6655,7 @@ mod tests { "Catalog missing file".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6702,6 +6711,7 @@ mod tests { "Incremental catalog repair".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6811,6 +6821,7 @@ mod tests { "Catalog rebuild fallback".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -6895,6 +6906,7 @@ mod tests { "History benchmark".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into()), ..Default::default() }, @@ -7001,6 +7013,7 @@ mod tests { "Paged".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into()), ..Default::default() }, @@ -7057,6 +7070,7 @@ mod tests { "Catalog staged revert".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7163,6 +7177,7 @@ mod tests { "Turn window".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7318,6 +7333,7 @@ mod tests { "Compression transcripts".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7459,6 +7475,7 @@ mod tests { "Concurrent metadata".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7534,6 +7551,7 @@ mod tests { "Incremental metadata".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7611,6 +7629,7 @@ mod tests { "Transactional deletion".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7683,6 +7702,7 @@ mod tests { "Transactional session deletion".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7729,6 +7749,7 @@ mod tests { "Canonical metadata lock".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7762,6 +7783,7 @@ mod tests { "Finished timestamp metadata".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -7810,6 +7832,7 @@ mod tests { "Concurrent metadata".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -8814,6 +8837,7 @@ mod tests { .context_for_remote_workspace("dev-host", &remote_path) .sessions_dir; let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(remote_workspace.id.clone()), workspace_path: Some(remote_path.clone()), remote_connection_id: Some("ssh-1".to_string()), 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 451d43eb5f..0fdf4936ac 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -7,7 +7,7 @@ use crate::agentic::core::{ new_turn_id, CompressionContract, CompressionState, InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, ProcessingPhase, Session, SessionAgentRouteOwner, SessionConfig, SessionKind, SessionModelBindingPolicy, SessionState, - SessionSummary, TurnStats, + SessionSummary, ToolPresentationMode, TurnStats, }; use crate::agentic::fork_agent::normalize_incomplete_tool_calls; use crate::agentic::image_analysis::ImageContextData; @@ -77,6 +77,8 @@ use std::time::{Duration, SystemTime}; use tokio::sync::{OwnedSemaphorePermit, Semaphore}; use tokio::time; +const TOOL_PRESENTATION_MODE_CONFIG_PATH: &str = "ai.tool_presentation_mode"; + #[cfg(test)] tokio::task_local! { pub(crate) static TEST_MODEL_RESOLUTION_AI_CONFIG: crate::service::config::types::AIConfig; @@ -1536,6 +1538,7 @@ impl SessionManager { workspace_hint: Option<&WorkspaceInfo>, ) -> Option { let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: metadata .workspace_id .clone() @@ -2755,6 +2758,21 @@ impl SessionManager { transient: bool, ) -> OpenBitFunResult { let mut config = config; + // The global setting is sampled exactly once while creating a session. + // A caller may provide an explicit PTC value (used by delegated/runtime + // paths); legacy/default native values follow the current global + // default. Persisting the resolved value makes existing sessions + // independent from later global changes. + if config.tool_presentation_mode.is_native() { + if let Ok(config_service) = get_global_config_service().await { + if let Ok(mode) = config_service + .get_config::(Some(TOOL_PRESENTATION_MODE_CONFIG_PATH)) + .await + { + config.tool_presentation_mode = mode; + } + } + } crate::agentic::workspace::normalize_session_workspace(&mut config).await?; let _workspace_path = Self::session_workspace_from_config(&config).ok_or_else(|| { OpenBitFunError::Validation("Session workspace_path is required".to_string()) @@ -10816,6 +10834,7 @@ mod tests { "Background delivery".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -11069,6 +11088,7 @@ mod tests { "Completion merge".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -11250,6 +11270,7 @@ mod tests { "Usage persistence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -11296,6 +11317,7 @@ mod tests { "Recovery".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -11438,6 +11460,7 @@ mod tests { "Recovery permission".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), permission_mode: Some(PermissionMode::FullAccess), ..SessionConfig::default() @@ -11498,6 +11521,7 @@ mod tests { "Recovery reasoning preset".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), reasoning_preset: Some("high".to_string()), ..SessionConfig::default() @@ -11561,6 +11585,7 @@ mod tests { "Recovery auto reasoning".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("model-original".to_string()), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() @@ -11627,6 +11652,7 @@ mod tests { "Recovery model binding".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("model-original".to_string()), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() @@ -11693,6 +11719,7 @@ mod tests { "Turn admission CAS".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), model_id: Some("model-original".to_string()), reasoning_preset: Some("high".to_string()), @@ -11739,6 +11766,7 @@ mod tests { "Turn admission context window CAS".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), model_id: Some("model-original".to_string()), max_context_tokens: 128_128, @@ -11789,6 +11817,7 @@ mod tests { "Turn admission route CAS".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -11836,6 +11865,7 @@ mod tests { "Turn admission route key CAS".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), agent_route_key: Some("local:agentic:v1".to_string()), ..SessionConfig::default() @@ -11885,6 +11915,7 @@ mod tests { "Turn admission workspace CAS".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(original_workspace.clone()), project_workspace_path: Some(original_workspace.clone()), execution_target: Some(SessionExecutionTarget::local( @@ -11943,6 +11974,7 @@ mod tests { "Recovery rollback".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12076,6 +12108,7 @@ mod tests { "Recovery append".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12195,6 +12228,7 @@ mod tests { "Repeated recovery".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12317,6 +12351,7 @@ mod tests { "Interrupted merge".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12401,6 +12436,7 @@ mod tests { "Recovery restart".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("primary".to_string()), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() @@ -12495,6 +12531,7 @@ mod tests { "ACP usage".to_string(), "acp:codex".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12537,6 +12574,7 @@ mod tests { "Delayed usage".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12603,6 +12641,7 @@ mod tests { "Binding race".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() }, @@ -12650,6 +12689,7 @@ mod tests { "View-only binding".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), project_workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() @@ -12703,6 +12743,7 @@ mod tests { "Boundary zero binding".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), project_workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..SessionConfig::default() @@ -12806,6 +12847,7 @@ mod tests { }, ); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -12858,6 +12900,7 @@ mod tests { "Single writer".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -12871,6 +12914,7 @@ mod tests { "Duplicate".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -12917,6 +12961,7 @@ mod tests { PersistenceManager::new(path_manager.clone()).expect("fixture persistence manager"), ); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -12972,6 +13017,7 @@ mod tests { "Aliased workspace".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13009,6 +13055,7 @@ mod tests { "Recovered fixture".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13043,6 +13090,7 @@ mod tests { "Failed fixture".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13059,6 +13107,7 @@ mod tests { "Recovered fixture".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13083,6 +13132,7 @@ mod tests { "Unload failure".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13120,6 +13170,7 @@ mod tests { "Processing".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13154,6 +13205,7 @@ mod tests { let workspace = TestWorkspace::new(); let manager = in_memory_test_manager(); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -13292,6 +13344,7 @@ mod tests { "Connection Session".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13320,6 +13373,7 @@ mod tests { "Prepared Subagent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13345,6 +13399,7 @@ mod tests { PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), ); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -13410,6 +13465,7 @@ mod tests { }, )); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -13461,6 +13517,7 @@ mod tests { "Unavailable mode".to_string(), "removed-mode-that-cannot-exist".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13507,6 +13564,7 @@ mod tests { "Unavailable mode".to_string(), "removed-mode-that-cannot-exist".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -13545,6 +13603,7 @@ mod tests { "Cold selector".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("primary".into()), ..Default::default() @@ -13745,6 +13804,7 @@ mod tests { "Legacy".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("primary".into()), ..Default::default() @@ -13828,6 +13888,7 @@ mod tests { "Persisted model update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("primary".to_string()), ..Default::default() @@ -13861,6 +13922,7 @@ mod tests { "Legacy model selector".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("auto".to_string()), ..Default::default() @@ -13910,6 +13972,7 @@ mod tests { "Failed model update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("primary".to_string()), ..Default::default() @@ -13998,6 +14061,7 @@ mod tests { let manager = in_memory_test_manager(); let session_id = "reusable-session-id"; let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -14052,6 +14116,7 @@ mod tests { let manager = test_manager(persistence_manager); let session_id = "persisted-session-id"; let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -14101,6 +14166,7 @@ mod tests { "Invalid".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14123,6 +14189,7 @@ mod tests { persistence_manager.fail_next_session_state_write_for_test(session_id); let manager = test_manager(persistence_manager.clone()); let config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }; @@ -14174,6 +14241,7 @@ mod tests { "Storage binding".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14248,6 +14316,7 @@ mod tests { "Original".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14300,6 +14369,7 @@ mod tests { "New Session".into(), "Claw".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -14338,6 +14408,7 @@ mod tests { "Original".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14370,6 +14441,7 @@ mod tests { "Original".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -14403,6 +14475,7 @@ mod tests { "Original".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -14448,6 +14521,7 @@ mod tests { "Original".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -14485,6 +14559,7 @@ mod tests { "Active".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14528,6 +14603,7 @@ mod tests { "Active".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14571,6 +14647,7 @@ mod tests { "Serialized".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path), ..Default::default() }, @@ -14599,6 +14676,7 @@ mod tests { "Persisted".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14638,6 +14716,7 @@ mod tests { "Serialized mode update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14675,6 +14754,7 @@ mod tests { "Serialized compression update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14718,6 +14798,7 @@ mod tests { "Serialized turn start".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14762,6 +14843,7 @@ mod tests { "Idempotent mode update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14800,6 +14882,7 @@ mod tests { "Durable mode update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14838,6 +14921,7 @@ mod tests { "Durable external route".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14925,6 +15009,7 @@ mod tests { "Metadata-only mode update".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -14980,6 +15065,7 @@ mod tests { "Parent".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("model-a".into()), model_pool_id: Some("pool-a".into()), @@ -15138,6 +15224,7 @@ mod tests { "Reasoning selection".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("model-a".to_string()), reasoning_preset: Some("high".to_string()), ..Default::default() @@ -15168,6 +15255,7 @@ mod tests { "Stale reasoning preset".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some("model-b".to_string()), reasoning_preset: Some("obsolete".to_string()), @@ -15235,6 +15323,7 @@ mod tests { "Remote session".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), model_id: Some("deepseek-v4-flash".to_string()), max_context_tokens: 128_128, @@ -15266,6 +15355,7 @@ mod tests { "DeepSeek session".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("deepseek-v4-pro".to_string()), max_context_tokens: 256_000, ..Default::default() @@ -15305,6 +15395,7 @@ mod tests { "Default session".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: None, max_context_tokens: 256_000, ..Default::default() @@ -15362,6 +15453,7 @@ mod tests { "Default subagent".to_string(), "Explore".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: None, max_context_tokens: 256_000, ..Default::default() @@ -15395,6 +15487,7 @@ mod tests { "Auto-save snapshot".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15543,6 +15636,7 @@ mod tests { "Voice".into(), "agent".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15619,6 +15713,7 @@ mod tests { "Voice".into(), "agent".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15650,6 +15745,7 @@ mod tests { "Usage session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15711,6 +15807,7 @@ mod tests { "Serialized local command".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15756,6 +15853,7 @@ mod tests { "Legacy processing session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15803,6 +15901,7 @@ mod tests { "Side thread".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15840,6 +15939,7 @@ mod tests { "Main thread".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -15877,6 +15977,7 @@ mod tests { "Review child".to_string(), "CodeReview".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16216,6 +16317,7 @@ mod tests { "Bound writes".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(workspace.workspace_id().into()), ..Default::default() }, @@ -16280,6 +16382,7 @@ mod tests { "Bound history".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(workspace.workspace_id().into()), ..Default::default() }, @@ -16459,6 +16562,7 @@ mod tests { "Resolved sessions restore".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16491,6 +16595,7 @@ mod tests { "Resolved sessions restore".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16531,6 +16636,7 @@ mod tests { "Remote identity restore".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/home/wsp/project".to_string()), remote_connection_id: Some("ssh-1".to_string()), remote_ssh_host: Some("dev-host".to_string()), @@ -16571,6 +16677,7 @@ mod tests { "Large history".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16645,6 +16752,7 @@ mod tests { "Legacy model".into(), agent.into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), model_id: Some(metadata_model.into()), ..Default::default() @@ -16714,6 +16822,7 @@ mod tests { "Fork child".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16792,6 +16901,7 @@ mod tests { "Atomic admission".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16848,6 +16958,7 @@ mod tests { "Retry admission".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16903,6 +17014,7 @@ mod tests { "Failed maintenance".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -16961,6 +17073,7 @@ mod tests { "History with tool output".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17097,6 +17210,7 @@ mod tests { "Rollback session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17332,6 +17446,7 @@ mod tests { "Staged revert".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17460,6 +17575,7 @@ mod tests { "Rollback failure".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17525,6 +17641,7 @@ mod tests { "Serialized rollback".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17595,6 +17712,7 @@ mod tests { "Skill agent snapshot".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17689,6 +17807,7 @@ mod tests { "Listing baseline rebuild".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17816,6 +17935,7 @@ mod tests { "Deferred listing baseline".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17894,6 +18014,7 @@ mod tests { "Restore sanitize".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -17997,6 +18118,7 @@ mod tests { "Rollback sanitize".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18123,6 +18245,7 @@ mod tests { "Rollback empty history".to_string(), "Cowork".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18180,6 +18303,7 @@ mod tests { "Cached session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18228,6 +18352,7 @@ mod tests { "Resolved storage session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18259,6 +18384,7 @@ mod tests { "Evicted cleanup session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18294,6 +18420,7 @@ mod tests { "Bound session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18325,6 +18452,7 @@ mod tests { "Failure atomic session".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18375,6 +18503,7 @@ mod tests { "Pending revert delete".to_string(), "agent".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -18526,6 +18655,7 @@ mod tests { "Durable evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18630,6 +18760,7 @@ mod tests { "Staged evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -18710,6 +18841,7 @@ mod tests { "Staged revert evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19155,6 +19287,7 @@ mod tests { "Staged revert explicit history evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19294,6 +19427,7 @@ mod tests { "Rollback evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19431,6 +19565,7 @@ mod tests { "Evidence write failure".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19471,6 +19606,7 @@ mod tests { "Concurrent evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19552,6 +19688,7 @@ mod tests { "Corrupt evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19596,6 +19733,7 @@ mod tests { "Corrupt retention evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19641,6 +19779,7 @@ mod tests { "Remote evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/home/wsp/project".to_string()), remote_connection_id: Some("ssh-1".to_string()), remote_ssh_host: Some("dev-host".to_string()), @@ -19696,6 +19835,7 @@ mod tests { "Converge evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19816,6 +19956,7 @@ mod tests { "Fork evidence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -19934,6 +20075,7 @@ mod tests { "Prompt cache".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path), ..Default::default() }, @@ -19992,6 +20134,7 @@ mod tests { "Listing baseline".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -20150,6 +20293,7 @@ mod tests { "Edit constraint persistence".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -20275,6 +20419,7 @@ mod tests { "Parent".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -20286,6 +20431,7 @@ mod tests { "Child".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -20376,6 +20522,7 @@ mod tests { "Prompt cache".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path), ..Default::default() }, @@ -20445,6 +20592,7 @@ mod tests { "Prompt cache".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -20528,6 +20676,7 @@ mod tests { "Prompt cache source".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.clone()), ..Default::default() }, @@ -20539,6 +20688,7 @@ mod tests { "Prompt cache target".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path), ..Default::default() }, @@ -20620,6 +20770,7 @@ mod tests { "Prompt cache".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path), ..Default::default() }, diff --git a/src/crates/assembly/core/src/agentic/session/session_store_port.rs b/src/crates/assembly/core/src/agentic/session/session_store_port.rs index 502392257a..a5939ae6a9 100644 --- a/src/crates/assembly/core/src/agentic/session/session_store_port.rs +++ b/src/crates/assembly/core/src/agentic/session/session_store_port.rs @@ -312,6 +312,7 @@ impl SessionStorePort for CoreSessionStorePort { let workspace_path = request.workspace_path.to_string_lossy().to_string(); let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.clone()), remote_connection_id: request.remote_connection_id, remote_ssh_host: request.remote_ssh_host, diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs index 125b29411c..3431c32897 100644 --- a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -166,6 +166,16 @@ fn build_tool_listing_sections( } else { None }, + ptc_tool_calling_guide: { + #[cfg(feature = "ptc-runtime")] + { + manifest.ptc_tool_calling_guide.clone() + } + #[cfg(not(feature = "ptc-runtime"))] + { + None + } + }, } } @@ -301,6 +311,8 @@ mod tests { name: "WebFetch".to_string(), short_description: Some("Fetch a web page".to_string()), }], + #[cfg(feature = "ptc-runtime")] + ptc_tool_calling_guide: None, catalog_generation: 7, }; diff --git a/src/crates/assembly/core/src/agentic/tools/framework.rs b/src/crates/assembly/core/src/agentic/tools/framework.rs index 4e33f76477..796e43de3b 100644 --- a/src/crates/assembly/core/src/agentic/tools/framework.rs +++ b/src/crates/assembly/core/src/agentic/tools/framework.rs @@ -43,6 +43,22 @@ pub trait Tool: Send + Sync { /// Input mode definition - using JSON Schema fn input_schema(&self) -> Value; + /// JSON Schema for the assistant-facing portion of the value returned to a + /// PTC program. + /// + /// This is intentionally a compact semantic projection, rather than a + /// mirror of every field in the internal/canonical result. Include fields + /// that are present in `result_for_assistant` or that the model needs for + /// control flow (for example a continuation cursor or an operation id). + /// Omit UI-only, diagnostic, persistence, and transport metadata. The + /// canonical result may still contain those fields at runtime; they are + /// simply not part of the model-facing SDK contract. The default is an + /// open/unknown JSON value so existing tools remain compatible while + /// output contracts are added incrementally. + fn output_schema(&self) -> Value { + Value::Object(serde_json::Map::new()) + } + /// JSON Schema sent to the model (may depend on app language or other runtime config). /// Default: same as [`input_schema`]. async fn input_schema_for_model(&self) -> Value { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_control_tool.rs index f68878ca0f..99860c0ffc 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_control_tool.rs @@ -161,6 +161,20 @@ impl Tool for AgentControlTool { Self::input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": {"type": "string"}, + "agent_ids": {"type": "array", "items": {"type": "string"}}, + "interrupted_background_tasks": {"type": "integer"}, + "deleted_agents": {"type": "integer"} + }, + "required": ["action"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs index 71bcc387c4..bb49226a36 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_list_tool.rs @@ -52,7 +52,8 @@ impl AgentListTool { for agent_id in &reserved_ids { assistant_text.push_str(&format!("\n- {agent_id}")); } - assistant_text.push_str("\n\nAll reserved IDs listed above are unavailable for AgentSpawn."); + assistant_text + .push_str("\n\nAll reserved IDs listed above are unavailable for AgentSpawn."); data["reserved_agent_ids"] = json!(reserved_ids); } Ok(vec![ToolResult::Result { @@ -112,6 +113,29 @@ impl Tool for AgentListTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_id": {"type": "string"}, + "status": {"type": "string"} + }, + "required": ["agent_id", "status"], + "additionalProperties": false + } + }, + "reserved_agent_ids": {"type": "array", "items": {"type": "string"}} + }, + "required": ["agents"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -277,6 +301,7 @@ mod tests { "Parent".into(), "Standard".into(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().into_owned()), ..Default::default() }, @@ -335,7 +360,9 @@ mod tests { assert_eq!(data["reserved_agent_ids"], serde_json::json!([deleted_id])); assert!(text.contains("Reserved IDs without active agents (1):")); assert!(text.contains(&deleted_id)); - assert!(text.contains("All reserved IDs listed above are unavailable for AgentSpawn.")); + assert!( + text.contains("All reserved IDs listed above are unavailable for AgentSpawn.") + ); } else { assert!(data.get("reserved_agent_ids").is_none()); assert!(!text.contains(&deleted_id)); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs index a33b5a10de..cd12f1531e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/agent_wait_tool.rs @@ -189,6 +189,36 @@ impl Tool for AgentWaitTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "string"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bg_task_id": {"type": "string"}, + "agent_id": {"type": "string"}, + "outcome": {"type": "string"}, + "content": {}, + "error": {} + }, + "required": ["bg_task_id", "agent_id", "outcome"], + "additionalProperties": false + } + }, + "pending_bg_task_ids": {"type": "array", "items": {"type": "string"}}, + "already_delivered_bg_task_ids": {"type": "array", "items": {"type": "string"}}, + "missing_bg_task_ids": {"type": "array", "items": {"type": "string"}}, + "invalid_bg_task_inputs": {"type": "array", "items": {}} + }, + "required": ["status", "results", "pending_bg_task_ids", "already_delivered_bg_task_ids", "missing_bg_task_ids", "invalid_bg_task_inputs"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs index efb940a394..3d7bd3a887 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/analyze_image_tool.rs @@ -247,6 +247,15 @@ impl Tool for AnalyzeImageTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": {"analysis": {"type": "string"}}, + "required": ["analysis"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs index 9f1a31b405..17114cb4f6 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/appearance_publish_tool.rs @@ -90,6 +90,22 @@ This is an outward-facing publication action. Use it only when the user explicit }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["sign_in_required", "pending_review", "submitted"]}, + "authorization_url": {"type": "string"}, + "slug": {"type": "string"}, + "submission_id": {"type": "string"}, + "release_number": {"type": "integer"}, + "name": {"type": "string"} + }, + "required": ["status"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 0801a3354d..f60bbf8b8a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -219,6 +219,43 @@ Usage notes: }) } + fn output_schema(&self) -> Value { + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "question": {"type": "string"}, + "header": {"type": "string"} + }, + "required": ["question", "header"], + "additionalProperties": false + } + }, + "answers": {"type": "object"}, + "status": {"const": "answered"} + }, + "required": ["questions", "answers", "status"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "questions_count": {"type": "integer"}, + "status": {"enum": ["cancelled", "timeout"]} + }, + "required": ["questions_count", "status"], + "additionalProperties": false + } + ] + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs index 0b8568d505..2fddfca5c4 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/canvas_tools.rs @@ -121,6 +121,10 @@ Returns a stable `openbitfun-canvas://...` artifact reference for subsequent Can }) } + fn output_schema(&self) -> Value { + canvas_output_schema() + } + async fn call_impl( &self, input: &Value, @@ -227,6 +231,10 @@ Provide either `artifact_reference` returned by CreateCanvas/PatchCanvas/UpdateC }) } + fn output_schema(&self) -> Value { + canvas_output_schema() + } + fn is_readonly(&self) -> bool { true } @@ -315,6 +323,10 @@ Provide either `artifact_reference` or `canvas_id`, plus one complete replacemen }) } + fn output_schema(&self) -> Value { + canvas_output_schema() + } + async fn call_impl( &self, input: &Value, @@ -409,6 +421,10 @@ Use this for small, targeted edits such as changing a label, number, style prop, }) } + fn output_schema(&self) -> Value { + canvas_output_schema() + } + async fn call_impl( &self, input: &Value, @@ -517,6 +533,24 @@ fn canvas_tool_result(action: &str, snapshot: &CanvasSnapshot, compiled: bool) - } } +fn canvas_output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string", "enum": ["created", "read", "updated", "patched"]}, + "artifactReference": {"type": "string"}, + "compiled": {"type": "boolean"}, + "renderValidated": {"type": "boolean"}, + "diagnosticCount": {"type": "integer"}, + "status": {"type": "string"}, + "diagnostics": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "source": {"type": "object", "additionalProperties": true} + }, + "additionalProperties": true + }) +} + fn canvas_result_for_assistant( action: &str, reference: &str, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs index 2bea387c37..5d1e1844df 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/code_review_tool.rs @@ -612,6 +612,16 @@ impl Tool for CodeReviewTool { Self::input_schema_value() } + fn output_schema(&self) -> Value { + // The normalized review packet is an internal/UI payload and the + // assistant-facing result is a fixed acknowledgement, so no + // canonical fields are projected into the PTC SDK here. + json!({ + "type": "object", + "additionalProperties": true + }) + } + async fn input_schema_for_model(&self) -> Value { let lang = get_app_language_code().await; Self::input_schema_value_for_language(lang.as_str()) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index 791d2ebe37..066daff3c3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -1671,6 +1671,30 @@ Use ExecCommand for genuine shell work, never ad hoc GUI capture, clipboard or i }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string"}, + "status": {"type": "string"}, + "observation": {"type": "object", "additionalProperties": true}, + "screenshot": {"type": "object", "additionalProperties": true}, + "screenshot_meta": {"type": "object", "additionalProperties": true}, + "screenshot_id": {"type": "string"}, + "windows": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "apps": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "steps": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "partial": {"type": "boolean"}, + "warning": {"type": "string"}, + "error": {"type": "string"}, + "control_guidance": {"type": "string"}, + "suggested_next_action": {"type": "object", "additionalProperties": true} + }, + "additionalProperties": true + }) + } + async fn input_schema_for_model_with_context(&self, context: Option<&ToolUseContext>) -> Value { let vision = context .map(|c| c.primary_model_supports_image_understanding()) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index f4b66c817a..3af34ced64 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -3648,6 +3648,30 @@ impl Tool for ControlHubTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "ok": {"type": "boolean"}, + "domain": {"type": "string", "enum": ["browser", "terminal", "meta"]}, + "action": {"type": "string"}, + "summary": {"type": "string"}, + "data": { + "anyOf": [ + {"type": "null"}, {"type": "boolean"}, {"type": "number"}, + {"type": "string"}, {"type": "array", "items": {}}, + {"type": "object", "additionalProperties": true} + ] + }, + "error": {"type": "object", "additionalProperties": true}, + "target": {"type": "string"}, + "session_id": {"type": "string"} + }, + "required": ["ok", "domain", "action"], + "additionalProperties": false + }) + } + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 2343113004..4221b93a29 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -809,6 +809,42 @@ Patch schema for "update": }) } + fn output_schema(&self) -> Value { + let job = json!({ + "type": "object", + "properties": { + "id": {"type": "string"}, + "name": {"type": "string"}, + "schedule": {"type": "object", "additionalProperties": true}, + "enabled": {"type": "boolean"}, + "sessionId": {"type": "string"}, + "workspacePath": {"type": "string"} + }, + "required": ["id", "name", "schedule", "enabled", "sessionId", "workspacePath"], + "additionalProperties": false + }); + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string", "enum": ["get_time", "list", "add", "update", "remove", "run", "report"]}, + "now": {"type": "string", "description": "Current local time in RFC 3339 format for get_time."}, + "workspace": {"type": "string"}, + "session_id": {"type": "string"}, + "count": {"type": "integer"}, + "job_id": {"type": "string"}, + "deleted": {"type": "boolean"}, + "report": {"type": "object", "additionalProperties": true}, + "jobs": { + "type": "array", + "items": job + }, + "job": job + }, + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs index 1ce4f94a26..72004ca9ec 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -107,6 +107,19 @@ Important notes: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "path": {"type": "string"}, + "is_directory": {"type": "boolean"} + }, + "required": ["success", "path", "is_directory"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs index a32dc322da..012bc0213e 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/desktop_card_tool.rs @@ -1032,6 +1032,23 @@ Cards are shown only on this desktop. They are unavailable for remote mobile or }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string", "enum": ["create", "update", "list", "get", "show", "hide", "delete", "refresh", "presets"]}, + "card": {"type": "object", "additionalProperties": true}, + "cards": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "presets": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "card_id": {"type": "string"}, + "visible": {"type": "boolean"}, + "data": {"type": "object", "additionalProperties": true} + }, + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs index 2ecd68cd93..b40f5227a2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/command.rs @@ -730,6 +730,29 @@ Output: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "wall_time_seconds": {"type": "number"}, + "output": {"type": "string"}, + "session_id": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "exit_code": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "completion": { + "anyOf": [ + {"type": "null"}, + {"type": "object", "properties": { + "source": {"type": "string"}, + "status": {"type": "string"} + }, "additionalProperties": false} + ] + } + }, + "required": ["wall_time_seconds", "output", "session_id", "exit_code", "completion"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs index 8351b55ad8..b3d5816f16 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/control.rs @@ -282,6 +282,20 @@ Output is only what was produced during this tool call's wait window."# }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "wall_time_seconds": {"type": "number"}, + "output": {"type": "string"}, + "session_id": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "exit_code": {"anyOf": [{"type": "integer"}, {"type": "null"}]} + }, + "required": ["wall_time_seconds", "output", "session_id", "exit_code"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs index 864eb81ae2..e34a4b6053 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/exec_command/stdin.rs @@ -149,6 +149,21 @@ Output is only what was produced during this tool call's wait window."# }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "wall_time_seconds": {"type": "number"}, + "output": {"type": "string"}, + "session_id": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "exit_code": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "completion": {"anyOf": [{"type": "object", "properties": {"source": {"type": "string"}, "status": {"type": "string"}}, "additionalProperties": false}, {"type": "null"}]} + }, + "required": ["wall_time_seconds", "output", "session_id", "exit_code", "completion"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs index 6c17994433..e2eaa5d59c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -103,6 +103,19 @@ impl Tool for FileEditTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "success": {"type": "boolean"}, + "match_count": {"type": "integer"} + }, + "required": ["file_path", "success"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs index 84ee1c2d7a..9684b339bd 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_read_tool.rs @@ -420,6 +420,24 @@ Returns numbered lines (line number, tab, text). Choose the window needed for th schema } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "content": {"type": "string"}, + "total_lines": {"type": "integer"}, + "lines_read": {"type": "integer"}, + "offset": {"type": "integer"}, + "content_truncated": {"type": "boolean"}, + "truncated_lines": {"type": "array", "items": {"type": "integer"}}, + "next_offset": {"anyOf": [{"type": "integer"}, {"type": "null"}]} + }, + "required": ["file_path", "content", "total_lines", "lines_read", "offset", "content_truncated", "truncated_lines", "next_offset"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -670,14 +688,16 @@ Returns numbered lines (line number, tab, text). Choose the window needed for th "total_lines": read_file_result.total_lines, "lines_read": presentation.lines_read, "offset": read_file_result.start_line, - "tail": tail, - "start_line": read_file_result.start_line, - "size": read_file_result.content.len(), - "hit_total_char_limit": read_file_result.hit_total_char_limit, "content_truncated": read_file_result.content_truncated, "truncated_lines": read_file_result.truncated_lines, "next_offset": presentation.next_offset, - "representation": "miniapp_context" + "diagnostics": { + "tail": tail, + "start_line": read_file_result.start_line, + "size": read_file_result.content.len(), + "hit_total_char_limit": read_file_result.hit_total_char_limit, + "representation": "miniapp_context" + } }), result_for_assistant: Some(presentation.result_for_assistant), image_attachments: None, @@ -821,30 +841,32 @@ Returns numbered lines (line number, tab, text). Choose the window needed for th "total_lines": read_file_result.total_lines, "lines_read": presentation.lines_read, "offset": read_file_result.start_line, - "tail": tail, - "start_line": read_file_result.start_line, - "size": read_file_result.content.len(), - "hit_total_char_limit": read_file_result.hit_total_char_limit, "content_truncated": read_file_result.content_truncated, "truncated_lines": read_file_result.truncated_lines, - "next_offset": presentation.next_offset + "next_offset": presentation.next_offset, + "diagnostics": { + "tail": tail, + "start_line": read_file_result.start_line, + "size": read_file_result.content.len(), + "hit_total_char_limit": read_file_result.hit_total_char_limit + } }); if let Some(metadata) = document_metadata { - data["representation"] = json!("extracted_markdown"); - data["source_format"] = json!(metadata.source_format); - data["source_size_bytes"] = json!(metadata.source_size_bytes); + data["diagnostics"]["representation"] = json!("extracted_markdown"); + data["diagnostics"]["source_format"] = json!(metadata.source_format); + data["diagnostics"]["source_size_bytes"] = json!(metadata.source_size_bytes); let pagination = metadata.pagination; - data["page_kind"] = json!(pagination.page_kind); - data["page_count"] = json!(pagination.page_count); - data["selected_pages"] = json!(pagination.selected_pages); - data["selected_page_count"] = json!(pagination.selected_page_count); - data["next_page"] = json!(pagination.next_page); + data["diagnostics"]["page_kind"] = json!(pagination.page_kind); + data["diagnostics"]["page_count"] = json!(pagination.page_count); + data["diagnostics"]["selected_pages"] = json!(pagination.selected_pages); + data["diagnostics"]["selected_page_count"] = json!(pagination.selected_page_count); + data["diagnostics"]["next_page"] = json!(pagination.next_page); let warnings = if let Some(coverage) = metadata.pdf_coverage { - data["conversion_engine"] = json!("pdf-inspector"); - data["extraction_status"] = json!(coverage.status()); - data["page_count"] = json!(coverage.page_count); - data["extracted_pages"] = json!(coverage.extracted_pages); - data["pages_needing_ocr"] = json!(coverage.pages_needing_ocr); + data["diagnostics"]["conversion_engine"] = json!("pdf-inspector"); + data["diagnostics"]["extraction_status"] = json!(coverage.status()); + data["diagnostics"]["page_count"] = json!(coverage.page_count); + data["diagnostics"]["extracted_pages"] = json!(coverage.extracted_pages); + data["diagnostics"]["pages_needing_ocr"] = json!(coverage.pages_needing_ocr); let mut warnings = vec![ "Coverage describes native text extraction; images and diagrams may require visual inspection." .to_string(), @@ -866,7 +888,7 @@ Returns numbered lines (line number, tab, text). Choose the window needed for th ); warnings } else { - data["conversion_engine"] = json!("anydoc"); + data["diagnostics"]["conversion_engine"] = json!("anydoc"); let warning = "Embedded images and objects are represented by their available text."; let mut warnings = vec![warning.to_string()]; @@ -883,7 +905,7 @@ Returns numbered lines (line number, tab, text). Choose the window needed for th ); warnings }; - data["extraction_warnings"] = json!(warnings); + data["diagnostics"]["extraction_warnings"] = json!(warnings); let unit_hint = match pagination.page_kind { "pdf_page" => "original PDF pages", "slide" => "slides in presentation order", @@ -1445,10 +1467,10 @@ mod tests { else { panic!("expected result"); }; - assert_eq!(data["representation"], "extracted_markdown"); - assert_eq!(data["source_format"], "rtf"); - assert_eq!(data["conversion_engine"], "anydoc"); - assert_eq!(data["source_size_bytes"], source.len()); + assert_eq!(data["diagnostics"]["representation"], "extracted_markdown"); + assert_eq!(data["diagnostics"]["source_format"], "rtf"); + assert_eq!(data["diagnostics"]["conversion_engine"], "anydoc"); + assert_eq!(data["diagnostics"]["source_size_bytes"], source.len()); assert!(data["content"] .as_str() .is_some_and(|content| content.contains("Hello from the document"))); @@ -1499,7 +1521,7 @@ mod tests { let message = result_for_assistant.as_deref().unwrap(); assert!(message.contains("No text was extracted")); assert!(!message.contains("empty.rtf is empty")); - assert!(data["extraction_warnings"] + assert!(data["diagnostics"]["extraction_warnings"] .as_array() .unwrap() .iter() @@ -1530,19 +1552,19 @@ mod tests { else { panic!("expected PDF result"); }; - assert_eq!(data["representation"], "extracted_markdown"); - assert_eq!(data["source_format"], "pdf"); - assert_eq!(data["conversion_engine"], "pdf-inspector"); - assert_eq!(data["source_size_bytes"], source.len()); - assert!(data["extraction_warnings"] + assert_eq!(data["diagnostics"]["representation"], "extracted_markdown"); + assert_eq!(data["diagnostics"]["source_format"], "pdf"); + assert_eq!(data["diagnostics"]["conversion_engine"], "pdf-inspector"); + assert_eq!(data["diagnostics"]["source_size_bytes"], source.len()); + assert!(data["diagnostics"]["extraction_warnings"] .as_array() .unwrap() .iter() .any(|warning| warning.as_str().unwrap().contains("native text extraction"))); - assert_eq!(data["extraction_status"], "complete"); - assert_eq!(data["page_count"], 1); - assert_eq!(data["extracted_pages"], json!([1])); - assert_eq!(data["pages_needing_ocr"], json!([])); + assert_eq!(data["diagnostics"]["extraction_status"], "complete"); + assert_eq!(data["diagnostics"]["page_count"], 1); + assert_eq!(data["diagnostics"]["extracted_pages"], json!([1])); + assert_eq!(data["diagnostics"]["pages_needing_ocr"], json!([])); let content = data["content"].as_str().expect("content"); assert!(content.contains("First document paragraph."), "{content}"); assert!(content.contains("Second document paragraph."), "{content}"); @@ -1570,9 +1592,9 @@ mod tests { assert_eq!(data["offset"], total_lines); assert_eq!(data["total_lines"], total_lines); assert_eq!(data["lines_read"], 1); - assert_eq!(data["representation"], "extracted_markdown"); + assert_eq!(data["diagnostics"]["representation"], "extracted_markdown"); assert_eq!(data["next_offset"], Value::Null); - assert_eq!(data["extracted_pages"], json!([1])); + assert_eq!(data["diagnostics"]["extracted_pages"], json!([1])); } } @@ -1604,11 +1626,11 @@ mod tests { else { panic!("expected partial extraction"); }; - assert_eq!(data["source_format"], "pdf"); - assert_eq!(data["extraction_status"], "partial"); - assert_eq!(data["page_count"], 3); - assert_eq!(data["extracted_pages"], json!([1, 3])); - assert_eq!(data["pages_needing_ocr"], json!([2])); + assert_eq!(data["diagnostics"]["source_format"], "pdf"); + assert_eq!(data["diagnostics"]["extraction_status"], "partial"); + assert_eq!(data["diagnostics"]["page_count"], 3); + assert_eq!(data["diagnostics"]["extracted_pages"], json!([1, 3])); + assert_eq!(data["diagnostics"]["pages_needing_ocr"], json!([2])); let message = result_for_assistant.as_deref().expect("model response"); assert!( message.contains("2 of 3 PDF pages have reliable text"), @@ -1647,9 +1669,9 @@ mod tests { else { panic!("expected scan status"); }; - assert_eq!(data["extraction_status"], "needs_ocr"); - assert_eq!(data["extracted_pages"], json!([])); - assert_eq!(data["pages_needing_ocr"], json!([1, 2])); + assert_eq!(data["diagnostics"]["extraction_status"], "needs_ocr"); + assert_eq!(data["diagnostics"]["extracted_pages"], json!([])); + assert_eq!(data["diagnostics"]["pages_needing_ocr"], json!([1, 2])); let message = result_for_assistant.as_deref().unwrap(); assert!(message.contains("0 of 2 PDF pages have reliable text")); assert!(message.contains("Pages 1-2 need OCR or visual inspection")); @@ -1680,9 +1702,9 @@ mod tests { let ToolResult::Result { data, .. } = &results[0] else { panic!("expected partial remote result"); }; - assert_eq!(data["extraction_status"], "partial"); - assert_eq!(data["extracted_pages"], json!([1])); - assert_eq!(data["pages_needing_ocr"], json!([2])); + assert_eq!(data["diagnostics"]["extraction_status"], "partial"); + assert_eq!(data["diagnostics"]["extracted_pages"], json!([1])); + assert_eq!(data["diagnostics"]["pages_needing_ocr"], json!([2])); assert!(data["content"] .as_str() .unwrap() @@ -1718,7 +1740,9 @@ mod tests { assert_eq!(data["next_offset"], 2); assert_eq!(data["truncated_lines"], json!([1])); assert_eq!(data["content_truncated"], true); - assert!(!data["hit_total_char_limit"].as_bool().unwrap()); + assert!(!data["diagnostics"]["hit_total_char_limit"] + .as_bool() + .unwrap()); assert!(result_for_assistant .as_deref() .unwrap() @@ -1779,8 +1803,11 @@ mod tests { assert!(auto_data["content"] .as_str() .is_some_and(|content| content.contains("name,value"))); - assert_eq!(markdown_data["representation"], "extracted_markdown"); - assert_eq!(markdown_data["source_format"], "csv"); + assert_eq!( + markdown_data["diagnostics"]["representation"], + "extracted_markdown" + ); + assert_eq!(markdown_data["diagnostics"]["source_format"], "csv"); assert!(markdown_data["content"] .as_str() .is_some_and(|content| content.contains("| name | value |"))); @@ -1807,7 +1834,7 @@ mod tests { bounded_limit.load(Ordering::Relaxed), MAX_DOCUMENT_INPUT_BYTES ); - assert_eq!(data["representation"], "extracted_markdown"); + assert_eq!(data["diagnostics"]["representation"], "extracted_markdown"); assert!(data["content"] .as_str() .is_some_and(|content| content.contains("Hello from remote RTF"))); @@ -1846,11 +1873,11 @@ mod tests { else { panic!("result"); }; - assert_eq!(data["page_kind"], "pdf_page"); - assert_eq!(data["page_count"], 3); - assert_eq!(data["extraction_status"], status); - assert_eq!(data["extracted_pages"], extracted); - assert_eq!(data["pages_needing_ocr"], missing); + assert_eq!(data["diagnostics"]["page_kind"], "pdf_page"); + assert_eq!(data["diagnostics"]["page_count"], 3); + assert_eq!(data["diagnostics"]["extraction_status"], status); + assert_eq!(data["diagnostics"]["extracted_pages"], extracted); + assert_eq!(data["diagnostics"]["pages_needing_ocr"], missing); assert!(result_for_assistant .as_deref() .unwrap() @@ -1859,8 +1886,8 @@ mod tests { let content = data["content"].as_str().unwrap(); assert!(content.contains("## PDF page 3")); assert!(!content.contains("## PDF page 1")); - assert_eq!(data["selected_pages"], "3"); - assert_eq!(data["selected_page_count"], 1); + assert_eq!(data["diagnostics"]["selected_pages"], "3"); + assert_eq!(data["diagnostics"]["selected_page_count"], 1); } } let error = tool @@ -1891,8 +1918,8 @@ mod tests { let ToolResult::Result { data, .. } = &initial[0] else { panic!("result"); }; - assert_eq!(data["page_kind"], "text_chunk"); - assert_eq!(data["page_count"], 3); + assert_eq!(data["diagnostics"]["page_kind"], "text_chunk"); + assert_eq!(data["diagnostics"]["page_count"], 3); assert!(!data["truncated_lines"].as_array().unwrap().is_empty()); let selected = tool .call_impl( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs index 7d29a578c8..4018d98a41 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/file_write_tool.rs @@ -205,8 +205,6 @@ impl FileWriteTool { "success": true, "status": outcome.status.as_str(), "missing_path_fallback": missing_path_fallback, - "rename_required": missing_path_fallback, - "path_format_corrected": path_format_warning.is_some(), "path_format_warning": path_format_warning, "message": assistant_message, }); @@ -340,6 +338,23 @@ impl Tool for FileWriteTool { FileWriteTool::input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "file_path": {"type": "string"}, + "bytes_written": {"type": "integer"}, + "lines_written": {"type": "integer"}, + "success": {"type": "boolean"}, + "status": {"type": "string"}, + "message": {"type": "string"}, + "plan_format": {"type": "object", "additionalProperties": true} + }, + "required": ["file_path", "bytes_written", "lines_written", "success", "status", "message"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } @@ -1123,7 +1138,6 @@ mod tests { panic!("expected result"); }; - assert_eq!(data["path_format_corrected"], true); assert_eq!(data["path_format_warning"], warning); assert!(warning.contains(requested_path)); assert!(warning.contains(r"E:\workspace\project\example.txt")); @@ -1219,7 +1233,7 @@ mod tests { Some(file_name.as_str()) ); assert_eq!(data["missing_path_fallback"], true); - assert_eq!(data["rename_required"], true); + assert!(data.get("rename_required").is_none()); let assistant_message = result_for_assistant.as_deref().unwrap_or_default(); assert!(assistant_message .contains("Use your shell tool to move this file to the intended path")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs index 29885c5327..a6e7838b6b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs @@ -86,6 +86,27 @@ This tool is unavailable for remote workspaces, remote-control turns, and non-de }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "string"}, + "draftId": {"type": "string"}, + "draftPath": {"type": "string"}, + "activeRevision": {"type": "string"}, + "confirmedRevision": {"type": "string"}, + "reason": {"type": "string"}, + "parts": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "slots": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "commands": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "state": {"type": "object", "additionalProperties": true}, + "result": {"anyOf": [{"type": "null"}, {"type": "object", "additionalProperties": true}]} + }, + "required": ["status"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs index 429a87a4e4..20f2b1c371 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generative_ui_tool.rs @@ -281,6 +281,22 @@ Input rules: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "widget_id": {"type": "string"}, + "title": {"type": "string"}, + "width": {"type": "integer"}, + "height": {"type": "integer"}, + "modules": {"type": "array", "items": {"type": "string"}} + }, + "required": ["success", "widget_id", "title", "width", "height", "modules"], + "additionalProperties": false + }) + } + async fn input_schema_for_model_with_context( &self, _context: Option<&ToolUseContext>, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs index 90c53c698b..5e4fa4a037 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -1307,6 +1307,24 @@ Usage: schema } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "diff_content": {"type": "string"}, + "has_more": {"type": "boolean"}, + "next_cursor": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "diff_budget_truncated": {"type": "boolean"}, + "omitted_diff_chars": {"type": "integer"}, + "evidence_limited": {"type": "boolean"}, + "review_budget_repeated_page": {"type": "boolean"}, + "message": {"type": "string"} + }, + "required": ["diff_content"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs index 5dc8874778..f48c1d42d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/get_time_tool.rs @@ -48,6 +48,20 @@ This tool takes no parameters."# }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "local_time": {"type": "string"}, + "weekday": {"type": "string"}, + "unix_timestamp_seconds": {"type": "integer"} + }, + "required": ["success", "local_time", "weekday", "unix_timestamp_seconds"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -86,13 +100,10 @@ This tool takes no parameters."# "success": true, "local_time": local_time, "utc_time": utc_time, - "date": now.format("%Y-%m-%d").to_string(), - "time": now.format("%H:%M:%S").to_string(), "weekday": weekday, "weekday_number_from_monday": now.weekday().number_from_monday(), "unix_timestamp_seconds": unix_timestamp_seconds, "unix_timestamp_millis": unix_timestamp_millis, - "timestamp": unix_timestamp_seconds, "timezone_offset": timezone_offset, }); let result_for_assistant = format!( @@ -161,6 +172,9 @@ mod tests { .is_some_and(|value| !value.is_empty())); assert!(data["unix_timestamp_seconds"].as_i64().is_some()); assert!(data["unix_timestamp_millis"].as_i64().is_some()); + assert!(data.get("timestamp").is_none()); + assert!(data.get("date").is_none()); + assert!(data.get("time").is_none()); assert!(result_for_assistant .as_ref() .is_some_and(|value| value.contains("Current local time"))); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs index 0e6264dc27..e605cb71d9 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/glob_tool.rs @@ -204,6 +204,23 @@ impl Tool for GlobTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "matches_relative_to": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "matches": {"type": "array", "items": {"type": "string"}}, + "match_count": {"type": "integer"}, + "total_matches": {"type": "integer"}, + "truncated": {"type": "boolean"} + }, + "required": ["pattern", "path", "matches", "match_count", "total_matches", "truncated"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -309,15 +326,17 @@ impl Tool for GlobTool { data: json!({ "pattern": pattern, "path": resolved.logical_path, - "effective_pattern": effective_pattern, "matches_relative_to": relative_json_field(result_relative_base.as_deref()), "matches": glob_result.paths, "match_count": match_count, "total_matches": total_matches, "truncated": truncated, - "repo_phase": glob_result.repo_status.phase, - "base_advance_in_progress": glob_result.repo_status.base_advance_in_progress, - "workspace_probe_pending": glob_result.repo_status.workspace_probe_pending + "diagnostics": { + "effective_pattern": effective_pattern, + "repo_phase": glob_result.repo_status.phase, + "base_advance_in_progress": glob_result.repo_status.base_advance_in_progress, + "workspace_probe_pending": glob_result.repo_status.workspace_probe_pending + } }), result_for_assistant: Some(annotate_workspace_probe_pending( result_text, @@ -414,8 +433,10 @@ impl Tool for GlobTool { "match_count": match_count, "total_matches": total_matches, "truncated": truncated, - "search_backend": if uses_ripgrep { "rg" } else { "workspace_io" }, - "ignore_scope": if uses_ripgrep { "target_git_configuration" } else { "search_path" } + "diagnostics": { + "search_backend": if uses_ripgrep { "rg" } else { "workspace_io" }, + "ignore_scope": if uses_ripgrep { "target_git_configuration" } else { "search_path" } + } }), result_for_assistant: Some(result_text), image_attachments: None, @@ -467,15 +488,17 @@ impl Tool for GlobTool { data: json!({ "pattern": pattern, "path": resolved_str, - "effective_pattern": effective_pattern, "matches_relative_to": relative_json_field(result_relative_base.as_deref()), "matches": glob_result.paths, "match_count": match_count, "total_matches": total_matches, "truncated": truncated, - "repo_phase": glob_result.repo_status.phase, - "base_advance_in_progress": glob_result.repo_status.base_advance_in_progress, - "workspace_probe_pending": glob_result.repo_status.workspace_probe_pending + "diagnostics": { + "effective_pattern": effective_pattern, + "repo_phase": glob_result.repo_status.phase, + "base_advance_in_progress": glob_result.repo_status.base_advance_in_progress, + "workspace_probe_pending": glob_result.repo_status.workspace_probe_pending + } }), result_for_assistant: Some(annotate_workspace_probe_pending( result_text, @@ -1057,8 +1080,8 @@ mod tests { else { panic!("expected glob result"); }; - assert_eq!(data["search_backend"], "workspace_io"); - assert_eq!(data["ignore_scope"], "search_path"); + assert_eq!(data["diagnostics"]["search_backend"], "workspace_io"); + assert_eq!(data["diagnostics"]["ignore_scope"], "search_path"); assert_eq!(data["matches"], json!(["line\nname\\file.rs"])); assert!(result_for_assistant .as_deref() diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs index 01306c9b51..664ccc0484 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/grep_tool.rs @@ -166,10 +166,12 @@ impl GrepTool { "applied_limit": result.applied_limit, "applied_offset": result.applied_offset, "result": result.result_text, - "search_backend": if search.used_rg_candidates { "rg_candidates_workspace_io" } else if search.used_grep_candidates { "grep_candidates_workspace_io" } else { "workspace_io" }, - "scanned_file_count": search.scanned_file_count, - "scanned_bytes": search.scanned_bytes, - "ignore_scope": "search_path", + "diagnostics": { + "search_backend": if search.used_rg_candidates { "rg_candidates_workspace_io" } else if search.used_grep_candidates { "grep_candidates_workspace_io" } else { "workspace_io" }, + "scanned_file_count": search.scanned_file_count, + "scanned_bytes": search.scanned_bytes, + "ignore_scope": "search_path" + } }), result_for_assistant: Some(assistant_text), image_attachments: None, @@ -547,6 +549,23 @@ Usage: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "pattern": {"type": "string"}, + "path": {"type": "string"}, + "output_mode": {"type": "string"}, + "file_count": {"type": "integer"}, + "total_matches": {"type": "integer"}, + "result": {"type": "string"}, + "truncated": {"type": "boolean"} + }, + "required": ["pattern", "path", "output_mode", "file_count", "total_matches", "result"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -631,7 +650,9 @@ Usage: "applied_limit": result.applied_limit, "applied_offset": result.applied_offset, "result": result.result_text, - "representation": "miniapp_context" + "diagnostics": { + "representation": "miniapp_context" + } }), result_for_assistant: Some(result.result_text), image_attachments: None, @@ -714,13 +735,15 @@ Usage: "output_mode": output_mode, "file_count": file_count, "total_matches": total_matches, - "backend": search_result.backend, - "repo_phase": search_result.repo_status.phase, - "base_advance_in_progress": search_result.repo_status.base_advance_in_progress, - "workspace_probe_pending": search_result.repo_status.workspace_probe_pending, "applied_limit": head_limit, "applied_offset": if offset > 0 { Some(offset) } else { None:: }, "result": result_text, + "diagnostics": { + "backend": search_result.backend, + "repo_phase": search_result.repo_status.phase, + "base_advance_in_progress": search_result.repo_status.base_advance_in_progress, + "workspace_probe_pending": search_result.repo_status.workspace_probe_pending + } }), result_for_assistant: Some(annotate_workspace_probe_pending( result_text, @@ -798,13 +821,15 @@ Usage: "output_mode": output_mode, "file_count": file_count, "total_matches": total_matches, - "backend": search_result.backend, - "repo_phase": search_result.repo_status.phase, - "base_advance_in_progress": search_result.repo_status.base_advance_in_progress, - "workspace_probe_pending": search_result.repo_status.workspace_probe_pending, "applied_limit": head_limit, "applied_offset": if offset > 0 { Some(offset) } else { None:: }, "result": result_text, + "diagnostics": { + "backend": search_result.backend, + "repo_phase": search_result.repo_status.phase, + "base_advance_in_progress": search_result.repo_status.base_advance_in_progress, + "workspace_probe_pending": search_result.repo_status.workspace_probe_pending + } }), result_for_assistant: Some(annotate_workspace_probe_pending( result_text, @@ -2335,7 +2360,7 @@ mod tests { }; assert_eq!(data["result"], expected.result.result_text); assert_eq!(data["total_matches"], expected.result.total_matches); - assert_eq!(data["search_backend"], "workspace_io"); + assert_eq!(data["diagnostics"]["search_backend"], "workspace_io"); } #[tokio::test] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs index f8d1dbfda4..7a7fd1c18b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_models_tool.rs @@ -197,6 +197,32 @@ impl Tool for ListModelsTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "query": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "match_count": {"type": "integer"}, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider_name": {"type": "string"}, + "model_id": {"type": "string"}, + "model_name": {"type": "string"} + }, + "required": ["provider_name", "model_id", "model_name"], + "additionalProperties": false + } + } + }, + "required": ["success", "query", "match_count", "models"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs index f231e1a5c8..bcecc5c593 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/list_workspaces_tool.rs @@ -251,6 +251,41 @@ impl Tool for ListWorkspacesTool { "cursor":{"type":"string", "description":"Opaque next_cursor from the preceding page; keep query and kind unchanged."} }, "additionalProperties":false}) } + + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "scope": {"type": "string"}, + "current_workspace_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "count": {"type": "integer"}, + "total": {"type": "integer"}, + "workspaces": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "kind": {"type": "string"}, + "is_current": {"type": "boolean"}, + "workspace_id": {"type": "string"}, + "project_workspace_id": {"type": "string"}, + "root_path": {"type": "string"}, + "last_accessed_at_ms": {"type": "integer"}, + "remote": {"type": "object", "additionalProperties": true}, + "binding_error": {"type": "string"} + }, + "required": ["name", "kind", "workspace_id", "root_path"], + "additionalProperties": false + } + }, + "next_cursor": {"anyOf": [{"type": "string"}, {"type": "null"}]} + }, + "required": ["success", "scope", "current_workspace_id", "count", "total", "workspaces", "next_cursor"], + "additionalProperties": false + }) + } fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs index f7b4097d1e..290a66eb9b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ls_tool.rs @@ -89,6 +89,33 @@ Usage: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "path": {"type": "string"}, + "is_dir": {"type": "boolean"} + }, + "required": ["name", "path", "is_dir"], + "additionalProperties": false + } + }, + "total": {"type": "integer"}, + "limit": {"type": "integer"}, + "truncated": {"type": "boolean"} + }, + "required": ["path", "entries", "total", "limit", "truncated"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs index 50f8470991..320c23cd90 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mcp_tools.rs @@ -285,6 +285,19 @@ impl Tool for ListMCPResourcesTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server_id": {"type": "string"}, + "count": {"type": "integer"}, + "resources": {"type": "array", "items": {"type": "object", "additionalProperties": true}} + }, + "required": ["server_id", "count", "resources"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -409,6 +422,20 @@ impl Tool for ReadMCPResourceTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server_id": {"type": "string"}, + "uri": {"type": "string"}, + "content_count": {"type": "integer"}, + "contents": {"type": "array", "items": {"type": "object", "additionalProperties": true}} + }, + "required": ["server_id", "uri", "content_count", "contents"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -540,6 +567,19 @@ impl Tool for ListMCPPromptsTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server_id": {"type": "string"}, + "count": {"type": "integer"}, + "prompts": {"type": "array", "items": {"type": "object", "additionalProperties": true}} + }, + "required": ["server_id", "count", "prompts"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -671,6 +711,22 @@ impl Tool for GetMCPPromptTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "server_id": {"type": "string"}, + "name": {"type": "string"}, + "arguments": {"anyOf": [{"type": "object", "additionalProperties": {"type": "string"}}, {"type": "null"}]}, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "messages": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "prompt_text": {"type": "string"} + }, + "required": ["server_id", "name", "messages", "prompt_text"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs index 98dcd83923..7374605e2a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_finalize_tool.rs @@ -41,7 +41,7 @@ Call this after every successful Read/Write/Edit pass under a MiniApp root retur Input: app_id and optional theme ('dark' or 'light'). Do not call this for a customization draft root; the customization host owns draft sync and apply. Do not edit meta.json version fields manually; this tool owns version transitions. -Returns app_id, version, changed, content_hash, and source_revision."# +Returns app_id, version, changed, and source_revision."# .to_string()) } @@ -68,6 +68,19 @@ Returns app_id, version, changed, content_hash, and source_revision."# }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "app_id": {"type": "string"}, + "version": {"type": "string"}, + "changed": {"type": "boolean"} + }, + "required": ["app_id", "version", "changed"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } @@ -150,7 +163,6 @@ Returns app_id, version, changed, content_hash, and source_revision."# "app_id": app.id, "version": app.version, "changed": changed, - "content_hash": app.runtime.content_hash, "source_revision": app.runtime.source_revision, }), result_for_assistant: Some(result_text), diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 4a70ad8ae7..bb102843bb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -103,6 +103,18 @@ Returns app_id and the app root directory. Use the root directory and file names }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "app_id": {"type": "string"}, + "path": {"type": "string"} + }, + "required": ["app_id", "path"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs index e52f077eaa..efe2064c3c 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -126,6 +126,22 @@ Publishing is an outward-facing action: only call this when the user explicitly }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["sign_in_required", "pending_review", "submitted"]}, + "authorization_url": {"type": "string"}, + "slug": {"type": "string"}, + "submission_id": {"type": "string"}, + "release_number": {"type": "integer"}, + "name": {"type": "string"} + }, + "required": ["status"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index b8a2880f28..e98569eab8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -64,6 +64,8 @@ mod plan_artifact_diagnostics; pub mod playbook_tool; #[cfg(feature = "tools-agent-control")] pub mod port_forward_tool; +#[cfg(feature = "ptc-runtime")] +pub mod ptc_run_code_tool; #[cfg(feature = "tools-git")] pub mod review_platform_tool; pub mod session_control_tool; @@ -138,6 +140,8 @@ pub use page_publish_tool::PagePublishTool; pub use playbook_tool::PlaybookTool; #[cfg(feature = "tools-agent-control")] pub use port_forward_tool::PortForwardTool; +#[cfg(feature = "ptc-runtime")] +pub use ptc_run_code_tool::PtcRunCodeTool; #[cfg(feature = "tools-git")] pub use review_platform_tool::ReviewPlatformTool; pub use session_control_tool::SessionControlTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs index aba8e6a7ee..d1f6895d8f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/openbitfun_control_tool.rs @@ -398,6 +398,31 @@ impl Tool for OpenBitFunControlTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string", "enum": ["list", "search", "get", "open", "execute", "configure"]}, + "capabilities": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "items": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "operations": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "options": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "count": {"type": "integer"}, + "nextCursor": {"anyOf": [{"type": "integer"}, {"type": "null"}]}, + "capabilityId": {"type": "string"}, + "capability": {"type": "object", "additionalProperties": true}, + "toolInput": {"type": "object", "additionalProperties": true}, + "nextToolCalls": {"type": "object", "additionalProperties": true}, + "idNamespaceRules": {"type": "object", "additionalProperties": true}, + "value": {}, + "result": {}, + "message": {"type": "string"} + }, + "additionalProperties": true + }) + } + async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { openbitfun_product_domains::product_control::catalog().is_ok() } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs index 25f5b645e4..4171497d0a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_deploy_tool.rs @@ -64,6 +64,21 @@ Preview a version at /p/{username}/{slug}/@v/{version_id}."# }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "slug": {"type": "string"}, + "version_id": {"type": "string"}, + "visibility": {"type": "string"}, + "deployed": {"type": "boolean"}, + "url": {"type": "string"} + }, + "required": ["slug", "version_id", "visibility", "deployed"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs index 4d8a0ec683..0a4c577446 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/page_publish_tool.rs @@ -104,6 +104,22 @@ Use PageDeploy only to switch an already-saved version_id (rollback / promote a }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "slug": {"type": "string"}, + "version_id": {"type": "string"}, + "visibility": {"type": "string"}, + "deployed": {"type": "boolean"}, + "url": {"type": "string"}, + "preview_url": {"type": "string"} + }, + "required": ["slug", "version_id", "visibility", "deployed"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs index 534590806b..439283ba26 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/playbook_tool.rs @@ -289,6 +289,19 @@ Use this tool when you recognize a common task pattern — it saves planning tim }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "playbooks": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "playbook": {"type": "string"}, + "description": {"type": "string"}, + "steps": {"type": "array", "items": {"type": "object", "additionalProperties": true}} + }, + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs index f61f7457f5..f7d532a6a2 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/port_forward_tool.rs @@ -363,6 +363,28 @@ Forwards bind 127.0.0.1 and last until stopped or the connection drops. Set expo }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "operation": {"type": "string", "enum": ["targets", "detect", "start", "list", "stop"]}, + "target": {"type": "string"}, + "count": {"type": "integer"}, + "targets": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "listening_ports": {"type": "array", "items": {"type": "integer"}}, + "local_address": {"type": "string"}, + "local_url": {"type": "string"}, + "local_port_moved": {"type": "boolean"}, + "forward": {"type": "object", "additionalProperties": true}, + "forwards": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "forward_id": {"type": "string"}, + "note": {"type": "string"} + }, + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs new file mode 100644 index 0000000000..c495de7997 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs @@ -0,0 +1,114 @@ +//! Virtual RunCode entrypoint for PTC tool presentation. +//! +//! The pipeline owns the actual execution because nested calls must re-enter +//! the complete permission and tool execution path. This Tool implementation +//! supplies the manifest schema and a fail-closed fallback for callers that +//! invoke it outside that pipeline. + +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; +use async_trait::async_trait; +use openbitfun_agent_tools::{ + ptc_run_code_description, ptc_run_code_input_schema, PTC_RUN_CODE_TOOL_NAME, +}; +use serde_json::Value; + +pub struct PtcRunCodeTool; + +impl PtcRunCodeTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PtcRunCodeTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for PtcRunCodeTool { + fn name(&self) -> &str { + PTC_RUN_CODE_TOOL_NAME + } + + async fn description(&self) -> OpenBitFunResult { + Ok(ptc_run_code_description()) + } + + fn short_description(&self) -> String { + "Execute a TypeScript program with available tools".to_string() + } + + fn input_schema(&self) -> Value { + ptc_run_code_input_schema() + } + + fn output_schema(&self) -> Value { + serde_json::json!({ + "anyOf": [ + {"type": "null"}, + {"type": "boolean"}, + {"type": "number"}, + {"type": "string"}, + {"type": "array", "items": {}}, + {"type": "object", "additionalProperties": true}, + { + "type": "object", + "properties": { + "value": {}, + "logs": {"type": "array", "items": {"type": "string"}} + }, + "required": ["value", "logs"], + "additionalProperties": false + } + ] + }) + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + false + } + + fn permission_intents( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> OpenBitFunResult> { + // The outer program has no independent resource target. Every nested + // tool call re-enters the normal permission pipeline and prompts for + // its own concrete resources. + Ok(Vec::new()) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + if input + .get("code") + .and_then(Value::as_str) + .is_some_and(|code| !code.trim().is_empty()) + { + return ValidationResult::default(); + } + ValidationResult { + result: false, + message: Some("RunCode requires a non-empty code string".to_string()), + error_code: Some(400), + meta: None, + } + } + + async fn call_impl( + &self, + _input: &Value, + _context: &ToolUseContext, + ) -> OpenBitFunResult> { + Err(OpenBitFunError::NotImplemented( + "RunCode must be executed by the PTC tool pipeline".to_string(), + )) + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs index 8ef28a5fac..c647fd1e36 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/review_platform_tool.rs @@ -382,6 +382,41 @@ When returning pull request results to the user, include the provider web URL so }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "action": {"type": "string"}, + "repositoryPath": {"type": "string"}, + "remoteId": {"type": "string"}, + "status": {"type": "string"}, + "authChallenge": {"type": "object", "additionalProperties": true}, + "openPanel": {"anyOf": [{"type": "object", "additionalProperties": true}, {"type": "null"}]}, + "snapshot": {"type": "object", "additionalProperties": true}, + "pullRequest": {"type": "object", "additionalProperties": true}, + "body": {"type": "string"}, + "ci": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "files": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "commits": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "threads": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "section": {"type": "string"}, + "pagination": {"type": "object", "additionalProperties": true}, + "items": {"type": "array", "items": {}}, + "total": {"type": "integer"}, + "hasNext": {"type": "boolean"}, + "pullRequestId": {"type": "string"}, + "ciItemId": {"type": "string"}, + "log": {"type": "string"}, + "truncated": {"type": "boolean"}, + "message": {"type": "string"}, + "result": {"type": "object", "additionalProperties": true}, + "remotes": {"type": "array", "items": {"type": "object", "additionalProperties": true}} + }, + "required": ["action"], + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } 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 5e0f0b5f22..d3875f1861 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 @@ -473,6 +473,51 @@ Arguments: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "action": {"type": "string", "enum": ["create", "cancel", "delete", "rename", "list"]}, + "workspace": {"type": "string"}, + "session_id": {"type": "string"}, + "session_name": {"type": "string"}, + "had_active_turn": {"type": "boolean"}, + "cancelled_turn_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "status": {"type": "string"}, + "current_session_id": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "count": {"type": "integer"}, + "session": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "session_name": {"type": "string"}, + "agent_type": {"type": "string"} + }, + "required": ["session_id", "session_name", "agent_type"], + "additionalProperties": false + }, + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "session_id": {"type": "string"}, + "session_name": {"type": "string"}, + "agent_type": {"type": "string"}, + "created_at_ms": {"type": "integer"}, + "last_active_at_ms": {"type": "integer"} + }, + "required": ["session_id", "session_name", "agent_type"], + "additionalProperties": false + } + } + }, + "required": ["success", "action"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs index efdbd982bf..2fe8896344 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_history_tool.rs @@ -139,6 +139,35 @@ Examples: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "workspace": {"type": "string"}, + "transcript": { + "type": "object", + "properties": { + "transcript_path": {"type": "string"}, + "index_range": { + "type": "object", + "properties": { + "start_line": {"type": "integer"}, + "end_line": {"type": "integer"} + }, + "required": ["start_line", "end_line"], + "additionalProperties": false + } + }, + "required": ["transcript_path", "index_range"], + "additionalProperties": true + } + }, + "required": ["success", "workspace", "transcript"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } 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 29e2f0ec39..2582b9f8e8 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 @@ -374,6 +374,21 @@ Allowed agent types when creating a session: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "target_workspace": {"type": "string"}, + "target_session_id": {"type": "string"}, + "target_agent_type": {"type": "string"}, + "created_session_id": {"anyOf": [{"type": "string"}, {"type": "null"}]} + }, + "required": ["success", "target_workspace", "target_session_id", "target_agent_type", "created_session_id"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs index 74097dcf49..4b706fc6a1 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skill_tool.rs @@ -148,6 +148,26 @@ impl Tool for SkillTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "skill_name": {"type": "string"}, + "skill_key": {"type": "string"}, + "source_slot": {"type": "string"}, + "source_id": {"type": "string"}, + "source_label": {"type": "string"}, + "description": {"type": "string"}, + "location": {"type": "string"}, + "content": {"type": "string"}, + "compatibility_warnings": {"type": "array", "items": {"type": "string"}} + }, + "required": ["success", "skill_name", "skill_key", "content"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/agent_control.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/agent_control.rs index 87f51fc3d0..6e716708fa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/agent_control.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/agent_control.rs @@ -171,6 +171,19 @@ impl Tool for AgentSpawnTool { Self::agent_spawn_input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"const": "started"}, + "agent_id": {"type": "string"}, + "bg_task_id": {"type": "string"} + }, + "required": ["status", "agent_id", "bg_task_id"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } @@ -314,6 +327,19 @@ impl Tool for AgentSendInputTool { Self::agent_send_input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"const": "started"}, + "agent_id": {"type": "string"}, + "bg_task_id": {"type": "string"} + }, + "required": ["status", "agent_id", "bg_task_id"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs index 619d858025..bcc829e9ff 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/launch_review_agent.rs @@ -447,6 +447,23 @@ impl Tool for LaunchReviewAgentTool { Self::launch_input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "status": {"type": "string"}, + "session_id": {"type": "string"}, + "task_id": {"type": "string"}, + "agent_type": {"type": "string"}, + "result": {"anyOf": [{"type": "null"}, {"type": "string"}, {"type": "object", "additionalProperties": true}]}, + "output": {"type": "string"}, + "error": {"type": "string"} + }, + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index 46066a6da8..16d53c30c7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -347,6 +347,20 @@ impl Tool for TaskTool { AgentExecutionTool::regular_input_schema() } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "status": {"type": "string"}, + "agent_id": {"type": "string"}, + "bg_task_id": {"type": "string"}, + "result": {"type": "string"}, + "error": {"type": "string"} + }, + "additionalProperties": true + }) + } + async fn input_schema_for_model_with_context( &self, _context: Option<&ToolUseContext>, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs index f0a3dea130..aca42d7659 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/thread_goal_tools.rs @@ -46,6 +46,21 @@ fn thread_goal_runtime_error(error: RuntimeError) -> OpenBitFunError { } } +fn thread_goal_output_schema() -> Value { + json!({ + "type": "object", + "properties": { + "goal": { + "anyOf": [ + {"type": "null"}, + {"type": "object", "properties": {"status": {"type": "string"}}, "required": ["status"], "additionalProperties": false} + ] + } + }, + "additionalProperties": false + }) +} + fn thread_goal_port_error(port_error: PortError) -> OpenBitFunError { match port_error.kind { PortErrorKind::InvalidRequest => OpenBitFunError::Validation(port_error.message), @@ -107,6 +122,10 @@ impl Tool for GetGoalTool { }) } + fn output_schema(&self) -> Value { + thread_goal_output_schema() + } + fn is_readonly(&self) -> bool { true } @@ -197,6 +216,10 @@ Set token_budget only when an explicit token budget is requested. Fails if a goa }) } + fn output_schema(&self) -> Value { + thread_goal_output_schema() + } + async fn call_impl( &self, input: &Value, @@ -274,6 +297,10 @@ After an explicit resume, begin a fresh blocked audit. Do not repeat failed side }) } + fn output_schema(&self) -> Value { + thread_goal_output_schema() + } + async fn call_impl( &self, input: &Value, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs index 482b7ccef5..e1be6a002d 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/todo_write_tool.rs @@ -103,6 +103,29 @@ Each item must include: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "count": {"type": "integer"}, + "summary": {"type": "string"}, + "stats": { + "type": "object", + "properties": { + "completed": {"type": "integer"}, + "in_progress": {"type": "integer"}, + "pending": {"type": "integer"} + }, + "required": ["completed", "in_progress", "pending"], + "additionalProperties": false + } + }, + "required": ["success", "count", "summary", "stats"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs index c43ddcf317..6c2a9ed7b3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/view_image_tool.rs @@ -245,6 +245,20 @@ impl Tool for ViewImageTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "width": {"type": "integer"}, + "height": {"type": "integer"}, + "summary": {"type": "string"} + }, + "required": ["path", "width", "height", "summary"], + "additionalProperties": true + }) + } + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { context .map(Self::supports_multimodal_tool_output) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs index e5a19cf747..1daf764b0d 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/fetch.rs @@ -77,6 +77,17 @@ Example usage: }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "content": {"type": "string"} + }, + "required": ["content"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } @@ -199,10 +210,12 @@ Example usage: RequestedFormat::Markdown => "markdown", RequestedFormat::Json => "json", }, - "content_representation": content_representation, - "extractor": extractor, "content": processed_content, - "content_length": processed_content.len() + "content_length": processed_content.len(), + "diagnostics": { + "content_representation": content_representation, + "extractor": extractor + } }), result_for_assistant: Some(processed_content), image_attachments: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs index 430f0149fd..147317a4fb 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/mod.rs @@ -116,7 +116,7 @@ mod tests { } => { assert_eq!(data["content"], "hello from webfetch"); assert_eq!(data["format"], "markdown"); - assert_eq!(data["content_representation"], "plain_text"); + assert_eq!(data["diagnostics"]["content_representation"], "plain_text"); assert!(data["title"].is_null()); assert_eq!(result_for_assistant.as_deref(), Some("hello from webfetch")); } @@ -152,8 +152,8 @@ mod tests { } => { let content = data["content"].as_str().expect("content string"); assert_eq!(data["format"], "markdown"); - assert_eq!(data["content_representation"], "markdown"); - assert_eq!(data["extractor"], "legible"); + assert_eq!(data["diagnostics"]["content_representation"], "markdown"); + assert_eq!(data["diagnostics"]["extractor"], "legible"); assert_eq!(data["title"], "Hello World"); assert_eq!(data["content_length"].as_u64(), Some(content.len() as u64)); assert!(content.contains("primary article content")); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs index b2689fbf40..f3aea09c8f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/web/search.rs @@ -117,6 +117,33 @@ impl Tool for WebSearchTool { }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "query": {"type": "string"}, + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string"}, + "url": {"type": "string"}, + "published": {"type": "string"}, + "author": {"type": "string"} + }, + "required": ["title", "url"], + "additionalProperties": false + } + }, + "result_count": {"type": "integer"}, + "provider": {"type": "string"} + }, + "required": ["query", "results", "result_count", "provider"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { true } 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 2af97c3f45..3f67e66430 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 @@ -233,6 +233,27 @@ The tool cannot remove or rebind the worktree in which it is running. Use Sessio }) } + fn output_schema(&self) -> Value { + json!({ + "type": "object", + "properties": { + "success": {"type": "boolean"}, + "operation": {"type": "string", "enum": ["list", "create_session", "create_branch", "remove"]}, + "project_workspace_path": {"type": "string"}, + "count": {"type": "integer"}, + "worktrees": {"type": "array", "items": {"type": "object", "additionalProperties": true}}, + "worktree_id": {"type": "string"}, + "path": {"type": "string"}, + "session_id": {"type": "string"}, + "session_name": {"type": "string"}, + "worktree": {"type": "object", "additionalProperties": true}, + "idempotent_replay": {"type": "boolean"}, + "next": {"type": "string"} + }, + "additionalProperties": true + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs b/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs index 14e8d02ec1..ed45252aca 100644 --- a/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs +++ b/src/crates/assembly/core/src/agentic/tools/openbitfun_control_config.rs @@ -761,6 +761,32 @@ mod tests { ); } + #[tokio::test] + async fn tool_presentation_mode_product_control_round_trips_from_native_default() { + let (service, _dir) = test_service("product-control-tool-presentation-mode").await; + let capability = product_capability("setting.tools.execution").unwrap(); + let option = capability + .options + .iter() + .find(|option| option.id == "tool-presentation-mode") + .expect("tool presentation mode option"); + + assert_eq!( + read_config_backed_option(&service, option) + .await + .expect("native default should be readable"), + Some(Value::String("native".to_string())) + ); + + let applied = + configure_config_backed_option(&service, option, &Value::String("ptc".to_string())) + .await + .expect("PTC should be accepted") + .expect("tool presentation mode is config-backed"); + assert_eq!(applied.changed_path, "ai.tool_presentation_mode"); + assert_eq!(applied.effective_value, Value::String("ptc".to_string())); + } + #[tokio::test] async fn shared_executor_orders_typed_and_legacy_callers_in_one_revision_stream() { let (service, _dir) = test_service("product-control-shared-executor").await; diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 510d6c9042..0c308e5525 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -32,15 +32,24 @@ use openbitfun_agent_tools::{ build_tool_execution_timeout_presentation, build_user_rejected_tool_presentation_with_instruction, build_user_steering_interrupted_presentation, build_write_tail_closure_notice, - render_tool_result_for_assistant, validate_tool_execution_admission, DynamicToolInfo, - PermissionIntent, ResolvedToolInvocation, ToolExecutionAdmissionRejection, - ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, GET_TOOL_SPEC_TOOL_NAME, + render_ptc_loaded_tool_typescript_sdk, render_tool_result_for_assistant, + validate_tool_execution_admission, DynamicToolInfo, LoadedDeferredToolSpec, PermissionIntent, + PtcToolContract, ResolvedToolInvocation, ToolExecutionAdmissionRejection, + ToolExecutionAdmissionRequest, ToolExecutionErrorPresentation, CALL_DEFERRED_TOOL_NAME, + GET_TOOL_SPEC_TOOL_NAME, PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, USER_STEERING_INTERRUPTED_MESSAGE, }; use openbitfun_runtime_ports::{ PermissionReply, PermissionRequest, PermissionRequestSource, PermissionRequestSourceKind, PermissionResourceCaseSensitivity, RoundInjectionToolPreemption, }; +#[cfg(feature = "ptc-runtime")] +use openbitfun_runtime_ports::{ + PortError, PortErrorKind, PtcExecutionRequest, PtcNestedToolCall, PtcRuntime, PtcToolBinding, + PtcToolCallHandler, PtcToolSpecRequest, +}; +#[cfg(feature = "ptc-runtime")] +use openbitfun_services_integrations::ptc_runtime::NodePtcRuntime; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -132,18 +141,52 @@ fn convert_tool_result( effective_tool_name: &str, dynamic_tool_info: Option<&DynamicToolInfo>, ) -> ModelToolResult { + convert_tool_result_with_metadata( + framework_result, + tool_id, + wire_tool_name, + effective_tool_name, + dynamic_tool_info, + ) + .0 +} + +fn convert_tool_result_with_metadata( + framework_result: FrameworkToolResult, + tool_id: &str, + wire_tool_name: &str, + effective_tool_name: &str, + dynamic_tool_info: Option<&DynamicToolInfo>, +) -> (ModelToolResult, Vec) { match framework_result { FrameworkToolResult::Result { data, result_for_assistant, image_attachments, } => { + let loaded_specs = data + .get("__ptc_loaded_deferred_tool_specs") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()) + .unwrap_or_default(); + let assistant_data = data + .as_object() + .map(|object| { + let mut object = object.clone(); + object.remove("__ptc_loaded_deferred_tool_specs"); + serde_json::Value::Object(object) + }) + .unwrap_or_else(|| data.clone()); // If the tool does not provide result_for_assistant, pass the full // structured result through to the model. Summaries like // "completed successfully" can hide fields the model needs for the // next decision. - let assistant_text = result_for_assistant - .or_else(|| Some(render_tool_result_for_assistant(effective_tool_name, &data))); + let assistant_text = result_for_assistant.or_else(|| { + Some(render_tool_result_for_assistant( + effective_tool_name, + &assistant_data, + )) + }); // Tools with a typed response envelope can report a recoverable // domain failure without throwing away their structured error and // hints. Preserve that payload, but propagate its semantic status @@ -155,19 +198,22 @@ fn convert_tool_result( || (dynamic_tool_info.is_some_and(|info| info.mcp.is_some()) && data.get("isError").and_then(|value| value.as_bool()) == Some(true)); - ModelToolResult { - tool_id: tool_id.to_string(), - tool_name: wire_tool_name.to_string(), - effective_tool_name: persisted_effective_tool_name( - wire_tool_name, - effective_tool_name, - ), - result: data, - result_for_assistant: assistant_text, - is_error, - duration_ms: None, - image_attachments, - } + ( + ModelToolResult { + tool_id: tool_id.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), + result: assistant_data, + result_for_assistant: assistant_text, + is_error, + duration_ms: None, + image_attachments, + }, + loaded_specs, + ) } FrameworkToolResult::Progress { content, .. } => { let assistant_text = Some(render_tool_result_for_assistant( @@ -175,40 +221,51 @@ fn convert_tool_result( &content, )); - ModelToolResult { - tool_id: tool_id.to_string(), - tool_name: wire_tool_name.to_string(), - effective_tool_name: persisted_effective_tool_name( - wire_tool_name, - effective_tool_name, - ), - result: content, - result_for_assistant: assistant_text, - is_error: false, - duration_ms: None, - image_attachments: None, - } + ( + ModelToolResult { + tool_id: tool_id.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), + result: content, + result_for_assistant: assistant_text, + is_error: false, + duration_ms: None, + image_attachments: None, + }, + Vec::new(), + ) } FrameworkToolResult::StreamChunk { data, .. } => { let assistant_text = Some(render_tool_result_for_assistant(effective_tool_name, &data)); - ModelToolResult { - tool_id: tool_id.to_string(), - tool_name: wire_tool_name.to_string(), - effective_tool_name: persisted_effective_tool_name( - wire_tool_name, - effective_tool_name, - ), - result: data, - result_for_assistant: assistant_text, - is_error: false, - duration_ms: None, - image_attachments: None, - } + ( + ModelToolResult { + tool_id: tool_id.to_string(), + tool_name: wire_tool_name.to_string(), + effective_tool_name: persisted_effective_tool_name( + wire_tool_name, + effective_tool_name, + ), + result: data, + result_for_assistant: assistant_text, + is_error: false, + duration_ms: None, + image_attachments: None, + }, + Vec::new(), + ) } } } +struct ExecutedToolResult { + result: ModelToolResult, + ptc_loaded_deferred_tool_specs: Vec, +} + fn resolve_pipeline_invocation( tool_call: &ToolCall, context: &ToolExecutionContext, @@ -332,6 +389,7 @@ fn build_error_execution_result( duration_ms: Some(execution_time_ms), image_attachments: None, }, + ptc_loaded_deferred_tool_specs: Vec::new(), execution_time_ms, } } @@ -379,6 +437,7 @@ fn build_user_steering_interrupted_result( duration_ms: Some(execution_time_ms), image_attachments: None, }, + ptc_loaded_deferred_tool_specs: Vec::new(), execution_time_ms, } } @@ -447,6 +506,7 @@ fn build_permission_rejected_tool_result( duration_ms: Some(execution_time_ms), image_attachments: None, }, + ptc_loaded_deferred_tool_specs: Vec::new(), execution_time_ms, } } @@ -492,6 +552,17 @@ fn map_tool_execution_admission_rejection( } } +#[cfg(feature = "ptc-runtime")] +fn map_ptc_runtime_error(error: PortError) -> OpenBitFunError { + match error.kind { + PortErrorKind::Cancelled => OpenBitFunError::Cancelled(error.message), + PortErrorKind::Timeout => OpenBitFunError::Timeout(error.message), + PortErrorKind::InvalidRequest => OpenBitFunError::Validation(error.message), + PortErrorKind::NotAvailable => OpenBitFunError::Service(error.message), + _ => OpenBitFunError::ProcessError(error.message), + } +} + fn recovered_write_has_potentially_truncated_marked_path( tool_name: &str, arguments: &serde_json::Value, @@ -673,6 +744,169 @@ pub struct ToolPipeline { hook_asks: Arc>>, } +#[cfg(feature = "ptc-runtime")] +struct PtcPipelineToolCallHandler { + pipeline: Arc, + context: ToolExecutionContext, + options: ToolExecutionOptions, + parent_tool_id: String, + loaded_deferred_tool_specs: Arc>>, +} + +#[cfg(feature = "ptc-runtime")] +#[async_trait::async_trait] +impl PtcToolCallHandler for PtcPipelineToolCallHandler { + async fn call(&self, request: PtcNestedToolCall) -> Result { + if matches!( + request.tool_name.as_str(), + openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME + | GET_TOOL_SPEC_TOOL_NAME + | CALL_DEFERRED_TOOL_NAME + ) { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC gateway tools must be accessed through the PTC SDK APIs", + )); + } + let tool_id = format!("{}:nested:{}", self.parent_tool_id, request.operation_id); + let call = ToolCall { + tool_id, + tool_name: request.tool_name, + arguments: request.arguments, + ..ToolCall::default() + }; + let mut nested_context = self.context.clone(); + nested_context.loaded_deferred_tool_specs = + self.loaded_deferred_tool_specs.lock().await.clone(); + for loaded in &nested_context.loaded_deferred_tool_specs { + if !nested_context + .deferred_tools + .iter() + .any(|name| name == &loaded.tool_name) + { + nested_context.deferred_tools.push(loaded.tool_name.clone()); + } + } + if let Some(allowed_tools) = ToolPipeline::ptc_nested_allowed_tools(&nested_context) { + nested_context.allowed_tools = allowed_tools; + } + let results = self + .pipeline + .execute_tools(vec![call], nested_context, self.options.clone()) + .await + .map_err(|error| PortError::new(PortErrorKind::Backend, error.to_string()))?; + results + .into_iter() + .next() + .map(|result| result.result.result) + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "nested tool returned no result")) + } + + async fn get_tool_spec( + &self, + request: PtcToolSpecRequest, + ) -> Result { + let mut execution_context = self.context.clone(); + execution_context.loaded_deferred_tool_specs = + self.loaded_deferred_tool_specs.lock().await.clone(); + if let Some(allowed_tools) = ToolPipeline::ptc_nested_allowed_tools(&execution_context) { + execution_context.allowed_tools = allowed_tools; + } + let mut context = tool_context_runtime::build_tool_use_context_for_execution_context( + &execution_context, + None, + self.pipeline.computer_use_host.clone(), + CancellationToken::new(), + None, + ); + context.loaded_deferred_tool_specs = execution_context.loaded_deferred_tool_specs.clone(); + + let input = serde_json::json!({ "tool_name": request.tool_name }); + let results = + crate::agentic::tools::product_runtime::resolve_product_get_tool_spec_results( + &input, + &context, + GET_TOOL_SPEC_TOOL_NAME, + ) + .await + .map_err(|error| PortError::new(PortErrorKind::InvalidRequest, error.to_string()))?; + let value = results + .into_iter() + .find_map(|result| match result { + FrameworkToolResult::Result { data, .. } => Some(data), + FrameworkToolResult::StreamChunk { .. } | FrameworkToolResult::Progress { .. } => { + None + } + }) + .ok_or_else(|| { + PortError::new(PortErrorKind::Backend, "GetToolSpec returned no definition") + })?; + + let catalog_generation = value + .get("catalog_generation") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "GetToolSpec result has no catalog generation", + ) + })?; + let tool_name = value + .get("tool_name") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + + let tool = self + .pipeline + .tool_registry + .read() + .await + .get_tool(&tool_name) + .ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + format!("GetToolSpec returned no registered tool: {tool_name}"), + ) + })?; + let object = value.as_object().ok_or_else(|| { + PortError::new( + PortErrorKind::Backend, + "GetToolSpec result is not an object", + ) + })?; + let description = object + .get("description") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + let input_schema = object + .get("input_schema") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let output_schema = tool.output_schema(); + let typescript_sdk = render_ptc_loaded_tool_typescript_sdk(&PtcToolContract::new( + tool_name.clone(), + description, + input_schema, + output_schema, + )); + + let mut loaded = self.loaded_deferred_tool_specs.lock().await; + if let Some(existing) = loaded.iter_mut().find(|spec| spec.tool_name == tool_name) { + existing.catalog_generation = catalog_generation; + } else { + loaded.push(LoadedDeferredToolSpec { + tool_name, + catalog_generation, + }); + } + Ok(serde_json::json!({ + "typescript_sdk": typescript_sdk, + })) + } +} + impl ToolPipeline { pub fn new( tool_registry: Arc>, @@ -2084,7 +2318,22 @@ impl ToolPipeline { current_catalog_generation: registry.current_snapshot_generation(), get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }); - (admission, registry.get_tool(&tool_name)) + ( + admission, + registry.get_tool(&tool_name).or_else(|| { + #[cfg(feature = "ptc-runtime")] + { + (tool_name == openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME).then(|| { + Arc::new(crate::agentic::tools::implementations::PtcRunCodeTool::new()) + as Arc + }) + } + #[cfg(not(feature = "ptc-runtime"))] + { + None + } + }), + ) }; if let Err(err) = admission { @@ -2347,9 +2596,12 @@ impl ToolPipeline { self.cancellation_tokens.remove(&tool_id); match result { - Ok(tool_result) => { + Ok(executed_result) => { let duration_ms = elapsed_ms_u64(start_time); - let mut tool_result = tool_result; + let ExecutedToolResult { + result: mut tool_result, + ptc_loaded_deferred_tool_specs, + } = executed_result; tool_result.duration_ms = Some(duration_ms); self.apply_plugin_post_tool_use_hook(&task, &tool_name, &tool_id, &mut tool_result) @@ -2421,6 +2673,7 @@ impl ToolPipeline { tool_name: wire_tool_name, effective_tool_name: tool_name, result: tool_result, + ptc_loaded_deferred_tool_specs, execution_time_ms: duration_ms, }) } @@ -2507,6 +2760,7 @@ impl ToolPipeline { duration_ms: Some(duration_ms), image_attachments: None, }, + ptc_loaded_deferred_tool_specs: Vec::new(), execution_time_ms: duration_ms, }); } @@ -2552,7 +2806,7 @@ impl ToolPipeline { task: &ToolTask, cancellation_token: CancellationToken, tool: Arc, - ) -> OpenBitFunResult { + ) -> OpenBitFunResult { let mut attempts = 0; let max_attempts = task.options.max_retries + 1; @@ -2599,7 +2853,7 @@ impl ToolPipeline { task: &ToolTask, cancellation_token: CancellationToken, tool: Arc, - ) -> OpenBitFunResult { + ) -> OpenBitFunResult { // Check cancellation token if cancellation_token.is_cancelled() { return Err(OpenBitFunError::Cancelled( @@ -2609,7 +2863,13 @@ impl ToolPipeline { let tool_context = self.build_tool_use_context(task, cancellation_token); - let execution_future = tool.call(task.effective_arguments(), &tool_context); + let execution_future = async { + #[cfg(feature = "ptc-runtime")] + if tool.name() == openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME { + return self.execute_ptc_program(task, &tool_context).await; + } + tool.call(task.effective_arguments(), &tool_context).await + }; let execution_owner = resolve_contextual_tool(Arc::clone(&tool), &tool_context); let dynamic_tool_info = execution_owner @@ -2648,13 +2908,17 @@ impl ToolPipeline { .into_iter() .last() .map(|r| { - convert_tool_result( + let (result, ptc_loaded_deferred_tool_specs) = convert_tool_result_with_metadata( r, &task.tool_call.tool_id, &task.tool_call.tool_name, task.effective_tool_name(), dynamic_tool_info.as_ref(), - ) + ); + ExecutedToolResult { + result, + ptc_loaded_deferred_tool_specs, + } }) .ok_or_else(|| { OpenBitFunError::Tool(format!( @@ -2664,6 +2928,163 @@ impl ToolPipeline { }) } + #[cfg(feature = "ptc-runtime")] + async fn execute_ptc_program( + &self, + task: &ToolTask, + context: &ToolUseContext, + ) -> OpenBitFunResult> { + let code = task + .effective_arguments() + .get("code") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| OpenBitFunError::Validation("RunCode requires code".to_string()))?; + let working_directory = context + .workspace_root() + .map(|path| path.to_string_lossy().into_owned()) + .or_else(|| { + std::env::current_dir() + .ok() + .map(|path| path.to_string_lossy().into_owned()) + }) + .ok_or_else(|| OpenBitFunError::Service("PTC has no working directory".to_string()))?; + let bindings = { + let registry = self.tool_registry.read().await; + let allowed = Self::ptc_nested_allowed_tools(&task.context); + let mut names = Vec::new(); + for tool in registry.get_all_tools() { + let name = tool.name(); + if name == openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME + || allowed.as_ref().is_some_and(|allowed| { + !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == name) + }) + || !context.runtime_tool_restrictions.is_tool_allowed(name) + || !tool.is_available_in_context(Some(context)).await + { + continue; + } + names.push(PtcToolBinding { + name: name.to_string(), + }); + } + names + }; + let timeout_ms = task + .effective_arguments() + .get("timeout_ms") + .and_then(serde_json::Value::as_u64); + let runtime = if context.is_remote() { + #[cfg(feature = "ssh-remote")] + { + let workspace = context.workspace.as_ref().ok_or_else(|| { + OpenBitFunError::NotImplemented( + "PTC execution requires a resolved remote workspace".to_string(), + ) + })?; + let connection_id = workspace.connection_id().ok_or_else(|| { + OpenBitFunError::NotImplemented( + "PTC execution requires a remote SSH connection".to_string(), + ) + })?; + let remote_workspace_manager = + crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() + .ok_or_else(|| { + OpenBitFunError::Service( + "Remote workspace services are not initialized".to_string(), + ) + })?; + let ssh_manager = remote_workspace_manager + .get_ssh_manager() + .await + .ok_or_else(|| { + OpenBitFunError::Service( + "Remote SSH services are not initialized".to_string(), + ) + })?; + NodePtcRuntime::for_remote(ssh_manager, connection_id) + } + #[cfg(not(feature = "ssh-remote"))] + { + return Err(OpenBitFunError::NotImplemented( + "PTC execution requires the SSH remote runtime".to_string(), + )); + } + } else { + NodePtcRuntime::discover() + }; + let operation_id = task.tool_call.tool_id.clone(); + let handler = Arc::new(PtcPipelineToolCallHandler { + pipeline: Arc::new(self.clone()), + context: task.context.clone(), + options: ToolExecutionOptions { + parent_cancellation_token: context.cancellation_token().cloned(), + ..task.options.clone() + }, + parent_tool_id: operation_id.clone(), + loaded_deferred_tool_specs: Arc::new(TokioMutex::new( + task.context.loaded_deferred_tool_specs.clone(), + )), + }); + let request = PtcExecutionRequest { + operation_id: operation_id.clone(), + source: code.to_string(), + bindings, + working_directory, + session_id: Some(task.context.session_id.clone()), + timeout_ms, + max_output_bytes: None, + }; + let execute = runtime.execute(request, handler.clone()); + let response = if let Some(cancellation_token) = context.cancellation_token().cloned() { + tokio::select! { + result = execute => result.map_err(map_ptc_runtime_error)?, + _ = cancellation_token.cancelled() => { + let _ = runtime.cancel(&operation_id).await; + return Err(OpenBitFunError::Cancelled("PTC execution was cancelled".to_string())); + } + } + } else { + execute.await.map_err(map_ptc_runtime_error)? + }; + let mut data = if response.logs.is_empty() { + response.value + } else { + serde_json::json!({ + "value": response.value, + "logs": response.logs, + }) + }; + let loaded_deferred_tool_specs = handler.loaded_deferred_tool_specs.lock().await.clone(); + if !loaded_deferred_tool_specs.is_empty() { + let loaded_specs_value = serde_json::to_value(&loaded_deferred_tool_specs) + .unwrap_or_else(|_| serde_json::Value::Array(Vec::new())); + if let Some(object) = data.as_object_mut() { + object.insert( + "__ptc_loaded_deferred_tool_specs".to_string(), + loaded_specs_value, + ); + } else { + data = serde_json::json!({ + "value": data, + "__ptc_loaded_deferred_tool_specs": loaded_specs_value, + }); + } + } + Ok(vec![FrameworkToolResult::Result { + data, + result_for_assistant: None, + image_attachments: None, + }]) + } + + #[cfg(feature = "ptc-runtime")] + fn ptc_nested_allowed_tools(context: &ToolExecutionContext) -> Option> { + context + .context_vars + .get(PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY) + .and_then(|value| serde_json::from_str(value).ok()) + } + fn build_tool_use_context( &self, task: &ToolTask, @@ -3937,6 +4358,55 @@ mod tests { })); } + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn ptc_run_code_dispatches_nested_calls_through_the_pipeline() { + let pipeline = test_tool_pipeline(); + register_static_test_tool(&pipeline, "Read", json!({"content": "ok"}), 0).await; + let workspace_root = std::env::temp_dir().join("openbitfun-ptc-pipeline-test"); + std::fs::create_dir_all(&workspace_root).expect("PTC test workspace should exist"); + let mut context = permission_test_context(); + context.workspace = Some(WorkspaceBinding::new(None, workspace_root)); + context.allowed_tools = vec![openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME.to_string()]; + context.context_vars.insert( + PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY.to_string(), + serde_json::to_string(&vec!["Read"]).expect("nested allowlist should serialize"), + ); + + let results = pipeline + .execute_tools( + vec![ToolCall { + tool_id: "outer-ptc".to_string(), + tool_name: openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME.to_string(), + arguments: json!({ + "code": "return await tools.Read({ path: 'src/lib.rs' });" + }), + ..ToolCall::default() + }], + context, + ToolExecutionOptions::default(), + ) + .await + .expect("PTC program should execute"); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].tool_id, "outer-ptc"); + assert_eq!( + results[0].tool_name, + openbitfun_agent_tools::PTC_RUN_CODE_TOOL_NAME + ); + assert_eq!(results[0].result.result, json!({"content": "ok"})); + + let nested = pipeline + .state_manager + .get_session_tasks("session_1") + .into_iter() + .find(|task| task.tool_call.tool_id != "outer-ptc") + .expect("nested call should be tracked"); + assert!(nested.tool_call.tool_id.starts_with("outer-ptc:nested:")); + assert_eq!(nested.tool_call.tool_name, "Read"); + } + async fn current_registry_generation(pipeline: &ToolPipeline) -> u64 { pipeline .tool_registry @@ -5404,7 +5874,7 @@ mod tests { } #[tokio::test] - async fn deferred_gateway_requires_loaded_get_tool_spec_result() { + async fn deferred_gateway_executes_without_loaded_get_tool_spec_result() { let pipeline = test_tool_pipeline(); register_capturing_test_tool(&pipeline, "get_weather", Arc::new(Mutex::new(None))).await; @@ -5424,18 +5894,13 @@ mod tests { let results = pipeline .execute_tools(vec![call], context, ToolExecutionOptions::default()) .await - .expect("pipeline should return a per-tool error result"); + .expect("pipeline should execute the deferred target"); assert_eq!(results.len(), 1); assert_eq!(results[0].tool_name, CALL_DEFERRED_TOOL_NAME); assert_eq!(results[0].effective_tool_name, "get_weather"); - assert!(results[0].result.is_error); - assert!(results[0] - .result - .result_for_assistant - .as_deref() - .unwrap_or_default() - .contains("Call GetToolSpec with {\"tool_name\":\"get_weather\"}")); + assert!(!results[0].result.is_error); + assert_eq!(results[0].result.result["received"]["city"], "Shanghai"); } #[tokio::test] @@ -5663,30 +6128,6 @@ mod tests { ); } - #[tokio::test] - async fn pipeline_admission_direct_deferred_tool_requires_get_tool_spec() { - let pipeline = test_tool_pipeline(); - let mut context = test_tool_execution_context(); - context.deferred_tools = vec!["WebFetch".to_string()]; - - let results = pipeline - .execute_tools( - vec![test_tool_call("tool_1", "WebFetch")], - context, - ToolExecutionOptions::default(), - ) - .await - .expect("admission rejection should be returned as an error result"); - - assert_eq!(results.len(), 1); - assert!(results[0].result.is_error); - assert_failed_task_contains( - &pipeline, - "tool_1", - "Call GetToolSpec with {\"tool_name\":\"WebFetch\"}", - ); - } - #[tokio::test] async fn pipeline_admission_allows_direct_deferred_tool_after_get_tool_spec() { let pipeline = test_tool_pipeline(); @@ -6276,11 +6717,11 @@ mod tests { } #[test] - fn deferred_tool_requires_loaded_catalog_spec() { + fn deferred_tool_admission_allows_missing_catalog_spec() { let mut task = test_tool_task("tool_1", "WebFetch"); task.context.deferred_tools = vec!["WebFetch".to_string()]; - let err = validate_tool_execution_admission(ToolExecutionAdmissionRequest { + validate_tool_execution_admission(ToolExecutionAdmissionRequest { tool_name: &task.tool_call.tool_name, allowed_tools: &task.context.allowed_tools, runtime_tool_restrictions: &task.context.runtime_tool_restrictions, @@ -6289,11 +6730,7 @@ mod tests { current_catalog_generation: 0, get_tool_spec_tool_name: GET_TOOL_SPEC_TOOL_NAME, }) - .expect_err("deferred tool should require a loaded GetToolSpec result"); - - assert!(err - .to_string() - .contains("Call GetToolSpec with {\"tool_name\":\"WebFetch\"}")); + .expect("deferred exposure must not block execution when no spec is loaded"); } #[test] diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index 3d7d784cd8..9f6f9602dc 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -192,5 +192,9 @@ pub struct ToolExecutionResult { /// Runtime target used for validation, permissions, hooks, and execution. pub effective_tool_name: String, pub result: crate::agentic::core::ToolResult, + /// Deferred definitions loaded while executing a PTC gateway call. + /// These are projected onto the outer RunCode message so a later round + /// can reuse them without turning the state into a session-global cache. + pub ptc_loaded_deferred_tool_specs: Vec, pub execution_time_ms: u64, } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs index 4e8e0eb918..bdc0586738 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/call_deferred_tool.rs @@ -42,6 +42,19 @@ impl Tool for CallDeferredTool { call_deferred_tool_input_schema() } + fn output_schema(&self) -> Value { + serde_json::json!({ + "anyOf": [ + {"type": "null"}, + {"type": "boolean"}, + {"type": "number"}, + {"type": "string"}, + {"type": "array", "items": {}}, + {"type": "object", "additionalProperties": true} + ] + }) + } + fn is_readonly(&self) -> bool { false } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs index 8642a3cf55..f77b4047f4 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/catalog.rs @@ -6,6 +6,13 @@ use crate::agentic::tools::registry::{get_global_tool_registry, ToolRef}; use crate::agentic::tools::tool_context_runtime::ToolUseContext; use crate::util::errors::{OpenBitFunError, OpenBitFunResult}; use crate::util::types::ToolDefinition; +#[cfg(feature = "ptc-runtime")] +use openbitfun_agent_tools::{ + ptc_run_code_description, ptc_run_code_input_schema, + render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk_with_options, + PtcToolContract, PTC_RUN_CODE_TOOL_NAME, PTC_TOOL_PRESENTATION_CONTEXT_KEY, + PTC_TOOL_PRESENTATION_MODE, +}; use openbitfun_agent_tools::{ resolve_contextual_tool_manifest, resolve_contextual_visible_tools, ContextualToolManifest, ContextualVisibleTools, DynamicToolInfo, GetToolSpecCatalogProvider, @@ -14,6 +21,8 @@ use openbitfun_agent_tools::{ CALL_DEFERRED_TOOL_NAME, GET_TOOL_SPEC_TOOL_NAME, }; use serde::Serialize; +#[cfg(feature = "ptc-runtime")] +use serde_json::json; use serde_json::Value; use std::sync::Arc; @@ -31,6 +40,8 @@ pub struct ToolInfoDto { pub name: String, pub description: String, pub input_schema: Value, + #[serde(default)] + pub output_schema: Value, pub is_readonly: bool, pub is_concurrency_safe: bool, #[serde(skip_serializing_if = "Option::is_none")] @@ -52,6 +63,7 @@ pub async fn build_tool_info(tool: &Arc) -> ToolInfoDto { name: tool.name().to_string(), description, input_schema: tool.input_schema_for_model().await, + output_schema: tool.output_schema(), is_readonly: tool.is_readonly(), is_concurrency_safe: tool.is_concurrency_safe(None), dynamic_info: tool.dynamic_tool_info(), @@ -76,6 +88,8 @@ pub struct ResolvedToolManifest { pub tool_definitions: Vec, pub deferred_tool_names: Vec, pub deferred_tool_summaries: Vec, + #[cfg(feature = "ptc-runtime")] + pub ptc_tool_calling_guide: Option, pub catalog_generation: u64, } @@ -125,6 +139,8 @@ impl From> for ResolvedToolManifest { .collect(), deferred_tool_names: value.deferred_tool_names, deferred_tool_summaries, + #[cfg(feature = "ptc-runtime")] + ptc_tool_calling_guide: None, catalog_generation: 0, } } @@ -329,14 +345,132 @@ pub(crate) async fn resolve_product_tool_manifest( ); let tool_snapshot = contextual_tool_snapshot(context).await; append_selected_plugin_tool_names(&mut allowed_tools, &tool_snapshot, context).await; - resolve_contextual_tool_manifest( + let mut manifest = resolve_contextual_tool_manifest( &tool_snapshot, &allowed_tools, &exposure_overrides, context, GET_TOOL_SPEC_TOOL_NAME, ) - .await + .await; + + #[cfg(feature = "ptc-runtime")] + if context_is_ptc_mode(context) { + // PTC keeps deferred tools out of the initial full schema. Their + // generated SDK is attached to the prompt reminder by the resolved + // manifest facade below. + manifest.tool_definitions = vec![ToolManifestDefinition::new( + PTC_RUN_CODE_TOOL_NAME, + ptc_run_code_description(), + ptc_run_code_input_schema(), + )]; + if !manifest + .allowed_tool_names + .iter() + .any(|name| name == PTC_RUN_CODE_TOOL_NAME) + { + manifest + .allowed_tool_names + .push(PTC_RUN_CODE_TOOL_NAME.to_string()); + } + } + + manifest +} + +#[cfg(feature = "ptc-runtime")] +fn context_is_ptc_mode(context: &ToolUseContext) -> bool { + context + .custom_data + .get(PTC_TOOL_PRESENTATION_CONTEXT_KEY) + .and_then(Value::as_str) + .is_some_and(|mode| mode.eq_ignore_ascii_case(PTC_TOOL_PRESENTATION_MODE)) + || context + .agent_type + .as_deref() + .is_some_and(|agent| agent.eq_ignore_ascii_case("PTC")) +} + +#[cfg(feature = "ptc-runtime")] +async fn build_ptc_contracts( + tools: &[ToolRef], + deferred_tool_names: &[String], + context: &ToolUseContext, +) -> Vec { + let mut contracts = Vec::with_capacity(tools.len()); + for tool in tools { + // The deferred gateways are host implementation details. PTC programs + // receive the same direct snapshot as native model calls and must use + // the generated binding for a concrete tool, never the gateway itself. + if matches!( + tool.name(), + GET_TOOL_SPEC_TOOL_NAME | CALL_DEFERRED_TOOL_NAME | PTC_RUN_CODE_TOOL_NAME + ) { + continue; + } + let is_deferred = deferred_tool_names.iter().any(|name| name == tool.name()); + let description = if is_deferred { + // MCP descriptions are often long provider-authored prose. The + // initial PTC SDK only needs to identify the binding as deferred; + // the full description and schema arrive from getToolSpec. + if tool + .dynamic_tool_info() + .is_some_and(|info| info.mcp.is_some()) + { + String::new() + } else { + tool.short_description() + } + } else { + tool.description_with_context(Some(context)) + .await + .unwrap_or_else(|_| "No description available".to_string()) + }; + let output_schema = if is_deferred { + // The full result contract is loaded by tools.getToolSpec. Keeping + // this placeholder also prevents dynamic providers from doing + // definition work while the initial PTC SDK is rendered. + json!({}) + } else { + tool.output_schema() + }; + let output_schema = if output_schema + .as_object() + .is_some_and(serde_json::Map::is_empty) + { + // Keep tools without a stable contract callable in PTC while making + // the fallback explicit in the generated SDK, matching the + // provider-neutral JsonValue contract used by the reference + // harness. + json!({ + "anyOf": [ + {"type": "null"}, + {"type": "boolean"}, + {"type": "number"}, + {"type": "string"}, + {"type": "array", "items": {}}, + {"type": "object", "additionalProperties": true} + ] + }) + } else { + output_schema + }; + contracts.push(PtcToolContract { + name: tool.name().to_string(), + description, + input_schema: if is_deferred { + json!({}) + } else { + tool.input_schema_for_model_with_context(Some(context)) + .await + }, + output_schema, + is_readonly: tool.is_readonly(), + is_concurrency_safe: tool.is_concurrency_safe(None), + is_deferred, + }); + } + contracts } async fn append_selected_plugin_tool_names( @@ -402,12 +536,45 @@ pub(crate) async fn resolve_product_resolved_tool_manifest( exposure_overrides: &AgentToolPolicyOverrides, context: &ToolUseContext, ) -> ResolvedToolManifest { - let mut manifest: ResolvedToolManifest = - resolve_product_tool_manifest(allowed_tools, exposure_overrides, context) - .await - .into(); + let contextual_manifest = + resolve_product_tool_manifest(allowed_tools, exposure_overrides, context).await; + #[cfg(feature = "ptc-runtime")] + let ptc_tool_calling_guide = if context_is_ptc_mode(context) { + let include_deferred_tool_apis = + ProductToolCatalogProvider::deferred_tool_loading_enabled(context) + && !contextual_manifest.deferred_tool_names.is_empty(); + let mut ptc_tools = contextual_manifest.direct_tools.clone(); + for tool in &contextual_manifest.deferred_tools { + if !ptc_tools + .iter() + .any(|existing| existing.name() == tool.name()) + { + ptc_tools.push(tool.clone()); + } + } + let contracts = build_ptc_contracts( + &ptc_tools, + &contextual_manifest.deferred_tool_names, + context, + ) + .await; + let sdk_source = + render_ptc_typescript_sdk_with_options(&contracts, include_deferred_tool_apis); + Some(render_ptc_tool_calling_guide_with_options( + &sdk_source, + include_deferred_tool_apis, + )) + } else { + None + }; + + let mut manifest: ResolvedToolManifest = contextual_manifest.into(); let provider = ProductToolCatalogProvider; manifest.catalog_generation = provider.catalog_generation().await; + #[cfg(feature = "ptc-runtime")] + { + manifest.ptc_tool_calling_guide = ptc_tool_calling_guide; + } manifest } @@ -431,6 +598,8 @@ pub(crate) async fn resolve_product_get_tool_spec_results( #[cfg(test)] mod tests { + #[cfg(feature = "ptc-runtime")] + use super::build_ptc_contracts; use super::{ append_selected_plugin_tool_names, resolve_product_get_tool_spec_results, resolve_product_readonly_enabled_tools, resolve_product_resolved_tool_manifest, @@ -446,9 +615,11 @@ mod tests { use crate::agentic::tools::ToolRuntimeRestrictions; #[cfg(feature = "external-sources")] use crate::agentic::WorkspaceBinding; + #[cfg(feature = "ptc-runtime")] + use openbitfun_agent_tools::PTC_TOOL_PRESENTATION_CONTEXT_KEY; use openbitfun_agent_tools::{ GetToolSpecCatalogProvider, ToolCatalogSnapshotProvider, CALL_DEFERRED_TOOL_NAME, - GET_TOOL_SPEC_TOOL_NAME, + GET_TOOL_SPEC_TOOL_NAME, PTC_RUN_CODE_TOOL_NAME, }; use serde_json::{json, Value}; use std::collections::HashMap; @@ -570,6 +741,205 @@ mod tests { tool_context(None) } + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn ptc_manifest_exposes_only_run_code_and_keeps_snapshot_allowlist() { + let mut context = tool_context(Some("Standard")); + context.custom_data.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + Value::String("ptc".to_string()), + ); + let allowed_tools = vec!["Read".to_string(), "SessionHistory".to_string()]; + + let manifest = resolve_product_tool_manifest( + &allowed_tools, + &AgentToolPolicyOverrides::default(), + &context, + ) + .await; + + assert_eq!( + manifest + .tool_definitions + .iter() + .map(|definition| definition.name.as_str()) + .collect::>(), + vec![PTC_RUN_CODE_TOOL_NAME] + ); + assert!(manifest + .allowed_tool_names + .iter() + .any(|name| name == PTC_RUN_CODE_TOOL_NAME)); + assert!(manifest + .allowed_tool_names + .iter() + .any(|name| name == "Read")); + assert!(manifest + .tool_definitions + .iter() + .find(|definition| definition.name == PTC_RUN_CODE_TOOL_NAME) + .is_some_and(|definition| { + definition.description.contains("TypeScript program") + && !definition.description.contains("Generated SDK") + && !definition.description.contains("ToolArgsMap") + })); + } + + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn resolved_ptc_manifest_moves_sdk_to_tool_calling_guide() { + let mut context = tool_context(Some("Standard")); + context.custom_data.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + Value::String("ptc".to_string()), + ); + let manifest = resolve_product_resolved_tool_manifest( + &["Read".to_string(), "SessionHistory".to_string()], + &AgentToolPolicyOverrides::default(), + &context, + ) + .await; + + let run_code = manifest + .tool_definitions + .iter() + .find(|definition| definition.name == PTC_RUN_CODE_TOOL_NAME) + .expect("PTC manifest should expose RunCode"); + assert!(!run_code.description.contains("ToolArgsMap")); + let guide = manifest + .ptc_tool_calling_guide + .as_deref() + .expect("resolved PTC manifest should carry the SDK guide"); + assert!(guide.starts_with("# Tool Calling Guide\n")); + assert!(guide.contains("ToolArgsMap")); + assert!(guide.contains("SessionHistory")); + } + + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn ptc_sdk_uses_only_deferred_marker_for_mcp_tools() { + let tool = Arc::new(DeferredMcpCatalogTool) as Arc; + let contracts = build_ptc_contracts( + &[tool], + &["mcp__github__search_repos".to_string()], + &tool_context(Some("Standard")), + ) + .await; + let sdk = openbitfun_agent_tools::render_ptc_typescript_sdk(&contracts); + + assert!(sdk.contains( + "/** Deferred tool: load its definition with tools.getToolSpec(\"mcp__github__search_repos\") first" + )); + assert!(!sdk.contains("Search repositories through GitHub MCP")); + assert!(!sdk.contains(" (GitHub)")); + } + + #[cfg(feature = "ptc-runtime")] + #[tokio::test] + async fn resolved_ptc_manifest_omits_deferred_apis_when_loading_is_disabled() { + let mut context = tool_context(Some("Standard")); + context.custom_data.insert( + PTC_TOOL_PRESENTATION_CONTEXT_KEY.to_string(), + Value::String("ptc".to_string()), + ); + context.custom_data.insert( + DEFERRED_TOOL_LOADING_CONTEXT_KEY.to_string(), + Value::Bool(false), + ); + let manifest = resolve_product_resolved_tool_manifest( + &["Read".to_string(), "SessionHistory".to_string()], + &AgentToolPolicyOverrides::default(), + &context, + ) + .await; + + assert!(manifest.deferred_tool_names.is_empty()); + let guide = manifest + .ptc_tool_calling_guide + .as_deref() + .expect("resolved PTC manifest should carry the SDK guide"); + assert!(!guide.contains("getToolSpec(name: string)")); + assert!(!guide.contains("callTool(name: string, args: Record)")); + assert!(!guide.contains("deferred tools")); + } + + #[tokio::test] + async fn missing_presentation_mode_keeps_native_manifest() { + let manifest = resolve_product_tool_manifest( + &["Read".to_string()], + &AgentToolPolicyOverrides::default(), + &tool_context(Some("Standard")), + ) + .await; + + assert!(manifest + .tool_definitions + .iter() + .any(|definition| definition.name == "Read")); + assert!(!manifest + .tool_definitions + .iter() + .any(|definition| definition.name == PTC_RUN_CODE_TOOL_NAME)); + } + + #[test] + fn builtin_tools_expose_non_empty_output_schemas() { + let registry = create_tool_registry(); + let missing = registry + .get_all_tools() + .into_iter() + .filter(|tool| { + tool.output_schema() + .as_object() + .is_some_and(serde_json::Map::is_empty) + }) + .map(|tool| tool.name().to_string()) + .collect::>(); + assert!( + missing.is_empty(), + "built-in tools without output schemas: {missing:?}" + ); + } + + #[test] + fn ptc_output_schemas_keep_assistant_fields_and_drop_internal_metadata() { + let registry = create_tool_registry(); + let schema_for = |name: &str| { + registry + .get_all_tools() + .into_iter() + .find(|tool| tool.name() == name) + .unwrap_or_else(|| panic!("missing built-in tool {name}")) + .output_schema() + }; + + let read = schema_for("Read"); + assert!(read["properties"].get("content").is_some()); + assert!(read["properties"].get("next_offset").is_some()); + assert!(read["properties"].get("hit_total_char_limit").is_none()); + assert!(read["properties"].get("truncated_lines").is_some()); + assert_eq!(read["additionalProperties"], false); + + let glob = schema_for("Glob"); + assert!(glob["properties"].get("effective_pattern").is_none()); + assert!(glob["properties"].get("workspace_probe_pending").is_none()); + + let grep = schema_for("Grep"); + assert!(grep["properties"].get("search_backend").is_none()); + assert!(grep["properties"].get("backend").is_none()); + + let exec = schema_for("ExecCommand"); + assert!(exec["properties"].get("output").is_some()); + assert!(exec["properties"].get("exit_code").is_some()); + assert!(exec["properties"].get("chunk_id").is_none()); + assert!(exec["properties"].get("shell").is_none()); + + let time = schema_for("GetTime"); + assert!(time["properties"].get("local_time").is_some()); + assert!(time["properties"].get("unix_timestamp_seconds").is_some()); + assert!(time["properties"].get("unix_timestamp_millis").is_none()); + } + #[cfg(feature = "opencode-plugin-host")] #[tokio::test] async fn selected_plugin_tools_extend_manifest_inputs_without_unlocking_colliding_builtins() { diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs index 0baf4d1cac..6aa4a6d40b 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/get_tool_spec_tool.rs @@ -64,6 +64,20 @@ impl Tool for GetToolSpecTool { with_runtime(|runtime| runtime.input_schema()) } + fn output_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "tool_name": {"type": "string"}, + "description": {"type": "string"}, + "input_schema": {"type": "object", "additionalProperties": true}, + "catalog_generation": {"type": "integer"} + }, + "required": ["tool_name", "description", "input_schema", "catalog_generation"], + "additionalProperties": false + }) + } + fn is_readonly(&self) -> bool { with_runtime(|runtime| runtime.is_readonly()) } diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs index a940e06566..42b1d24111 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/loaded_spec_state.rs @@ -4,6 +4,7 @@ use crate::agentic::core::{Message, MessageContent}; use openbitfun_agent_tools::{ collect_loaded_deferred_tool_specs, GetToolSpecLoadObservation, LoadedDeferredToolSpec, }; +use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ProductLoadedDeferredToolSpecs { @@ -12,17 +13,60 @@ pub(crate) struct ProductLoadedDeferredToolSpecs { impl ProductLoadedDeferredToolSpecs { pub(crate) fn from_messages(messages: &[Message], deferred_tools: &[String]) -> Self { - let observations = messages + let deferred_names = deferred_tools .iter() - .filter_map(get_tool_spec_load_observation) - .collect::>(); + .map(String::as_str) + .collect::>(); + let mut loaded_by_name = BTreeMap::new(); + for message in messages { + if let Some(observation) = get_tool_spec_load_observation(message) { + for spec in collect_loaded_deferred_tool_specs( + &[observation], + deferred_tools, + crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME, + ) { + loaded_by_name.insert(spec.tool_name.clone(), spec); + } + } + let is_ptc_run_code = matches!( + &message.content, + MessageContent::ToolResult { tool_name, .. } if tool_name == "RunCode" + ); + if is_ptc_run_code { + for spec in &message.metadata.ptc_loaded_deferred_tool_specs { + if deferred_names.contains(spec.tool_name.as_str()) { + loaded_by_name.insert(spec.tool_name.clone(), spec.clone()); + } + } + } + if let MessageContent::ToolResult { + tool_name, + result, + is_error: false, + .. + } = &message.content + { + if tool_name != "RunCode" { + continue; + } + if let Some(specs) = result + .get("__ptc_loaded_deferred_tool_specs") + .cloned() + .and_then(|value| { + serde_json::from_value::>(value).ok() + }) + { + for spec in specs { + if deferred_names.contains(spec.tool_name.as_str()) { + loaded_by_name.insert(spec.tool_name.clone(), spec); + } + } + } + } + } Self { - loaded_specs: collect_loaded_deferred_tool_specs( - &observations, - deferred_tools, - crate::agentic::tools::registry::GET_TOOL_SPEC_TOOL_NAME, - ), + loaded_specs: loaded_by_name.into_values().collect(), } } @@ -83,7 +127,7 @@ mod tests { } #[test] - fn compaction_requires_reload_only_when_the_full_spec_leaves_the_context() { + fn compaction_drops_receipt_without_blocking_deferred_execution() { let deferred_tools = vec!["Cron".to_string()]; let input = json!({ "tool_name": "Cron" }); let spec_result = Message::tool_result(ToolResult { @@ -159,16 +203,14 @@ mod tests { )); } else { assert!(loaded.is_empty()); - assert!(admission - .unwrap_err() - .to_string() - .contains("reload it even if the summary says it was loaded")); + admission.expect("a missing loaded-spec receipt must not block deferred execution"); assert!(matches!( reload_plan, GetToolSpecExecutionPlan::LoadDetail { tool_name: "Cron" } )); - // Reading the definition again restores normal admission. + // Reading the definition again restores the receipt and avoids a + // redundant load, while execution was already allowed above. compressed.push(spec_result.clone()); let reloaded = collect_product_loaded_deferred_tool_specs(&compressed, &deferred_tools); @@ -179,7 +221,7 @@ mod tests { 42, GET_TOOL_SPEC_TOOL_NAME, ) - .expect("a fresh spec must unlock Cron after compaction"); + .expect("a fresh spec should remain valid after compaction"); } } } @@ -369,4 +411,61 @@ mod tests { assert!(!state.is_loaded("WebFetch")); assert_eq!(state.into_loaded_specs(), vec![loaded_spec("Worktree")]); } + + #[test] + fn product_loaded_spec_state_collects_ptc_run_code_receipts() { + let run_code_result = Message::tool_result(ToolResult { + tool_id: "run-code-1".to_string(), + tool_name: "RunCode".to_string(), + effective_tool_name: None, + result: json!({ + "value": {"ok": true}, + "__ptc_loaded_deferred_tool_specs": [{ + "tool_name": "mcp__threejs__show_threejs_scene", + "catalog_generation": 42, + }], + }), + result_for_assistant: Some("{\"ok\":true}".to_string()), + is_error: false, + duration_ms: None, + image_attachments: None, + }); + + let loaded = collect_product_loaded_deferred_tool_specs( + &[run_code_result.clone()], + &["mcp__threejs__show_threejs_scene".to_string()], + ); + + assert_eq!( + loaded, + vec![loaded_spec("mcp__threejs__show_threejs_scene")] + ); + + let native_result = Message::tool_result(ToolResult { + tool_id: "native-spec-1".to_string(), + tool_name: "GetToolSpec".to_string(), + effective_tool_name: None, + result: json!({ + "tool_name": "mcp__threejs__show_threejs_scene", + "catalog_generation": 43, + }), + result_for_assistant: None, + is_error: false, + duration_ms: None, + image_attachments: None, + }); + // Native and PTC receipts must retain message order when refreshing + // the same tool's catalog generation. + for (messages, expected_generation) in [ + (vec![run_code_result.clone(), native_result.clone()], 43), + (vec![native_result, run_code_result], 42), + ] { + let loaded = collect_product_loaded_deferred_tool_specs( + &messages, + &["mcp__threejs__show_threejs_scene".to_string()], + ); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].catalog_generation, expected_generation); + } + } } 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 c56631c337..aae862ac32 100644 --- a/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs +++ b/src/crates/assembly/core/src/agentic/tools/tool_adapter.rs @@ -40,6 +40,10 @@ impl ToolRegistryItem for dyn Tool { Tool::input_schema(self) } + fn output_schema(&self) -> Value { + Tool::output_schema(self) + } + fn short_description(&self) -> String { Tool::short_description(self) } 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 f66c76d93e..66f19c92fa 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 @@ -49,6 +49,7 @@ use openbitfun_agent_runtime::user_questions::{ }; use openbitfun_agent_tools::{ LoadedDeferredToolSpec, PortableToolContextProvider, ToolContextFacts, ToolWorkspaceKind, + PTC_TOOL_PRESENTATION_CONTEXT_KEY, }; #[cfg(feature = "canvas-runtime")] use openbitfun_product_domains::canvas::CanvasStoragePort; @@ -415,6 +416,12 @@ fn build_tool_context_custom_data(context: &ToolExecutionContext) -> HashMap RouteResul .unwrap_or_else(|| "OpenCode Plugin Session".to_string()), "Standard".to_string(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(context.workspace_id.clone()), workspace_path: Some(context.directory.to_string_lossy().into_owned()), project_workspace_path: Some(context.directory.to_string_lossy().into_owned()), diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index be8130e824..6b7453dbbc 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -3794,6 +3794,7 @@ mod tests { "Reload test".to_string(), "Standard".to_string(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.path().to_string_lossy().to_string()), ..Default::default() }, @@ -4107,6 +4108,7 @@ mod tests { format!("Remote fork {host}"), "Standard".to_string(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(remote_path.clone()), remote_connection_id: Some(connection_id.to_string()), remote_ssh_host: Some(host.to_string()), @@ -4415,6 +4417,7 @@ mod tests { "Temporary question".to_string(), "Standard".to_string(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(record.id.clone()), workspace_path: Some(remote_path.clone()), remote_connection_id: Some(connection_id.to_string()), @@ -4614,6 +4617,7 @@ mod tests { "Source".into(), "Standard".into(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(local.id.clone()), workspace_path: Some("/stale/controller/mirror/sessions".into()), remote_connection_id: Some("stale-ssh".into()), @@ -4815,6 +4819,7 @@ mod tests { "Transient settlement".to_string(), "Standard".to_string(), crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_root.to_string_lossy().into_owned()), ..Default::default() }, diff --git a/src/crates/assembly/core/src/service/cron/service.rs b/src/crates/assembly/core/src/service/cron/service.rs index d2abb004a5..495eb4f490 100644 --- a/src/crates/assembly/core/src/service/cron/service.rs +++ b/src/crates/assembly/core/src/service/cron/service.rs @@ -819,6 +819,7 @@ impl CronService { format!("Scheduled: {}", enqueue_input.job_name.trim()), launch.agent_type.clone(), SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace.workspace_path.clone()), project_workspace_path: workspace.project_workspace_path.clone(), execution_target: workspace.execution_target.clone(), diff --git a/src/crates/assembly/core/src/service/snapshot/manager.rs b/src/crates/assembly/core/src/service/snapshot/manager.rs index 2ce503c57f..cff8c5f0ba 100644 --- a/src/crates/assembly/core/src/service/snapshot/manager.rs +++ b/src/crates/assembly/core/src/service/snapshot/manager.rs @@ -892,6 +892,10 @@ impl Tool for WrappedTool { self.original_tool.input_schema() } + fn output_schema(&self) -> Value { + self.original_tool.output_schema() + } + async fn input_schema_for_model(&self) -> Value { self.original_tool.input_schema_for_model().await } diff --git a/src/crates/assembly/core/src/service/workspace/legacy_compat.rs b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs index 4d358239ba..20864e4cdb 100644 --- a/src/crates/assembly/core/src/service/workspace/legacy_compat.rs +++ b/src/crates/assembly/core/src/service/workspace/legacy_compat.rs @@ -636,6 +636,7 @@ mod tests { std::fs::create_dir_all(&execution).unwrap(); let record = register_local_fixture(&execution, Some(&project)).await; let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(execution.to_string_lossy().into_owned()), project_workspace_path: Some(project.to_string_lossy().into_owned()), ..Default::default() @@ -666,6 +667,7 @@ mod tests { let root = tempfile::tempdir().unwrap(); let _ = fixture_workspace_service().await; let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(root.path().to_string_lossy().into_owned()), ..Default::default() }; @@ -688,6 +690,7 @@ mod tests { let logical_root = format!("/srv/id-only-{}", uuid::Uuid::new_v4()); let record = register_remote_fixture(&logical_root, "ssh-localhost", "localhost").await; let mut config = SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(record.id.clone()), remote_connection_id: Some("untrusted-request".into()), ..Default::default() @@ -1025,6 +1028,7 @@ impl WorkspaceService { binding: openbitfun_runtime_ports::AgentSessionWorkspaceBinding, ) -> OpenBitFunResult { let mut config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: binding.workspace_id, project_workspace_id: binding.project_workspace_id, workspace_kind: binding.workspace_kind, diff --git a/src/crates/assembly/core/src/service/worktree/session_binding.rs b/src/crates/assembly/core/src/service/worktree/session_binding.rs index 03ec0d2335..083089ef68 100644 --- a/src/crates/assembly/core/src/service/worktree/session_binding.rs +++ b/src/crates/assembly/core/src/service/worktree/session_binding.rs @@ -218,6 +218,7 @@ async fn load_binding_context( ) })?; let mut config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: metadata.workspace_id.clone(), project_workspace_id: metadata.project_workspace_id.clone(), workspace_path: metadata diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 3645cdfd94..3dd7fc3105 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -252,6 +252,7 @@ async fn configured_plugin_execution_root( request: &AgentSessionCreateRequest, ) -> PortResult> { let mut config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: request.workspace_id.clone(), workspace_path: request.workspace_path.clone(), project_workspace_path: request.project_workspace_path.clone(), @@ -1894,6 +1895,7 @@ impl CoreServiceAgentRuntime { return Err("Remote workspace connection identity is unavailable".to_string()); } let config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(current.path), remote_connection_id: current.remote_connection_id, remote_ssh_host: current.remote_ssh_host, @@ -1968,6 +1970,7 @@ impl CoreServiceAgentRuntime { } if let Some(workspace_id) = workspace_id { let config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: Some(workspace_id.to_string()), ..Default::default() }; @@ -2037,6 +2040,7 @@ impl CoreServiceAgentRuntime { } } let config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some(workspace_path.into()), remote_connection_id: remote_connection_id.map(str::to_string), ..Default::default() @@ -4852,6 +4856,7 @@ async fn resolve_history_workspace( ssh_host: Option<&str>, ) -> PortResult { let mut config = crate::agentic::core::SessionConfig { + tool_presentation_mode: Default::default(), workspace_id: workspace_id.map(str::to_owned), workspace_path: Some(path.to_owned()), remote_connection_id: connection_id.map(str::to_owned), diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 7b5c7c4989..1caa260842 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -2,7 +2,7 @@ //! //! Defines all configuration-related types shared between backend and frontend. -use openbitfun_core_types::{product_identity, WorktreeSettings}; +use openbitfun_core_types::{product_identity, ToolPresentationMode, WorktreeSettings}; pub use openbitfun_core_types::{ReasoningConfig, ReasoningPreset, ReasoningPresetAction}; use openbitfun_product_domains::tool_permissions::{PermissionRule, ToolPermissionConfig}; use serde::{Deserialize, Serialize}; @@ -1098,6 +1098,11 @@ pub struct AIConfig { /// All configured models. pub models: Vec, + /// Tool presentation used by sessions created after this setting changes. + /// Existing sessions retain their persisted presentation mode. + #[serde(default)] + pub tool_presentation_mode: ToolPresentationMode, + #[serde(default)] pub model_pools: crate::model_pool::ModelPoolDocument, @@ -2124,6 +2129,7 @@ impl Default for AIConfig { Self { model_pools: crate::model_pool::ModelPoolDocument::default(), models: vec![], + tool_presentation_mode: ToolPresentationMode::default(), task_models: TaskModelsConfig::default(), default_models: DefaultModelsConfig::default(), agent_model_defaults: AgentModelDefaultsConfig::default(), @@ -3259,6 +3265,66 @@ mod tests { ); } + #[test] + fn ai_tool_presentation_defaults_to_native_and_round_trips_ptc() { + let legacy: super::AIConfig = serde_json::from_value(serde_json::json!({ + "models": [] + })) + .expect("AI config without tool presentation should deserialize"); + assert_eq!( + legacy.tool_presentation_mode, + openbitfun_core_types::ToolPresentationMode::Native + ); + assert_eq!( + serde_json::to_value(&legacy).unwrap()["tool_presentation_mode"], + "native" + ); + + let ptc: super::AIConfig = serde_json::from_value(serde_json::json!({ + "models": [], + "tool_presentation_mode": "ptc" + })) + .expect("PTC AI config should deserialize"); + assert_eq!( + ptc.tool_presentation_mode, + openbitfun_core_types::ToolPresentationMode::Ptc + ); + let written = serde_json::to_value(ptc).unwrap(); + assert_eq!(written["tool_presentation_mode"], "ptc"); + let restored: super::AIConfig = serde_json::from_value(written).unwrap(); + assert_eq!( + restored.tool_presentation_mode, + openbitfun_core_types::ToolPresentationMode::Ptc + ); + + let future: super::AIConfig = serde_json::from_value(serde_json::json!({ + "models": [], + "tool_presentation_mode": "future_mode" + })) + .expect("future tool presentation values should degrade to native"); + assert_eq!( + future.tool_presentation_mode, + openbitfun_core_types::ToolPresentationMode::Native + ); + + let global: super::GlobalConfig = + serde_json::from_value(current_global_config_with(serde_json::json!({ + "ai": { + "tool_presentation_mode": "ptc" + } + }))) + .expect("global config should read the runtime tool presentation setting"); + assert_eq!( + global.ai.tool_presentation_mode, + openbitfun_core_types::ToolPresentationMode::Ptc + ); + let global_written = serde_json::to_value(global).unwrap(); + assert_eq!(global_written["ai"]["tool_presentation_mode"], "ptc"); + assert!(global_written["app"]["flow_chat"] + .get("tool_presentation_mode") + .is_none()); + } + #[test] fn app_flow_chat_selection_toolbar_defaults_and_round_trips() { // Old payloads retain automatic display and remain readable after saving. diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index cd157f9870..89ae3e9cde 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -18,6 +18,7 @@ pub mod speech; pub mod subscription; pub mod surface; pub mod tool_image_attachment; +pub mod tool_presentation; pub mod worktree; pub use ai::{ @@ -51,6 +52,7 @@ pub use surface::{ RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, }; pub use tool_image_attachment::ToolImageAttachment; +pub use tool_presentation::ToolPresentationMode; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, diff --git a/src/crates/contracts/core-types/src/tool_presentation.rs b/src/crates/contracts/core-types/src/tool_presentation.rs new file mode 100644 index 0000000000..803c9c23f4 --- /dev/null +++ b/src/crates/contracts/core-types/src/tool_presentation.rs @@ -0,0 +1,74 @@ +use serde::{Deserialize, Deserializer, Serialize}; + +/// How the model is presented with tools for a session. +/// +/// This is intentionally independent from the Agent harness mode. A session +/// freezes the value when it is created so changing the global default only +/// affects sessions created afterwards. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ToolPresentationMode { + Native, + Ptc, +} + +impl Default for ToolPresentationMode { + fn default() -> Self { + Self::Native + } +} + +impl<'de> Deserialize<'de> for ToolPresentationMode { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Ok(match value.as_str() { + "ptc" => Self::Ptc, + // Unknown values come from a newer build. Keep the session/config + // readable and fail closed to the legacy native presentation. + _ => Self::Native, + }) + } +} + +impl ToolPresentationMode { + pub const fn is_native(&self) -> bool { + matches!(self, Self::Native) + } + + pub const fn is_ptc(&self) -> bool { + matches!(self, Self::Ptc) + } +} + +#[cfg(test)] +mod tests { + use super::ToolPresentationMode; + + #[test] + fn tool_presentation_mode_uses_stable_snake_case_values() { + assert_eq!( + ToolPresentationMode::default(), + ToolPresentationMode::Native + ); + assert_eq!( + serde_json::to_value(ToolPresentationMode::Native).unwrap(), + "native" + ); + assert_eq!( + serde_json::to_value(ToolPresentationMode::Ptc).unwrap(), + "ptc" + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("ptc")).unwrap(), + ToolPresentationMode::Ptc + ); + assert_eq!( + serde_json::from_value::(serde_json::json!("future_mode")) + .unwrap(), + ToolPresentationMode::Native + ); + } +} diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 707c9340a1..f9849d0107 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", - "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", + "digest": "65f1a11c6d994c382da25f2b9ad995ad0f443a5d352c2eafa10a8b9bcbf849e5", + "ownerDigest": "4e1ace270088acecbd333d2c8aced0547dddb0fd6bae778bec24f2b0fb48ea91", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -147,9 +147,9 @@ "features": 23, "settings": 21, "userFacing": 44, - "documentedItems": 332, + "documentedItems": 333, "controlCoverage": { - "direct": 51, + "direct": 52, "delegated": 65, "interactive": 216, "unsupported": 0 @@ -16039,6 +16039,7 @@ "global-project-rules", "remembered-grants", "deferred-tools", + "tool-presentation", "parallelism", "timeouts", "review-capacity", @@ -16175,6 +16176,54 @@ "pageId": "tools.execution" } }, + { + "id": "setting.tools.execution:option:tool-presentation-mode", + "capabilityId": "setting.tools.execution", + "itemIds": [ + "tool-presentation" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "tools.execution" + } + }, { "id": "setting.tools.execution:option:subagent-batch-policy", "capabilityId": "setting.tools.execution", @@ -28870,13 +28919,15 @@ "categoryId": "assistant", "titleZh": "执行与权限控制", "titleEn": "Execution & permissions", - "summaryZh": "管理智能体工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。", - "summaryEn": "Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control.", + "summaryZh": "管理智能体工具权限、呈现模式、并行度、超时、延迟加载、Computer Use 与浏览器控制。", + "summaryEn": "Manage agent tool permissions, presentation mode, concurrency, timeouts, deferred loading, Computer Use, and browser control.", "keywordsZh": [ "执行控制", "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -28888,6 +28939,8 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", + "PTC", "approval", "parallel", "timeout", @@ -28992,6 +29045,25 @@ ] } }, + { + "id": "tool-presentation", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", + "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "destination": { + "kind": "settings", + "pageId": "tools.execution", + "viewId": "advanced" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "tool-presentation-mode" + } + ] + } + }, { "id": "parallelism", "titleZh": "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", @@ -29175,6 +29247,24 @@ "path": "ai.enable_deferred_tool_loading" } }, + { + "id": "tool-presentation-mode", + "titleZh": "工具呈现模式", + "titleEn": "Tool presentation mode", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "valueSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + }, + "handler": { + "kind": "config", + "path": "ai.tool_presentation_mode" + } + }, { "id": "subagent-batch-policy", "titleZh": "子智能体批量执行策略", @@ -29296,6 +29386,8 @@ "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -29305,6 +29397,7 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", "approval", "parallel", "timeout", @@ -29327,6 +29420,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", + "选择模型使用原生模式还是 PTC TypeScript 模式", + "Choose native mode or PTC TypeScript mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs index ed14d14eab..037c68f158 100644 --- a/src/crates/contracts/product-domains/src/product_control_owner_registry.rs +++ b/src/crates/contracts/product-domains/src/product_control_owner_registry.rs @@ -743,6 +743,12 @@ pub fn owner_definitions() -> Vec { boolean(), config("ai.enable_deferred_tool_loading"), ), + option( + "setting.tools.execution", + "tool-presentation-mode", + string_enum(&["native", "ptc"]), + config("ai.tool_presentation_mode"), + ), option( "setting.tools.execution", "subagent-batch-policy", diff --git a/src/crates/contracts/runtime-ports/AGENTS.md b/src/crates/contracts/runtime-ports/AGENTS.md index c96df4a057..bc96587114 100644 --- a/src/crates/contracts/runtime-ports/AGENTS.md +++ b/src/crates/contracts/runtime-ports/AGENTS.md @@ -26,6 +26,10 @@ 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. +- `PtcRuntime` owns only program execution, nested tool calls, definition + requests, availability, and cancellation contracts behind `ptc-runtime`. + Node process supervision belongs to Services Integrations; tool admission + and permission decisions stay in the Core tool pipeline. - `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), @@ -56,6 +60,7 @@ cargo check --locked -p openbitfun-runtime-ports --no-default-features cargo test --locked -p openbitfun-runtime-ports --no-default-features --features agent-api --lib cargo test --locked -p openbitfun-runtime-ports --no-default-features --features workspace-ports --test session_store_contracts cargo test --locked -p openbitfun-runtime-ports --no-default-features --features plugin-runtime --test plugin_runtime_contracts +cargo test --locked -p openbitfun-runtime-ports --no-default-features --features ptc-runtime --test ptc_tool_port_contracts node scripts/check-core-boundaries.mjs ``` diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index f0f3161b84..c386981db9 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -25,6 +25,11 @@ name = "script_tool_port_contracts" path = "tests/script_tool_port_contracts.rs" required-features = ["script-tool-runtime"] +[[test]] +name = "ptc_tool_port_contracts" +path = "tests/ptc_tool_port_contracts.rs" +required-features = ["ptc-runtime"] + [[test]] name = "hook_function_runtime_contracts" path = "tests/hook_function_runtime_contracts.rs" @@ -52,6 +57,7 @@ agent-api = ["dep:openbitfun-core-types"] plugin-runtime = [] hook-function-runtime = [] script-tool-runtime = [] +ptc-runtime = [] web-search-port = [] workspace-ports = ["dep:anyhow", "dep:tokio-util", "dep:tokio", "tokio/io-util"] terminal-port = ["dep:tokio"] diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index 5c54fed612..1821abbb40 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -23,6 +23,8 @@ mod permission; mod plugin; #[cfg(feature = "product-search")] mod product_search; +#[cfg(feature = "ptc-runtime")] +mod ptc_tool; #[cfg(feature = "script-tool-runtime")] mod script_tool; #[cfg(feature = "web-search-port")] @@ -74,6 +76,11 @@ pub use plugin::{ }; #[cfg(feature = "product-search")] pub use product_search::ProductSearchPort; +#[cfg(feature = "ptc-runtime")] +pub use ptc_tool::{ + PtcExecutionRequest, PtcExecutionResponse, PtcNestedToolCall, PtcRuntime, + PtcRuntimeAvailability, PtcToolBinding, PtcToolCallHandler, PtcToolSpecRequest, +}; #[cfg(feature = "script-tool-runtime")] pub use script_tool::{ ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, @@ -149,6 +156,7 @@ pub enum RuntimeServiceCapability { RemoteWorkspace, RemoteProjection, RemoteCapabilities, + PtcRuntime, } impl RuntimeServiceCapability { @@ -169,6 +177,7 @@ impl RuntimeServiceCapability { Self::RemoteWorkspace => "remote_workspace", Self::RemoteProjection => "remote_projection", Self::RemoteCapabilities => "remote_capabilities", + Self::PtcRuntime => "ptc_runtime", } } } diff --git a/src/crates/contracts/runtime-ports/src/ptc_tool.rs b/src/crates/contracts/runtime-ports/src/ptc_tool.rs new file mode 100644 index 0000000000..4cd76791ca --- /dev/null +++ b/src/crates/contracts/runtime-ports/src/ptc_tool.rs @@ -0,0 +1,93 @@ +//! Provider-neutral port for executing a PTC program. + +use crate::{PortError, PortErrorKind, PortResult, RuntimeServiceCapability}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum PtcRuntimeAvailability { + Available { executable: String, version: String }, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PtcToolBinding { + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PtcExecutionRequest { + pub operation_id: String, + pub source: String, + pub bindings: Vec, + pub working_directory: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_output_bytes: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PtcExecutionResponse { + pub value: Value, + #[serde(default)] + pub logs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PtcNestedToolCall { + pub operation_id: String, + pub tool_name: String, + pub arguments: Value, +} + +/// A PTC-local request to load the definition of a deferred tool. +/// +/// This is deliberately separate from the native `GetToolSpec` tool call. The +/// PTC host keeps the loaded definition in the current runtime operation rather +/// than adding a model-visible tool result to the conversation history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PtcToolSpecRequest { + pub operation_id: String, + pub tool_name: String, +} + +#[async_trait] +pub trait PtcToolCallHandler: Send + Sync { + async fn call(&self, request: PtcNestedToolCall) -> PortResult; + + async fn get_tool_spec(&self, request: PtcToolSpecRequest) -> PortResult { + let _ = request; + Err(PortError::new( + PortErrorKind::NotAvailable, + "PTC deferred tool definition loading is unavailable", + )) + } +} + +#[async_trait] +pub trait PtcRuntime: Send + Sync { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::PtcRuntime + } + + async fn availability(&self) -> PtcRuntimeAvailability; + + async fn execute( + &self, + request: PtcExecutionRequest, + handler: Arc, + ) -> PortResult; + + async fn cancel(&self, operation_id: &str) -> PortResult<()>; +} diff --git a/src/crates/contracts/runtime-ports/tests/ptc_tool_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/ptc_tool_port_contracts.rs new file mode 100644 index 0000000000..7dbadcc30f --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/ptc_tool_port_contracts.rs @@ -0,0 +1,54 @@ +use openbitfun_runtime_ports::{ + PtcExecutionRequest, PtcExecutionResponse, PtcNestedToolCall, PtcRuntimeAvailability, + PtcToolBinding, +}; +use serde_json::json; + +#[test] +fn ptc_execution_wire_shape_is_additive_and_lossless_for_json_values() { + let request = PtcExecutionRequest { + operation_id: "op-1".to_string(), + source: "return await tools.Read({ path: 'x' });".to_string(), + bindings: vec![PtcToolBinding { + name: "Read".to_string(), + }], + working_directory: "/workspace".to_string(), + session_id: Some("session-1".to_string()), + timeout_ms: Some(1000), + max_output_bytes: Some(1024), + }; + let encoded = serde_json::to_value(&request).expect("request serializes"); + let restored: PtcExecutionRequest = serde_json::from_value(encoded).expect("request restores"); + assert_eq!(restored, request); + + let response = PtcExecutionResponse { + value: json!({"unicode": "中文", "nested": [true, null]}), + logs: vec!["done".to_string()], + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(response.clone()).unwrap() + ) + .unwrap(), + response + ); +} + +#[test] +fn ptc_nested_call_and_availability_have_stable_wire_names() { + let call = PtcNestedToolCall { + operation_id: "nested-1".to_string(), + tool_name: "Read".to_string(), + arguments: json!({"path": "README.md"}), + }; + let value = serde_json::to_value(call).expect("nested call serializes"); + assert_eq!(value["operationId"], "nested-1"); + assert_eq!(value["toolName"], "Read"); + assert_eq!( + serde_json::to_value(PtcRuntimeAvailability::Unavailable { + reason: "missing node".to_string() + }) + .unwrap()["state"], + "unavailable" + ); +} diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index 76db1e2e76..28cb477803 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -615,6 +615,8 @@ pub struct ToolListingSections { pub agent_listing: Option, pub direct_tool_listing: Option, pub deferred_tool_listing: Option, + /// PTC's generated TypeScript SDK, rendered as a conversation-start guide. + pub ptc_tool_calling_guide: Option, } impl ToolListingSections { @@ -623,6 +625,7 @@ impl ToolListingSections { && self.agent_listing.is_none() && self.direct_tool_listing.is_none() && self.deferred_tool_listing.is_none() + && self.ptc_tool_calling_guide.is_none() } pub fn render_skill_listing_reminder(&self) -> Option { @@ -675,6 +678,7 @@ impl ToolListingSections { #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PrependedPromptReminders { pub deferred_tool_listing: Option, + pub ptc_tool_calling_guide: Option, pub skill_listing: Option, pub agent_listing: Option, pub runtime_context: Option, @@ -687,6 +691,9 @@ impl PrependedPromptReminders { if let Some(deferred_tool_listing) = self.deferred_tool_listing.as_deref() { reminders.push(deferred_tool_listing); } + if let Some(ptc_tool_calling_guide) = self.ptc_tool_calling_guide.as_deref() { + reminders.push(ptc_tool_calling_guide); + } if let Some(skill_listing) = self.skill_listing.as_deref() { reminders.push(skill_listing); } diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index ceb15fa5b0..d78d93bd6b 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -2,7 +2,7 @@ use crate::session_state::SessionState; pub use openbitfun_core_types::SessionKind; pub use openbitfun_core_types::{ SessionAgentRouteOwner, SessionContinuationPolicy, SessionExecutionTarget, - SessionModelBindingPolicy, + SessionModelBindingPolicy, ToolPresentationMode, }; pub use openbitfun_runtime_ports::PermissionMode; use serde::{Deserialize, Serialize}; @@ -245,6 +245,10 @@ pub struct SessionConfig { skip_serializing_if = "Option::is_none" )] pub permission_mode: Option, + /// Frozen tool presentation for this session. Legacy sessions default to + /// native tool calls when this field is absent on disk. + #[serde(default, skip_serializing_if = "ToolPresentationMode::is_native")] + pub tool_presentation_mode: ToolPresentationMode, /// Whether this child session accepts another delegated turn. #[serde(default, skip_serializing_if = "is_reusable_continuation_policy")] pub continuation_policy: SessionContinuationPolicy, @@ -322,6 +326,7 @@ impl Default for SessionConfig { model_pool_binding: None, reasoning_preset: None, permission_mode: None, + tool_presentation_mode: ToolPresentationMode::default(), continuation_policy: SessionContinuationPolicy::default(), model_binding_policy: SessionModelBindingPolicy::default(), model_binding_fingerprint: None, @@ -414,7 +419,7 @@ mod tests { use super::{ sanitize_persisted_session_state, CompressionState, PermissionMode, PersistedSessionStateFile, Session, SessionAgentRouteOwner, SessionConfig, - SessionContinuationPolicy, SessionModelBindingPolicy, + SessionContinuationPolicy, SessionModelBindingPolicy, ToolPresentationMode, }; use crate::session_state::{ProcessingPhase, SessionState}; @@ -515,6 +520,7 @@ mod tests { assert!(config.remote_ssh_host.is_none()); assert!(config.model_id.is_none()); assert!(config.reasoning_preset.is_none()); + assert_eq!(config.tool_presentation_mode, ToolPresentationMode::Native); assert_eq!( config.continuation_policy, SessionContinuationPolicy::Reusable @@ -527,9 +533,29 @@ mod tests { assert!(config.agent_route_key.is_none()); } + #[test] + fn session_tool_presentation_mode_is_frozen_and_legacy_data_defaults_native() { + let config = SessionConfig { + tool_presentation_mode: ToolPresentationMode::Ptc, + ..SessionConfig::default() + }; + let mut serialized = serde_json::to_value(&config).expect("serialize session config"); + assert_eq!(serialized["tool_presentation_mode"], "ptc"); + let restored: SessionConfig = serde_json::from_value(serialized.clone()).unwrap(); + assert_eq!(restored.tool_presentation_mode, ToolPresentationMode::Ptc); + + serialized + .as_object_mut() + .expect("session config object") + .remove("tool_presentation_mode"); + let legacy: SessionConfig = serde_json::from_value(serialized).unwrap(); + assert_eq!(legacy.tool_presentation_mode, ToolPresentationMode::Native); + } + #[test] fn external_agent_route_owner_persists_and_legacy_sessions_default_local() { let config = SessionConfig { + tool_presentation_mode: Default::default(), agent_route_owner: SessionAgentRouteOwner::External, agent_route_key: Some("opencode:plugin:build".to_string()), ..SessionConfig::default() @@ -555,6 +581,7 @@ mod tests { #[test] fn non_default_subagent_session_policies_are_persisted_and_legacy_defaults_remain_compatible() { let config = SessionConfig { + tool_presentation_mode: Default::default(), continuation_policy: SessionContinuationPolicy::FreshOnly, model_binding_policy: SessionModelBindingPolicy::ApprovedImmutable, ..SessionConfig::default() @@ -619,6 +646,7 @@ mod tests { "Main".to_string(), "Standard".to_string(), SessionConfig { + tool_presentation_mode: Default::default(), model_id: Some("provider/model".to_string()), workspace_path: Some("/worktrees/session_1".to_string()), workspace_id: Some("workspace_1".to_string()), @@ -671,6 +699,7 @@ mod tests { let file = PersistedSessionStateFile { schema_version: 1, config: SessionConfig { + tool_presentation_mode: Default::default(), workspace_path: Some("/workspace".to_string()), model_id: Some("model-a".to_string()), ..SessionConfig::default() diff --git a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs index 6036415f93..e20f5190a9 100644 --- a/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs +++ b/src/crates/execution/agent-runtime/src/skill_agent_snapshot.rs @@ -385,6 +385,7 @@ pub fn build_skill_agent_tool_listing_sections_from_snapshot( .deferred_tools .as_deref() .and_then(render_full_deferred_tool_listing_body), + ptc_tool_calling_guide: None, } } diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs index 3360fa6ab4..8f2e86c11a 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/prompt_contracts.rs @@ -50,6 +50,7 @@ fn tool_listing_sections_render_only_present_sections() { .to_string(), ), deferred_tool_listing: Some("Search: summary".to_string()), + ptc_tool_calling_guide: None, }; assert!(!sections.is_empty()); @@ -92,6 +93,7 @@ fn tool_listing_sections_render_only_present_sections() { fn prepended_prompt_reminders_keep_runtime_injection_order() { let reminders = PrependedPromptReminders { deferred_tool_listing: Some("deferred-tools".to_string()), + ptc_tool_calling_guide: None, skill_listing: Some("skills".to_string()), agent_listing: Some("agents".to_string()), runtime_context: Some("runtime-context".to_string()), @@ -113,6 +115,23 @@ fn prepended_prompt_reminders_keep_runtime_injection_order() { .is_empty()); } +#[test] +fn ptc_sdk_is_injected_as_a_separate_tool_calling_reminder() { + let reminders = PrependedPromptReminders { + ptc_tool_calling_guide: Some( + "# Tool Calling Guide\nGenerated SDK:\n```ts\nexport type ToolArgsMap = {};\n```" + .to_string(), + ), + ..Default::default() + }; + assert_eq!( + reminders.ptc_tool_calling_guide.as_deref(), + Some("# Tool Calling Guide\nGenerated SDK:\n```ts\nexport type ToolArgsMap = {};\n```") + ); + assert_eq!(reminders.ordered_reminders().len(), 1); + assert!(reminders.ordered_reminders()[0].contains("ToolArgsMap")); +} + #[test] fn prompt_environment_info_preserves_local_and_remote_guidance() { let local = render_prompt_environment_info(PromptEnvironmentFacts { diff --git a/src/crates/execution/runtime-services/src/lib.rs b/src/crates/execution/runtime-services/src/lib.rs index 467631fe5d..10d8da39ea 100644 --- a/src/crates/execution/runtime-services/src/lib.rs +++ b/src/crates/execution/runtime-services/src/lib.rs @@ -161,6 +161,7 @@ impl RuntimeServices { RuntimeServiceCapability::RemoteWorkspace => self.remote_workspace.is_some(), RuntimeServiceCapability::RemoteProjection => self.remote_projection.is_some(), RuntimeServiceCapability::RemoteCapabilities => self.remote_capabilities.is_some(), + RuntimeServiceCapability::PtcRuntime => false, } } diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index f1ebb0f49c..e22ebdcc1e 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -153,14 +153,10 @@ pub fn validate_deferred_tool_usage( current_catalog_generation: u64, get_tool_spec_tool_name: &str, ) -> Result<(), DeferredToolUsageError> { - if tool_name == get_tool_spec_tool_name { - return Ok(()); - } - - if !deferred_tools - .iter() - .any(|deferred_tool| deferred_tool == tool_name) - { + // The admission helper is shared by direct and deferred calls. A receipt + // for a tool that is no longer deferred must not affect ordinary direct + // execution. + if !deferred_tools.iter().any(|name| name == tool_name) { return Ok(()); } @@ -179,10 +175,10 @@ pub fn validate_deferred_tool_usage( }); } - Err(DeferredToolUsageError::RequiresGetToolSpec { - tool_name: tool_name.to_string(), - get_tool_spec_tool_name: get_tool_spec_tool_name.to_string(), - }) + // Deferred exposure is a model-facing optimization. Execution remains + // authorized by the allowlist, runtime restrictions, and the tool's own + // validation; a missing spec is no longer a hard execution prerequisite. + Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -545,7 +541,7 @@ pub struct GetToolSpecLoadObservation<'a> { pub is_error: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct LoadedDeferredToolSpec { pub tool_name: String, pub catalog_generation: u64, @@ -689,6 +685,18 @@ pub trait ToolRegistryItem: Send + Sync { fn input_schema(&self) -> Value; + /// JSON Schema for the compact assistant-facing projection of the value + /// returned to a PTC program. + /// + /// Keep fields that appear in `result_for_assistant` or are needed for + /// model control flow, and omit UI, diagnostic, persistence, and transport + /// metadata from the generated SDK. Existing tools may omit a stronger + /// contract; the empty schema projects to `unknown` and does not change + /// native tool behavior. + fn output_schema(&self) -> Value { + Value::Object(serde_json::Map::new()) + } + fn short_description(&self) -> String { self.name().to_string() } diff --git a/src/crates/execution/tool-contracts/src/lib.rs b/src/crates/execution/tool-contracts/src/lib.rs index d817f506fa..34b127be22 100644 --- a/src/crates/execution/tool-contracts/src/lib.rs +++ b/src/crates/execution/tool-contracts/src/lib.rs @@ -23,6 +23,7 @@ pub mod input_validator; #[cfg(feature = "mcp-bridge")] pub mod mcp_tool_bridge; pub mod permission_intent; +pub mod ptc; pub mod tool_execution_presentation; pub mod tool_result_storage; pub mod tool_snapshot; @@ -111,6 +112,14 @@ pub use openbitfun_runtime_ports::{ DynamicToolDescriptor, DynamicToolProvider, PortError, PortErrorKind, PortResult, ToolDecorator, }; pub use permission_intent::PermissionIntent; +pub use ptc::{ + json_schema_to_typescript, ptc_run_code_description, ptc_run_code_input_schema, + render_ptc_loaded_tool_typescript_sdk, render_ptc_tool_calling_guide, + render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk, + render_ptc_typescript_sdk_with_options, PtcToolCallError, PtcToolContract, + PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY, PTC_RUN_CODE_TOOL_NAME, + PTC_TOOL_PRESENTATION_CONTEXT_KEY, PTC_TOOL_PRESENTATION_MODE, +}; pub use tool_execution_presentation::{ build_invalid_tool_call_error_message, build_normal_tool_json_repair_notice, build_permission_denied_tool_presentation, build_tool_call_truncation_recovery_notice, diff --git a/src/crates/execution/tool-contracts/src/ptc.rs b/src/crates/execution/tool-contracts/src/ptc.rs new file mode 100644 index 0000000000..3154c182bb --- /dev/null +++ b/src/crates/execution/tool-contracts/src/ptc.rs @@ -0,0 +1,721 @@ +//! Provider-neutral contracts used by the PTC tool presentation mode. +//! +//! This module deliberately contains no process, model-provider, or product +//! runtime code. It describes the stable tool snapshot that can be projected +//! into a TypeScript program before the program is executed by a host runtime. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub const PTC_TOOL_PRESENTATION_CONTEXT_KEY: &str = "tool_presentation_mode"; +pub const PTC_TOOL_PRESENTATION_MODE: &str = "ptc"; +/// Serialized per-turn allowlist used by the PTC host for nested calls. +/// +/// The model-facing manifest intentionally contains only `RunCode`, while +/// nested SDK calls must still use the Agent's original tool policy. +pub const PTC_NESTED_ALLOWED_TOOLS_CONTEXT_KEY: &str = "ptc_nested_allowed_tools"; +pub const PTC_RUN_CODE_TOOL_NAME: &str = "RunCode"; + +/// A tool definition exposed to a PTC program. +/// +/// `input_schema` and `output_schema` are JSON Schema values. `output_schema` +/// describes the compact assistant-facing projection of a tool result. It is +/// deliberately smaller than the canonical value when the latter contains +/// UI, diagnostics, persistence, or transport metadata; fields needed for +/// model control flow (such as cursors and operation ids) should still be +/// included. An empty output schema means that the tool has no stronger output +/// contract and projects to `unknown` in TypeScript. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PtcToolContract { + pub name: String, + pub description: String, + pub input_schema: Value, + #[serde(default)] + pub output_schema: Value, + #[serde(default)] + pub is_readonly: bool, + #[serde(default)] + pub is_concurrency_safe: bool, + /// Deferred tools are listed by name and description, but their full + /// schema is loaded through `tools.getToolSpec` at runtime. + #[serde(default)] + pub is_deferred: bool, +} + +impl PtcToolContract { + pub fn new( + name: impl Into, + description: impl Into, + input_schema: Value, + output_schema: Value, + ) -> Self { + Self { + name: name.into(), + description: description.into(), + input_schema, + output_schema, + is_readonly: false, + is_concurrency_safe: false, + is_deferred: false, + } + } +} + +/// The error shape returned by a failed nested tool call. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PtcToolCallError { + pub code: String, + pub message: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// Converts a JSON Schema value into a deterministic TypeScript type. +/// +/// JSON Schema is larger than TypeScript's type system. Unsupported or +/// ambiguous constructs intentionally degrade to `unknown`; runtime JSON +/// validation remains authoritative when the generated SDK is invoked. +pub fn json_schema_to_typescript(schema: &Value) -> String { + schema_type(schema) +} + +/// Render the type declarations used by the generated PTC SDK. +pub fn render_ptc_typescript_sdk(contracts: &[PtcToolContract]) -> String { + render_ptc_typescript_sdk_with_options(contracts, true) +} + +/// Render the generated PTC SDK, optionally exposing the APIs used to load and +/// invoke deferred tools by name. +pub fn render_ptc_typescript_sdk_with_options( + contracts: &[PtcToolContract], + include_deferred_tool_apis: bool, +) -> String { + render_ptc_typescript_sdk_internal( + contracts, + include_deferred_tool_apis, + true, + include_deferred_tool_apis, + ) +} + +/// Render the SDK returned after a deferred tool definition has been loaded. +/// +/// The conversation reminder already explains the load workflow. This result +/// only needs the loaded tool's concrete types and the generic call API used +/// to invoke it. +pub fn render_ptc_loaded_tool_typescript_sdk(contract: &PtcToolContract) -> String { + let mut contract = contract.clone(); + let call_hint = format!( + "Invoke it with tools.callTool(\"{}\", args).", + contract.name + ); + contract.description = if contract.description.trim().is_empty() { + call_hint + } else { + format!("{} {}", contract.description, call_hint) + }; + render_ptc_typescript_sdk_internal(&[contract], false, false, true) +} + +fn render_ptc_typescript_sdk_internal( + contracts: &[PtcToolContract], + include_deferred_tool_apis: bool, + include_shared_types: bool, + include_call_tool_api: bool, +) -> String { + let mut output = String::new(); + if include_shared_types { + output.push_str( + "export type PtcToolCallError = { code: string; message: string; details?: unknown };\n\n", + ); + } + + output.push_str("export type ToolArgsMap = {\n"); + for contract in contracts { + let description = if contract.is_deferred { + format!( + "{} Deferred tool: load its definition with tools.getToolSpec(\"{}\") first, then invoke it with tools.callTool(\"{}\", args).", + contract.description, contract.name, contract.name + ) + } else { + contract.description.clone() + }; + output.push_str(&render_jsdoc(&description, " ")); + output.push_str(" "); + output.push_str(&typescript_property_key(&contract.name)); + output.push_str(": "); + if contract.is_deferred { + output.push_str("Record"); + } else { + output.push_str(&schema_type_with_docs(&contract.input_schema, 2)); + } + output.push_str(";\n"); + } + output.push_str("};\n\n"); + + output.push_str("export type ToolOutputMap = {\n"); + for contract in contracts { + output.push_str(" "); + output.push_str(&typescript_property_key(&contract.name)); + output.push_str(": "); + if contract.is_deferred { + output.push_str("unknown"); + } else { + output.push_str(&schema_type_with_docs(&contract.output_schema, 2)); + } + output.push_str(";\n"); + } + output.push_str("};\n\n"); + + output.push_str("export type PtcToolBindings = {\n"); + for contract in contracts { + if contract.is_deferred { + output.push_str( + " /** Deferred tool placeholder. Load its schema with tools.getToolSpec and execute with tools.callTool; do not guess its arguments. */\n", + ); + } + output.push_str(" "); + output.push_str(&typescript_property_key(&contract.name)); + output.push_str("(args: ToolArgsMap["); + output.push_str(&json_string_literal(&contract.name)); + output.push_str("]): Promise;\n"); + } + if include_deferred_tool_apis { + output + .push_str(" /** Load a deferred tool definition into the current PTC operation. */\n"); + output.push_str(" getToolSpec(name: string): Promise<{ typescript_sdk: string }>;\n"); + } + if include_call_tool_api { + output + .push_str(" /** Execute a tool by name with arguments matching its loaded SDK. */\n"); + output.push_str( + " callTool(name: string, args: Record): Promise;\n", + ); + } + output.push_str("};\n\n"); + output.push_str("declare const tools: PtcToolBindings;\n"); + output +} + +/// Input schema for the single model-visible PTC entrypoint. +pub fn ptc_run_code_input_schema() -> Value { + serde_json::json!({ + "type": "object", + "additionalProperties": false, + "properties": { + "code": { + "type": "string", + "description": "TypeScript program. Call tools by name through the generated tools binding and return a JSON value." + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "description": "Optional positive execution deadline in milliseconds." + } + }, + "required": ["code"] + }) +} + +pub fn ptc_run_code_description() -> String { + "Execute a TypeScript program that can call available tools through the provided `tools` binding and return a compact JSON value." + .to_string() +} + +/// Render the PTC SDK as a conversation-start reminder instead of embedding +/// the large declaration block in the single `RunCode` tool description. +pub fn render_ptc_tool_calling_guide(sdk_source: &str) -> String { + render_ptc_tool_calling_guide_with_options(sdk_source, true) +} + +/// Render the PTC conversation-start guide with wording that matches the +/// capabilities exposed by the generated SDK. +pub fn render_ptc_tool_calling_guide_with_options( + sdk_source: &str, + include_deferred_tool_apis: bool, +) -> String { + let deferred_guidance = if include_deferred_tool_apis { + "\n\nFor deferred tools:\n1. Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.\n2. Read the returned `typescript_sdk`, which contains the input and output types.\n3. Call `tools.callTool(\"tool_name\", args)` with arguments matching that SDK.\nDo not infer deferred tool arguments from the placeholder `Record` type." + } else { + "" + }; + format!( + "# Tool Calling Guide\nUse `RunCode` with a TypeScript program. Call tools through the generated `tools` binding and return a JSON-serializable value. The binding includes direct tool signatures.{}\n\nGenerated SDK:\n```ts\n{}\n```", + deferred_guidance, + sdk_source.trim(), + ) +} + +fn schema_type(schema: &Value) -> String { + let Some(object) = schema.as_object() else { + return "unknown".to_string(); + }; + + if let Some(constant) = object.get("const") { + return json_literal_type(constant); + } + + if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { + if enum_values.is_empty() { + return "never".to_string(); + } + return enum_values + .iter() + .map(json_literal_type) + .collect::>() + .join(" | "); + } + + for key in ["oneOf", "anyOf"] { + if let Some(variants) = object.get(key).and_then(Value::as_array) { + if variants.is_empty() { + return "unknown".to_string(); + } + return variants + .iter() + .map(schema_type) + .collect::>() + .join(" | "); + } + } + + if let Some(all_of) = object.get("allOf").and_then(Value::as_array) { + if all_of.is_empty() { + return "unknown".to_string(); + } + return all_of + .iter() + .map(schema_type) + .collect::>() + .join(" & "); + } + + // JSON Schema also permits a type union in the compact `type: [...]` + // form. Normalize each member through the same projector used by + // `anyOf`, so nullable fields remain useful in the generated SDK. + if let Some(types) = object.get("type").and_then(Value::as_array) { + if types.is_empty() { + return "unknown".to_string(); + } + return types + .iter() + .map(|type_value| { + let Some(type_name) = type_value.as_str() else { + return "unknown".to_string(); + }; + let mut variant = Map::new(); + variant.insert("type".to_string(), Value::String(type_name.to_string())); + schema_type(&Value::Object(variant)) + }) + .collect::>() + .join(" | "); + } + + match object.get("type").and_then(Value::as_str) { + Some("null") => "null".to_string(), + Some("boolean") => "boolean".to_string(), + Some("integer") | Some("number") => "number".to_string(), + Some("string") => "string".to_string(), + Some("array") => object + .get("items") + .map(schema_type) + .map(|item| format!("Array<{item}>")) + .unwrap_or_else(|| "Array".to_string()), + Some("object") => object_type(object), + Some("true") => "unknown".to_string(), + Some("false") => "never".to_string(), + _ => "unknown".to_string(), + } +} + +fn object_type(object: &Map) -> String { + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + return object + .get("additionalProperties") + .map(|value| match value { + Value::Bool(false) => "Record".to_string(), + Value::Bool(true) => "Record".to_string(), + _ => format!("Record", schema_type(value)), + }) + .unwrap_or_else(|| "unknown".to_string()); + }; + + if properties.is_empty() { + return match object.get("additionalProperties") { + Some(Value::Bool(false)) => "Record".to_string(), + Some(Value::Bool(true)) | None => "Record".to_string(), + Some(schema) => format!("Record", schema_type(schema)), + }; + } + + let required = object + .get("required") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + + let mut output = String::from("{ "); + for (name, property) in properties { + output.push_str("e_property_key(name)); + if !required.contains(name.as_str()) { + output.push('?'); + } + output.push_str(": "); + output.push_str(&schema_type(property)); + output.push_str("; "); + } + if !matches!(object.get("additionalProperties"), Some(Value::Bool(false))) { + output.push_str("[key: string]: unknown; "); + } + output.push('}'); + output +} + +/// Render the same schema projection as [`json_schema_to_typescript`] while +/// retaining the descriptions that help a model call the generated SDK. +fn schema_type_with_docs(schema: &Value, indent: usize) -> String { + let Some(object) = schema.as_object() else { + return "unknown".to_string(); + }; + + if let Some(constant) = object.get("const") { + return json_literal_type(constant); + } + + if let Some(enum_values) = object.get("enum").and_then(Value::as_array) { + if enum_values.is_empty() { + return "never".to_string(); + } + return enum_values + .iter() + .map(json_literal_type) + .collect::>() + .join(" | "); + } + + for key in ["oneOf", "anyOf"] { + if let Some(variants) = object.get(key).and_then(Value::as_array) { + if variants.is_empty() { + return "unknown".to_string(); + } + return variants + .iter() + .map(|variant| schema_type_with_docs(variant, indent)) + .collect::>() + .join(" | "); + } + } + + if let Some(all_of) = object.get("allOf").and_then(Value::as_array) { + if all_of.is_empty() { + return "unknown".to_string(); + } + return all_of + .iter() + .map(|variant| schema_type_with_docs(variant, indent)) + .collect::>() + .join(" & "); + } + + match object.get("type").and_then(Value::as_str) { + Some("array") => object + .get("items") + .map(|item| format!("Array<{}>", schema_type_with_docs(item, indent))) + .unwrap_or_else(|| "Array".to_string()), + Some("object") => object_type_with_docs(object, indent), + _ => schema_type(schema), + } +} + +fn object_type_with_docs(object: &Map, indent: usize) -> String { + let Some(properties) = object.get("properties").and_then(Value::as_object) else { + return object_type(object); + }; + + if properties.is_empty() { + return object_type(object); + } + + let required = object + .get("required") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .collect::>() + }) + .unwrap_or_default(); + let property_indent = " ".repeat(indent + 2); + let closing_indent = " ".repeat(indent); + let mut output = String::from("{\n"); + + for (name, property) in properties { + if let Some(description) = property.get("description").and_then(Value::as_str) { + output.push_str(&render_jsdoc(description, &property_indent)); + } + output.push_str(&property_indent); + output.push_str(&typescript_property_key(name)); + if !required.contains(name.as_str()) { + output.push('?'); + } + output.push_str(": "); + output.push_str(&schema_type_with_docs(property, indent + 2)); + output.push_str(";\n"); + } + + if !matches!(object.get("additionalProperties"), Some(Value::Bool(false))) { + output.push_str(&property_indent); + output.push_str("[key: string]: unknown;\n"); + } + output.push_str(&closing_indent); + output.push('}'); + output +} + +fn render_jsdoc(description: &str, indent: &str) -> String { + let description = description + .split_whitespace() + .collect::>() + .join(" ") + .replace("*/", "* /"); + if description.is_empty() { + return String::new(); + } + format!("{indent}/** {description} */\n") +} + +fn quote_property_key(value: &str) -> String { + json_string_literal(value) +} + +fn typescript_property_key(value: &str) -> String { + if is_typescript_identifier(value) { + value.to_string() + } else { + json_string_literal(value) + } +} + +fn is_typescript_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first == '_' || first == '$' || first.is_ascii_alphabetic()) + && chars.all(|character| { + character == '_' || character == '$' || character.is_ascii_alphanumeric() + }) +} + +fn json_string_literal(value: &str) -> String { + serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()) +} + +fn json_literal_type(value: &Value) -> String { + match value { + Value::String(_) => json_string_literal(value.as_str().unwrap_or_default()), + Value::Number(number) => number.to_string(), + Value::Bool(value) => value.to_string(), + Value::Null => "null".to_string(), + Value::Array(_) | Value::Object(_) => "unknown".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::{ + json_schema_to_typescript, ptc_run_code_description, ptc_run_code_input_schema, + render_ptc_loaded_tool_typescript_sdk, render_ptc_tool_calling_guide, + render_ptc_tool_calling_guide_with_options, render_ptc_typescript_sdk, + render_ptc_typescript_sdk_with_options, PtcToolContract, + }; + use serde_json::json; + + #[test] + fn projects_object_schema_with_required_and_optional_properties() { + let result = json_schema_to_typescript(&json!({ + "type": "object", + "properties": { + "path": {"type": "string"}, + "limit": {"type": "integer"} + }, + "required": ["path"], + "additionalProperties": false + })); + + assert_eq!(result, "{ \"limit\"?: number; \"path\": string; }"); + } + + #[test] + fn unsupported_schema_is_unknown_and_empty_output_is_open() { + assert_eq!(json_schema_to_typescript(&json!({})), "unknown"); + assert_eq!( + json_schema_to_typescript(&json!({"type": "array"})), + "Array" + ); + } + + #[test] + fn projects_json_schema_type_arrays_as_typescript_unions() { + assert_eq!( + json_schema_to_typescript(&json!({"type": ["integer", "null"]})), + "number | null" + ); + } + + #[test] + fn sdk_quotes_arbitrary_tool_names_and_contains_input_and_output_maps() { + let mut contract = PtcToolContract::new( + "tool.with-dash", + "demo", + json!({"type": "object", "properties": {}, "additionalProperties": false}), + json!({"type": "string"}), + ); + contract.is_readonly = true; + let output = render_ptc_typescript_sdk(&[contract]); + + assert!(output.contains("export type ToolArgsMap")); + assert!(output.contains("\"tool.with-dash\": Record;")); + assert!(output.contains("\"tool.with-dash\": string;")); + assert!(output.contains("Promise")); + } + + #[test] + fn sdk_preserves_tool_and_parameter_descriptions_as_jsdoc() { + let contract = PtcToolContract::new( + "ask_user_question", + "Ask the user a concise question when you need confirmation.", + json!({ + "type": "object", + "properties": { + "questions": { + "type": "array", + "description": "Questions to ask the user before continuing.", + "items": {"type": "string"} + }, + "timeout_ms": { + "type": "integer", + "description": "Optional deadline in milliseconds." + } + }, + "required": ["questions"], + "additionalProperties": false + }), + json!({"type": "string", "description": "The user's answer."}), + ); + + let output = render_ptc_typescript_sdk(&[contract]); + assert!(output.contains( + " /** Ask the user a concise question when you need confirmation. */\n ask_user_question:" + )); + assert!(output.contains( + " /** Questions to ask the user before continuing. */\n questions: Array;" + )); + assert!(output + .contains(" /** Optional deadline in milliseconds. */\n timeout_ms?: number;")); + assert_eq!( + output + .matches("/** Ask the user a concise question when you need confirmation. */") + .count(), + 1 + ); + assert!(output.contains(" ask_user_question: string;")); + assert!(output.contains(" ask_user_question(args: ToolArgsMap[\"ask_user_question\"]):")); + } + + #[test] + fn sdk_lists_deferred_tools_without_eager_schema_and_adds_lazy_apis() { + let mut contract = PtcToolContract::new( + "WebFetch", + "Fetch a web page.", + json!({"type": "object", "properties": {"url": {"type": "string"}}}), + json!({"type": "object", "properties": {"content": {"type": "string"}}}), + ); + contract.is_deferred = true; + + let output = render_ptc_typescript_sdk(&[contract]); + assert!(output.contains("WebFetch: Record;")); + assert!(output.contains("WebFetch: unknown;")); + assert!(output.contains("getToolSpec(name: string)")); + assert!(output.contains("Promise<{ typescript_sdk: string }>")); + assert!(!output.contains("input_schema: unknown")); + assert!(output.contains("callTool(name: string, args: Record)")); + assert!(!output.contains("url: string")); + assert!(!output.contains("Deferred tool workflow:")); + assert!( + !output.contains("Call tools.getToolSpec(\"tool_name\") before choosing arguments.") + ); + assert!(output.contains( + "Deferred tool: load its definition with tools.getToolSpec(\"WebFetch\") first" + )); + assert!(output.contains( + "Deferred tool placeholder. Load its schema with tools.getToolSpec and execute with tools.callTool" + )); + + let guide = render_ptc_tool_calling_guide_with_options(&output, true); + assert!(guide.contains("For deferred tools:")); + assert!( + guide.contains("Call `tools.getToolSpec(\"tool_name\")` before choosing arguments.") + ); + assert!(guide.contains("Read the returned `typescript_sdk`")); + assert!(guide.contains("Call `tools.callTool(\"tool_name\", args)`")); + assert!(guide.contains("Do not infer deferred tool arguments")); + } + + #[test] + fn loaded_tool_sdk_keeps_only_call_guidance_and_concrete_types() { + let contract = PtcToolContract::new( + "WebFetch", + "Fetch a web page.", + json!({"type": "object", "properties": {"url": {"type": "string"}}}), + json!({"type": "object", "properties": {"content": {"type": "string"}}}), + ); + + let output = render_ptc_loaded_tool_typescript_sdk(&contract); + assert!(!output.contains("PtcToolCallError")); + assert!(!output.contains("Deferred tool workflow:")); + assert!(!output.contains("getToolSpec(name: string)")); + assert!(output.contains("callTool(name: string, args: Record)")); + assert!(output.contains("Invoke it with tools.callTool(\"WebFetch\", args).")); + assert!(output.contains("url?: string")); + assert!(output.contains("content?: string")); + } + + #[test] + fn sdk_can_omit_deferred_tool_apis_when_loading_is_disabled() { + let output = render_ptc_typescript_sdk_with_options(&[], false); + assert!(!output.contains("getToolSpec(name: string)")); + assert!(!output.contains("callTool(name: string, args: Record)")); + + let guide = render_ptc_tool_calling_guide_with_options(&output, false); + assert!(guide.contains("The binding includes direct tool signatures.")); + assert!(!guide.contains("deferred tools")); + } + + #[test] + fn run_code_schema_requires_code_and_description_points_to_guide() { + let schema = ptc_run_code_input_schema(); + assert_eq!(schema["required"], json!(["code"])); + assert_eq!(schema["properties"]["code"]["type"], "string"); + let description = ptc_run_code_description(); + assert!(description.contains("TypeScript program")); + assert!(description.contains("provided `tools` binding")); + assert!(description.contains("compact JSON value")); + assert!(!description.contains("Tool Calling Guide")); + assert!(!description.contains("permission")); + assert!(!description.contains("remote-routing")); + assert!( + render_ptc_tool_calling_guide("export type ToolArgsMap = {};").contains("ToolArgsMap") + ); + } +} diff --git a/src/crates/execution/tool-contracts/src/tool_snapshot.rs b/src/crates/execution/tool-contracts/src/tool_snapshot.rs index d6edfd8485..7dcb20691c 100644 --- a/src/crates/execution/tool-contracts/src/tool_snapshot.rs +++ b/src/crates/execution/tool-contracts/src/tool_snapshot.rs @@ -101,6 +101,8 @@ pub struct ToolSnapshotItem { pub name: String, pub description: String, pub input_schema: Value, + #[serde(default)] + pub output_schema: Value, pub short_description: String, pub provider: ToolProviderIdentity, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -233,6 +235,7 @@ pub async fn materialize_tool_snapshot( name: tool.name().to_string(), description: tool.description().await?, input_schema: tool.input_schema_for_model().await, + output_schema: tool.output_schema(), short_description: tool.short_description(), provider, dynamic_info, diff --git a/src/crates/execution/tool-contracts/tests/tool_contracts.rs b/src/crates/execution/tool-contracts/tests/tool_contracts.rs index 0c07534076..058c8a54ea 100644 --- a/src/crates/execution/tool-contracts/tests/tool_contracts.rs +++ b/src/crates/execution/tool-contracts/tests/tool_contracts.rs @@ -1458,22 +1458,18 @@ fn runtime_artifact_reference_owner_preserves_session_prefix_and_rejects_escape( } #[test] -fn deferred_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { +fn deferred_tool_usage_allows_calls_without_a_loaded_spec() { let deferred_tools = vec!["WebFetch".to_string()]; let loaded_deferred_tool_specs = Vec::new(); - let err = validate_deferred_tool_usage( + validate_deferred_tool_usage( "WebFetch", &deferred_tools, &loaded_deferred_tool_specs, 0, GET_TOOL_SPEC_TOOL_NAME, ) - .expect_err("deferred tool should require GetToolSpec unlock"); - assert_eq!( - err.to_string(), - "Tool 'WebFetch' has no loaded definition in the current context. Call GetToolSpec with {\"tool_name\":\"WebFetch\"} to read its full usage instructions and input schema before invoking it. If compaction removed an earlier definition, reload it even if the summary says it was loaded." - ); + .expect("deferred exposure must not block execution when no spec is loaded"); let loaded_deferred_tool_specs = vec![LoadedDeferredToolSpec { tool_name: "WebFetch".to_string(), @@ -1510,7 +1506,19 @@ fn deferred_tool_usage_gate_preserves_get_tool_spec_unlock_contract() { 0, GET_TOOL_SPEC_TOOL_NAME, ) - .expect("GetToolSpec itself is the unlock path"); + .expect("GetToolSpec remains executable"); + + validate_deferred_tool_usage( + "WebFetch", + &[], + &[LoadedDeferredToolSpec { + tool_name: "WebFetch".to_string(), + catalog_generation: 41, + }], + 42, + GET_TOOL_SPEC_TOOL_NAME, + ) + .expect("a stale receipt must not affect a tool that is no longer deferred"); } #[test] diff --git a/src/crates/execution/tool-execution/src/exec_command.rs b/src/crates/execution/tool-execution/src/exec_command.rs index 94011a8e03..27636fcb49 100644 --- a/src/crates/execution/tool-execution/src/exec_command.rs +++ b/src/crates/execution/tool-execution/src/exec_command.rs @@ -779,12 +779,10 @@ fn exec_command_result_fields_value( include_completion: bool, ) -> Value { let mut data = json!({ - "chunk_id": fields.chunk_id, "wall_time_seconds": fields.wall_time_seconds, "output": fields.output, "session_id": fields.session_id, "exit_code": fields.exit_code, - "original_output_chars": fields.original_output_chars, }); if include_completion { data["completion"] = fields @@ -900,7 +898,6 @@ pub fn write_stdin_session_not_found_result( "session_id": null, "exit_code": null, "output": "", - "original_output_chars": 0, }); if remote { data["remote"] = json!(true); @@ -928,7 +925,6 @@ pub fn exec_control_session_not_found_result( "session_id": null, "exit_code": null, "output": "", - "original_output_chars": 0, "action": action_name, }); if remote { @@ -1492,7 +1488,7 @@ mod tests { } #[test] - fn exec_command_result_builder_preserves_existing_wire_shape() { + fn exec_command_result_builder_exposes_only_model_result_fields() { let data = exec_command_result_value(ExecCommandResultData { fields: ExecCommandResultFields { chunk_id: "chunk-1".to_string(), @@ -1522,12 +1518,10 @@ mod tests { assert_eq!( data, json!({ - "chunk_id": "chunk-1", "wall_time_seconds": 1.25, "output": "done", "session_id": 42, "exit_code": null, - "original_output_chars": 4, "completion": { "status": "interrupted", "source": "out_of_band_control" @@ -1569,6 +1563,10 @@ mod tests { control_data.get("action").and_then(Value::as_str), Some("kill") ); + assert!(stdin_data.get("chunk_id").is_none()); + assert!(stdin_data.get("original_output_chars").is_none()); + assert!(control_data.get("chunk_id").is_none()); + assert!(control_data.get("original_output_chars").is_none()); assert!(control_data.get("completion").is_none()); } diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index 76debd7861..b3e73a73cf 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -467,6 +467,17 @@ script-tool-runtime = [ "tokio/time", "which", ] +ptc-runtime = [ + "async-trait", + "openbitfun-runtime-ports/ptc-runtime", + "process-tree", + "tokio/io-util", + "tokio/process", + "tokio/rt", + "tokio/sync", + "tokio/time", + "which", +] web-tools = [ "async-trait", "openbitfun-runtime-ports/web-search-port", diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 6be12173d2..83bc5c9d5b 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -101,6 +101,9 @@ pub(crate) mod review_platform_http; #[cfg(feature = "script-tool-runtime")] pub mod script_tool; + +#[cfg(feature = "ptc-runtime")] +pub mod ptc_runtime; #[cfg(feature = "speech")] pub mod speech; diff --git a/src/crates/services/services-integrations/src/ptc_runtime.rs b/src/crates/services/services-integrations/src/ptc_runtime.rs new file mode 100644 index 0000000000..059f5ea76a --- /dev/null +++ b/src/crates/services/services-integrations/src/ptc_runtime.rs @@ -0,0 +1,770 @@ +//! Node runtime for PTC programs on local or remote workspaces. +//! +//! The child process is a failure-containment boundary, not a security +//! sandbox. The bootstrap exposes only the `tools` binding and a small set of +//! language primitives; host-side policy still owns nested tool admission. + +#[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] +use crate::remote_ssh::{shell_quote_posix, SSHCommandOptions, SSHConnectionManager}; +use async_trait::async_trait; +use openbitfun_runtime_ports::{ + PortError, PortErrorKind, PortResult, PtcExecutionRequest, PtcExecutionResponse, + PtcNestedToolCall, PtcRuntime, PtcRuntimeAvailability, PtcToolCallHandler, PtcToolSpecRequest, + RuntimeServiceCapability, +}; +use openbitfun_services_core::process_tree::ProcessTreeChild; +use serde::Deserialize; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::sync::{Mutex, RwLock}; +use tokio::time::{timeout, Duration}; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120); +const CANCEL_GRACE: Duration = Duration::from_millis(500); +const MAX_FRAME_BYTES: usize = 128 * 1024 * 1024; + +const BOOTSTRAP: &str = r#" +import readline from 'node:readline'; +import { randomUUID } from 'node:crypto'; +import { stripTypeScriptTypes } from 'node:module'; +import vm from 'node:vm'; + +const stringify = JSON.stringify.bind(JSON); +const parse = JSON.parse.bind(JSON); +const writeRaw = process.stdout.write.bind(process.stdout); +const token = randomUUID(); +const pending = new Map(); +let maxOutputBytes = 8 * 1024 * 1024; +let logs = []; + +function write(value) { writeRaw(`${stringify(value)}\n`); } +function log(...values) { + logs.push(values.map((value) => typeof value === 'string' ? value : stringify(value)).join(' ')); +} +function failure(message, code = 'exception') { + const error = new Error(message); + error.ptcCode = code; + throw error; +} +function jsonValue(value) { + if (value === undefined) return null; + const encoded = stringify(value); + if (Buffer.byteLength(encoded, 'utf8') > maxOutputBytes) failure('PTC result exceeds output limit', 'output_limit'); + return parse(encoded); +} +function callTool(name, argumentsValue, allowed, enforceBinding = true) { + if (enforceBinding && !allowed.has(name)) return Promise.reject(new Error(`tool '${name}' is not available in this PTC snapshot`)); + if (!argumentsValue || typeof argumentsValue !== 'object' || Array.isArray(argumentsValue)) { + return Promise.reject(new Error(`arguments for '${name}' must be an object`)); + } + const operationId = randomUUID(); + write({ kind: 'tool_call', token, operationId, toolName: name, arguments: argumentsValue, enforceBinding }); + return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); +} + +function getToolSpec(name) { + if (typeof name !== 'string' || name.length === 0) { + return Promise.reject(new Error('getToolSpec requires a non-empty tool name')); + } + const operationId = randomUUID(); + write({ kind: 'get_tool_spec', token, operationId, toolName: name }); + return new Promise((resolve, reject) => pending.set(operationId, { resolve, reject })); +} + +async function execute(message) { + maxOutputBytes = Number.isInteger(message.maxOutputBytes) && message.maxOutputBytes > 0 + ? message.maxOutputBytes : 8 * 1024 * 1024; + logs = []; + const names = new Set(Array.isArray(message.bindings) ? message.bindings.map((binding) => binding.name) : []); + const tools = Object.create(null); + for (const name of names) tools[name] = (args) => callTool(name, args, names); + tools.getToolSpec = getToolSpec; + tools.callTool = (name, args) => callTool(name, args, names, false); + const consoleFacade = Object.freeze({ + log, info: log, warn: log, error: log, debug: log, + }); + const context = vm.createContext({ + AbortController, AbortSignal, Buffer, URL, URLSearchParams, + clearImmediate, clearInterval, clearTimeout, console: consoleFacade, + queueMicrotask, setImmediate, setInterval, setTimeout, structuredClone, + }); + const wrappedSource = `(async (tools) => {\n${message.source}\n})`; + const source = stripTypeScriptTypes(wrappedSource, { mode: 'strip' }); + const program = new vm.Script(source, { + filename: 'openbitfun-ptc.ts', + }).runInContext(context); + const value = await program(Object.freeze(tools)); + const result = jsonValue(value); + const logBytes = Buffer.byteLength(stringify(logs), 'utf8'); + if (logBytes > maxOutputBytes) failure('PTC logs exceed output limit', 'output_limit'); + return { value: result, logs }; +} + +write({ kind: 'ready', token }); +const input = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); +input.on('line', (line) => { + let message; + try { message = parse(line); } catch { process.exit(1); return; } + if (message.type === 'tool_result') { + const call = pending.get(message.operationId); + if (!call) return; + pending.delete(message.operationId); + if (message.ok) call.resolve(message.value); + else call.reject(new Error(message.error || 'nested tool call failed')); + return; + } + if (message.type !== 'execute') return; + void execute(message).then( + (result) => write({ kind: 'complete', token, id: message.id, ok: true, result }), + (error) => write({ + kind: 'complete', token, id: message.id, ok: false, + errorKind: error?.ptcCode || 'exception', + error: error instanceof Error ? error.message.slice(0, 4096) : 'PTC execution failed', + }), + ); +}); +"#; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Frame { + kind: String, + token: String, + #[serde(default)] + id: Option, + #[serde(default)] + operation_id: Option, + #[serde(default)] + tool_name: Option, + #[serde(default)] + arguments: Value, + #[serde(default)] + ok: bool, + #[serde(default)] + result: Option, + #[serde(default)] + error_kind: Option, + #[serde(default)] + error: Option, + #[serde(default = "default_true")] + enforce_binding: bool, +} + +fn default_true() -> bool { + true +} + +type PtcReader = Pin>; +type PtcWriter = Pin>; + +enum PtcProcessControl { + Local(Mutex), + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + Remote(crate::remote_ssh::WorkspaceProcessControl), +} + +struct PtcProcess { + stdin: Mutex, + control: PtcProcessControl, +} + +impl PtcProcess { + async fn terminate(&self) { + match &self.control { + PtcProcessControl::Local(child) => { + let _ = child.lock().await.terminate(CANCEL_GRACE).await; + } + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + PtcProcessControl::Remote(control) => { + let _ = control.kill().await; + } + } + } + + async fn send(&self, value: Value) -> PortResult<()> { + let encoded = serde_json::to_vec(&value).map_err(|error| { + PortError::new( + PortErrorKind::InvalidRequest, + format!("invalid PTC frame: {error}"), + ) + })?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC control frame exceeds the protocol limit", + )); + } + let mut stdin = self.stdin.lock().await; + stdin.write_all(&encoded).await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + })?; + stdin.write_all(b"\n").await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + })?; + stdin.flush().await.map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("PTC stdin failed: {error}"), + ) + }) + } +} + +pub struct NodePtcRuntime { + executable: RwLock>, + operations: Mutex>>, + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + remote_target: Option, +} + +#[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] +#[derive(Clone)] +struct RemotePtcTarget { + ssh_manager: SSHConnectionManager, + connection_id: String, +} + +impl Default for NodePtcRuntime { + fn default() -> Self { + Self::discover() + } +} + +impl NodePtcRuntime { + pub fn discover() -> Self { + Self { + executable: RwLock::new(which::which("node").ok()), + operations: Mutex::new(HashMap::new()), + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + remote_target: None, + } + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + pub fn for_remote(ssh_manager: SSHConnectionManager, connection_id: impl Into) -> Self { + Self { + executable: RwLock::new(None), + operations: Mutex::new(HashMap::new()), + remote_target: Some(RemotePtcTarget { + ssh_manager, + connection_id: connection_id.into(), + }), + } + } + + async fn executable(&self) -> Option { + if let Some(path) = self.executable.read().await.clone() { + return Some(path); + } + let path = which::which("node").ok()?; + *self.executable.write().await = Some(path.clone()); + Some(path) + } + + async fn spawn_local( + &self, + executable: &PathBuf, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + let mut command = + openbitfun_services_core::process_manager::create_tokio_command(executable); + command + .arg("--input-type=module") + .arg("--eval") + .arg(BOOTSTRAP) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_remove("NODE_OPTIONS") + .env_remove("NODE_PATH"); + let mut child = ProcessTreeChild::spawn(&mut command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start Node PTC runtime: {error}"), + ) + })?; + let stdin = child + .take_stdin() + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC stdin unavailable"))?; + let stdout = child + .take_stdout() + .ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC stdout unavailable"))?; + if let Some(mut stderr) = child.take_stderr() { + tokio::spawn(async move { + let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await; + }); + } + let process = Arc::new(PtcProcess { + stdin: Mutex::new(Box::pin(stdin)), + control: PtcProcessControl::Local(Mutex::new(child)), + }); + Ok((process, BufReader::new(Box::pin(stdout)))) + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + async fn spawn_remote( + &self, + target: &RemotePtcTarget, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + let command = format!( + "unset NODE_OPTIONS NODE_PATH; cd -- {} && exec node --input-type=module --eval {}", + shell_quote_posix(cwd), + shell_quote_posix(BOOTSTRAP), + ); + let transport = target + .ssh_manager + .open_workspace_stdio(&target.connection_id, &command) + .await + .map_err(|error| { + PortError::new( + PortErrorKind::NotAvailable, + format!("failed to start remote Node PTC runtime: {error}"), + ) + })?; + let (stdin, stdout, mut stderr, control, completion) = transport.into_parts(); + tokio::spawn(async move { + let _ = tokio::io::copy(&mut stderr, &mut tokio::io::sink()).await; + let _ = completion.wait().await; + }); + let process = Arc::new(PtcProcess { + stdin: Mutex::new(stdin), + control: PtcProcessControl::Remote(control), + }); + Ok((process, BufReader::new(stdout))) + } + + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + async fn remote_node_version(target: &RemotePtcTarget) -> Result { + let result = target + .ssh_manager + .execute_command_with_options( + &target.connection_id, + "node --version", + SSHCommandOptions { + timeout_ms: Some(10_000), + cancellation_token: None, + }, + ) + .await + .map_err(|error| error.to_string())?; + if result.exit_code != 0 { + return Err(if result.stderr.trim().is_empty() { + "remote Node.js version probe failed".to_string() + } else { + result.stderr.trim().to_string() + }); + } + let version = result.stdout.trim().to_string(); + if version.is_empty() { + Err("remote Node.js version probe returned no version".to_string()) + } else { + Ok(version) + } + } + + async fn runtime_version(&self) -> PortResult<(Option, String)> { + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + if let Some(target) = &self.remote_target { + let version = Self::remote_node_version(target).await.map_err(|reason| { + PortError::new( + PortErrorKind::NotAvailable, + format!("remote Node.js PTC runtime is unavailable: {reason}"), + ) + })?; + return Ok((None, version)); + } + + let executable = self.executable().await.ok_or_else(|| { + PortError::new(PortErrorKind::NotAvailable, "Node.js is not available") + })?; + let version = node_version(&executable).await.map_err(|reason| { + PortError::new( + PortErrorKind::NotAvailable, + format!("Node.js PTC runtime is unavailable: {reason}"), + ) + })?; + Ok((Some(executable), version)) + } + + async fn spawn_process( + &self, + executable: Option<&PathBuf>, + cwd: &str, + ) -> PortResult<(Arc, BufReader)> { + #[cfg(all(feature = "remote-ssh", feature = "remote-ssh-concrete"))] + if let Some(target) = &self.remote_target { + return self.spawn_remote(target, cwd).await; + } + + self.spawn_local( + executable.expect("local PTC runtime should have an executable"), + cwd, + ) + .await + } + + async fn execute_inner( + &self, + request: PtcExecutionRequest, + handler: Arc, + ) -> PortResult { + if request.operation_id.trim().is_empty() || request.source.trim().is_empty() { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC execution request is incomplete", + )); + } + let (executable, version) = self.runtime_version().await?; + if !supports_type_stripping(&version) { + return Err(PortError::new( + PortErrorKind::NotAvailable, + format!( + "Node.js {version} does not provide stripTypeScriptTypes (requires Node.js 22.6 or newer)" + ), + )); + } + let (process, mut reader) = self + .spawn_process(executable.as_ref(), &request.working_directory) + .await?; + self.operations + .lock() + .await + .insert(request.operation_id.clone(), process.clone()); + let result = async { + let ready = timeout(STARTUP_TIMEOUT, read_frame(&mut reader)) + .await + .map_err(|_| PortError::new(PortErrorKind::Timeout, "PTC runtime startup timed out"))??; + if ready.kind != "ready" || ready.token.len() < 16 || ready.token.len() > 128 { + return Err(PortError::new(PortErrorKind::Backend, "invalid PTC startup frame")); + } + let id = 1_u64; + process.send(serde_json::json!({ + "type": "execute", "id": id, "source": request.source, + "bindings": request.bindings, "maxOutputBytes": request.max_output_bytes, + })).await?; + let allowed = request.bindings.iter().map(|binding| binding.name.clone()).collect::>(); + loop { + let frame = read_frame(&mut reader).await?; + if frame.token != ready.token { continue; } + if frame.kind == "tool_call" { + let operation_id = frame.operation_id.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool call has no operation id"))?; + let tool_name = frame.tool_name.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool call has no tool name"))?; + if frame.enforce_binding && !allowed.contains(&tool_name) { + process.send(serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":"tool is not in the PTC snapshot"})).await?; + continue; + } + let child_process = process.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let result = handler.call(PtcNestedToolCall { operation_id: operation_id.clone(), tool_name, arguments: frame.arguments }).await; + let payload = match result { + Ok(value) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":true,"value":value}), + Err(error) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":error.message,"errorKind":error.kind}), + }; + let _ = child_process.send(payload).await; + }); + continue; + } + if frame.kind == "get_tool_spec" { + let operation_id = frame.operation_id.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool spec request has no operation id"))?; + let tool_name = frame.tool_name.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC tool spec request has no tool name"))?; + let child_process = process.clone(); + let handler = handler.clone(); + tokio::spawn(async move { + let result = handler + .get_tool_spec(PtcToolSpecRequest { operation_id: operation_id.clone(), tool_name }) + .await; + let payload = match result { + Ok(value) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":true,"value":value}), + Err(error) => serde_json::json!({"type":"tool_result","operationId":operation_id,"ok":false,"error":error.message,"errorKind":error.kind}), + }; + let _ = child_process.send(payload).await; + }); + continue; + } + if frame.kind == "complete" && frame.id == Some(id) { + if frame.ok { + return frame.result.ok_or_else(|| PortError::new(PortErrorKind::Backend, "PTC completion has no result")); + } + let kind = match frame.error_kind.as_deref() { + Some("output_limit") => PortErrorKind::InvalidRequest, + Some("abort") => PortErrorKind::Cancelled, + _ => PortErrorKind::Backend, + }; + return Err(PortError::new(kind, frame.error.unwrap_or_else(|| "PTC program failed".to_string()))); + } + } + }.await; + self.operations.lock().await.remove(&request.operation_id); + process.terminate().await; + result + } +} + +async fn read_frame(reader: &mut BufReader) -> PortResult +where + R: AsyncRead + Unpin, +{ + let mut bytes = Vec::new(); + loop { + let available = reader + .fill_buf() + .await + .map_err(|error| PortError::new(PortErrorKind::NotAvailable, error.to_string()))?; + if available.is_empty() { + return Err(PortError::new( + PortErrorKind::NotAvailable, + "PTC runtime exited", + )); + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let take = newline.unwrap_or(available.len()); + if bytes.len().saturating_add(take) > MAX_FRAME_BYTES { + return Err(PortError::new( + PortErrorKind::InvalidRequest, + "PTC frame exceeds the protocol limit", + )); + } + bytes.extend_from_slice(&available[..take]); + reader.consume(take + usize::from(newline.is_some())); + if newline.is_some() { + break; + } + } + serde_json::from_slice(&bytes).map_err(|error| { + PortError::new( + PortErrorKind::Backend, + format!("invalid PTC frame: {error}"), + ) + }) +} + +async fn node_version(executable: &PathBuf) -> Result { + let output = openbitfun_services_core::process_manager::create_tokio_command(executable) + .arg("--version") + .output() + .await + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("Node.js version probe failed".to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn supports_type_stripping(version: &str) -> bool { + let version = version.trim().trim_start_matches('v'); + let mut parts = version + .split('.') + .filter_map(|part| part.parse::().ok()); + let Some(major) = parts.next() else { + return false; + }; + let minor = parts.next().unwrap_or(0); + major > 22 || (major == 22 && minor >= 6) +} + +#[async_trait] +impl PtcRuntime for NodePtcRuntime { + fn capability(&self) -> RuntimeServiceCapability { + RuntimeServiceCapability::PtcRuntime + } + + async fn availability(&self) -> PtcRuntimeAvailability { + match self.runtime_version().await { + Ok((executable, version)) if supports_type_stripping(&version) => { + PtcRuntimeAvailability::Available { + executable: executable + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|| "node (remote workspace)".to_string()), + version, + } + } + Ok((_, version)) => PtcRuntimeAvailability::Unavailable { + reason: format!( + "Node.js {version} does not provide stripTypeScriptTypes (requires Node.js 22.6 or newer)" + ), + }, + Err(error) => PtcRuntimeAvailability::Unavailable { + reason: error.message, + }, + } + } + + async fn execute( + &self, + request: PtcExecutionRequest, + handler: Arc, + ) -> PortResult { + let deadline = Duration::from_millis( + request + .timeout_ms + .unwrap_or(DEFAULT_TIMEOUT.as_millis() as u64) + .max(1), + ); + match timeout(deadline, self.execute_inner(request.clone(), handler)).await { + Ok(result) => result, + Err(_) => { + let _ = self.cancel(&request.operation_id).await; + Err(PortError::new( + PortErrorKind::Timeout, + "PTC program timed out", + )) + } + } + } + + async fn cancel(&self, operation_id: &str) -> PortResult<()> { + let process = self + .operations + .lock() + .await + .remove(operation_id) + .ok_or_else(|| { + PortError::new(PortErrorKind::NotFound, "PTC operation is not running") + })?; + process.terminate().await; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::{supports_type_stripping, NodePtcRuntime}; + use async_trait::async_trait; + use openbitfun_runtime_ports::{ + PtcExecutionRequest, PtcNestedToolCall, PtcRuntime, PtcToolBinding, PtcToolCallHandler, + PtcToolSpecRequest, + }; + use serde_json::{json, Value}; + use std::sync::Arc; + + #[test] + fn type_stripping_requires_node_22_6_or_newer() { + assert!(!supports_type_stripping("v22.5.1")); + assert!(supports_type_stripping("v22.6.0")); + assert!(supports_type_stripping("v23.0.0")); + assert!(!supports_type_stripping("not-a-version")); + } + + struct Handler; + #[async_trait] + impl PtcToolCallHandler for Handler { + async fn call( + &self, + request: PtcNestedToolCall, + ) -> openbitfun_runtime_ports::PortResult { + Ok(json!({"tool": request.tool_name, "arguments": request.arguments})) + } + + async fn get_tool_spec( + &self, + request: PtcToolSpecRequest, + ) -> openbitfun_runtime_ports::PortResult { + Ok(json!({ + "tool_name": request.tool_name, + "catalog_generation": 7, + "input_schema": {"type": "object"}, + "output_schema": {"type": "object"}, + })) + } + } + + #[tokio::test] + async fn executes_program_and_dispatches_nested_tool_call_when_node_is_available() { + let runtime = NodePtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-test".to_string(), + source: "const result = await tools.Read({ path: 'src/lib.rs' }); return result;" + .to_string(), + bindings: vec![PtcToolBinding { + name: "Read".to_string(), + }], + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["tool"], "Read"); + } + + #[tokio::test] + async fn exposes_ptc_spec_loading_and_generic_call_api() { + let runtime = NodePtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-spec-test".to_string(), + source: "const spec = await tools.getToolSpec('Deferred'); const result = await tools.callTool('Deferred', { value: 1 }); return { spec, result };".to_string(), + bindings: Vec::new(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(30_000), + max_output_bytes: Some(1024 * 1024), + }; + let response = runtime.execute(request, Arc::new(Handler)).await.unwrap(); + assert_eq!(response.value["spec"]["tool_name"], "Deferred"); + assert_eq!(response.value["result"]["tool"], "Deferred"); + } + + #[tokio::test] + async fn timeout_terminates_a_long_running_program() { + let runtime = NodePtcRuntime::discover(); + if !matches!( + runtime.availability().await, + openbitfun_runtime_ports::PtcRuntimeAvailability::Available { .. } + ) { + return; + } + let request = PtcExecutionRequest { + operation_id: "ptc-timeout-test".to_string(), + source: "await new Promise((resolve) => setTimeout(resolve, 5000)); return 1;" + .to_string(), + bindings: Vec::new(), + working_directory: std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(), + session_id: None, + timeout_ms: Some(100), + max_output_bytes: Some(1024 * 1024), + }; + let error = runtime + .execute(request, Arc::new(Handler)) + .await + .expect_err("long-running PTC program should time out"); + assert_eq!(error.kind, openbitfun_runtime_ports::PortErrorKind::Timeout); + } +} diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 19c32b044a..0945556e76 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -7745,13 +7745,15 @@ "categoryId": "assistant", "titleZh": "执行与权限控制", "titleEn": "Execution & permissions", - "summaryZh": "管理智能体工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。", - "summaryEn": "Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control.", + "summaryZh": "管理智能体工具权限、呈现模式、并行度、超时、延迟加载、Computer Use 与浏览器控制。", + "summaryEn": "Manage agent tool permissions, presentation mode, concurrency, timeouts, deferred loading, Computer Use, and browser control.", "keywordsZh": [ "执行控制", "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -7763,6 +7765,8 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", + "PTC", "approval", "parallel", "timeout", @@ -7888,6 +7892,28 @@ "source:src/web-ui/src/locales/zh-CN/settings/runtime.json#deferredToolLoading.sectionTitle" ] }, + { + "id": "tool-presentation", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", + "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "destination": { + "kind": "settings", + "pageId": "tools.execution", + "viewId": "advanced" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "tool-presentation-mode" + } + ] + }, + "evidence": [ + "source:src/web-ui/src/locales/zh-CN/settings/runtime.json#toolPresentation.mode" + ] + }, { "id": "parallelism", "titleZh": "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", @@ -8086,6 +8112,13 @@ "descriptionZh": "按需加载大型工具定义,减少智能体初始上下文。", "descriptionEn": "Load large tool definitions on demand to reduce initial agent context." }, + { + "id": "tool-presentation-mode", + "titleZh": "工具呈现模式", + "titleEn": "Tool presentation mode", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript." + }, { "id": "subagent-batch-policy", "titleZh": "子智能体批量执行策略", diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 99af228476..5fde780727 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,8 +4,8 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "da8a1c7e721910916da62280a534391c89f6457098aadf9200e85e797145a12a", - "ownerDigest": "103fc8646df7b35e9fac1c52149a35b86e9c6115b462b0aa36240a4600903ce4", + "digest": "65f1a11c6d994c382da25f2b9ad995ad0f443a5d352c2eafa10a8b9bcbf849e5", + "ownerDigest": "4e1ace270088acecbd333d2c8aced0547dddb0fd6bae778bec24f2b0fb48ea91", "searchAcceptance": [ { "id": "companion-pet-mixed", @@ -147,9 +147,9 @@ "features": 23, "settings": 21, "userFacing": 44, - "documentedItems": 332, + "documentedItems": 333, "controlCoverage": { - "direct": 51, + "direct": 52, "delegated": 65, "interactive": 216, "unsupported": 0 @@ -16039,6 +16039,7 @@ "global-project-rules", "remembered-grants", "deferred-tools", + "tool-presentation", "parallelism", "timeouts", "review-capacity", @@ -16175,6 +16176,54 @@ "pageId": "tools.execution" } }, + { + "id": "setting.tools.execution:option:tool-presentation-mode", + "capabilityId": "setting.tools.execution", + "itemIds": [ + "tool-presentation" + ], + "kind": "option", + "risk": "write", + "executionHost": "productHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "valueSource": { + "kind": "static" + }, + "presentationTarget": { + "kind": "settings", + "pageId": "tools.execution" + } + }, { "id": "setting.tools.execution:option:subagent-batch-policy", "capabilityId": "setting.tools.execution", @@ -28523,13 +28572,15 @@ "categoryId": "assistant", "titleZh": "执行与权限控制", "titleEn": "Execution & permissions", - "summaryZh": "管理智能体工具权限、并行度、超时、延迟加载、Computer Use 与浏览器控制。", - "summaryEn": "Manage agent tool permissions, concurrency, timeouts, deferred loading, Computer Use, and browser control.", + "summaryZh": "管理智能体工具权限、呈现模式、并行度、超时、延迟加载、Computer Use 与浏览器控制。", + "summaryEn": "Manage agent tool permissions, presentation mode, concurrency, timeouts, deferred loading, Computer Use, and browser control.", "keywordsZh": [ "执行控制", "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -28541,6 +28592,8 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", + "PTC", "approval", "parallel", "timeout", @@ -28645,6 +28698,25 @@ ] } }, + { + "id": "tool-presentation", + "titleZh": "选择模型使用原生模式还是 PTC TypeScript 模式", + "titleEn": "Choose native mode or PTC TypeScript mode for the model", + "destination": { + "kind": "settings", + "pageId": "tools.execution", + "viewId": "advanced" + }, + "control": { + "kind": "direct", + "operations": [], + "options": [ + { + "id": "tool-presentation-mode" + } + ] + } + }, { "id": "parallelism", "titleZh": "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", @@ -28821,6 +28893,20 @@ "type": "boolean" } }, + { + "id": "tool-presentation-mode", + "titleZh": "工具呈现模式", + "titleEn": "Tool presentation mode", + "descriptionZh": "选择模型直接调用原生工具,或通过 PTC TypeScript 调用工具。", + "descriptionEn": "Choose whether the model calls native tools directly or uses PTC TypeScript.", + "valueSchema": { + "type": "string", + "enum": [ + "native", + "ptc" + ] + } + }, { "id": "subagent-batch-policy", "titleZh": "子智能体批量执行策略", @@ -28914,6 +29000,8 @@ "工具权限", "工具调用超时", "延迟加载工具", + "工具呈现模式", + "PTC", "审批", "并行", "超时", @@ -28923,6 +29011,7 @@ "execution control", "tool permissions", "deferred tool loading", + "tool presentation", "approval", "parallel", "timeout", @@ -28945,6 +29034,8 @@ "Review, revoke, or clear remembered project permission grants and audit records", "按需延迟加载工具定义以减少智能体初始上下文", "Deferred tool loading on demand to reduce initial agent context", + "选择模型使用原生模式还是 PTC TypeScript 模式", + "Choose native mode or PTC TypeScript mode for the model", "设置子智能体与 Swarm 最大并发及安全、强制并行或串行策略", "Set subagent and swarm concurrency plus safe-only, forced-parallel, or serial policy", "设置或取消单次工具调用超时", diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx index bf2663a29c..7b88a0912f 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx @@ -41,6 +41,7 @@ type DescribedSelectOption = SelectOption & { description?: string }; const log = createLogger('ExecutionSettingsPage'); type SubagentBatchExecutionPolicy = 'safe_only' | 'force_parallel' | 'serial'; +type ToolPresentationMode = 'native' | 'ptc'; const DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY: SubagentBatchExecutionPolicy = 'force_parallel'; @@ -67,7 +68,7 @@ const ExecutionSettingsPage: React.FC = () => { const { buildModelOption } = useModelSelectPresentation(); - const seed = useConfigSeed([modelSelectionsConfigPath(), 'ai.models', 'ai.enable_deferred_tool_loading', 'ai.subagent_max_concurrency', 'ai.swarm_max_concurrency', 'ai.tool_execution_timeout_secs', 'ai.subagent_batch_execution_policy', 'ai.user_question_timeout_secs']); + const seed = useConfigSeed([modelSelectionsConfigPath(), 'ai.models', 'ai.enable_deferred_tool_loading', 'ai.subagent_max_concurrency', 'ai.swarm_max_concurrency', 'ai.tool_execution_timeout_secs', 'ai.subagent_batch_execution_policy', 'ai.user_question_timeout_secs', 'ai.tool_presentation_mode']); let seededSelections: AgentModelSelections | undefined; if (seed.loaded) { try { seededSelections = effectiveModelSelections(seed.get(modelSelectionsConfigPath(), undefined)); } @@ -105,6 +106,12 @@ const ExecutionSettingsPage: React.FC = () => { const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); + const [toolPresentationMode, setToolPresentationMode] = useState( + seed.get('ai.tool_presentation_mode', 'native') === 'ptc' ? 'ptc' : 'native', + ); + + const [toolPresentationModeSaving, setToolPresentationModeSaving] = useState(false); + const subagentBatchExecutionPolicyOptions: DescribedSelectOption[] = [ { value: 'safe_only', @@ -136,6 +143,26 @@ const ExecutionSettingsPage: React.FC = () => { } }; + const handleToolPresentationModeChange = async (value: string | number | (string | number)[]) => { + const nextMode: ToolPresentationMode = String(Array.isArray(value) ? value[0] : value) === 'ptc' + ? 'ptc' + : 'native'; + if (nextMode === toolPresentationMode) return; + const previousMode = toolPresentationMode; + setToolPresentationMode(nextMode); + setToolPresentationModeSaving(true); + try { + await configManager.setConfig('ai.tool_presentation_mode', nextMode); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save tool presentation mode', error); + setToolPresentationMode(previousMode); + notificationService.error(t('messages.saveFailed')); + } finally { + setToolPresentationModeSaving(false); + } + }; + const handleSubagentBatchExecutionPolicyChange = async (value: string | number | (string | number)[]) => { const nextPolicy = normalizeSubagentBatchExecutionPolicy(Array.isArray(value) ? value[0] : value); const previousPolicy = subagentBatchExecutionPolicy; @@ -285,6 +312,7 @@ const ExecutionSettingsPage: React.FC = () => { execTimeout, loadedSubagentBatchExecutionPolicy, loadedUserQuestionTimeout, + loadedToolPresentationMode, poolDocument, ] = await Promise.all([ readModelSelections(), @@ -295,6 +323,7 @@ const ExecutionSettingsPage: React.FC = () => { configManager.getConfig('ai.tool_execution_timeout_secs'), configManager.getConfig('ai.subagent_batch_execution_policy'), configManager.getOptionalConfig('ai.user_question_timeout_secs'), + configManager.getOptionalConfig('ai.tool_presentation_mode'), supportsModelPools() ? configManager.getOptionalConfig('ai.model_pools') : Promise.resolve(undefined), ]); setSubagentDefaultModel(loadedSubagentDefaultModel.subagents.default); @@ -312,6 +341,7 @@ const ExecutionSettingsPage: React.FC = () => { ? '180' : loadedUserQuestionTimeout === null ? '0' : String(loadedUserQuestionTimeout)); setSubagentBatchExecutionPolicy(normalizeSubagentBatchExecutionPolicy(loadedSubagentBatchExecutionPolicy)); + setToolPresentationMode(loadedToolPresentationMode === 'ptc' ? 'ptc' : 'native'); hasLoadedPageDataRef.current = true; } catch (error) { @@ -373,6 +403,30 @@ const ExecutionSettingsPage: React.FC = () => { + + +
+