From be71c977b01a36de47cf52fc0267fa902a4e0f0f Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 25 Oct 2026 18:39:02 +0800 Subject: [PATCH] feat(tools): Add programmatic tool calling Add a Node.js PTC runtime with generated TypeScript tool bindings, deferred tool discovery, and nested calls through the tool pipeline. Support local and SSH workspace execution, cancellation, and timeouts. Persist each session's tool presentation mode and expose the default in execution settings. Update output schemas, capability catalogs, localizations, and contract tests for the new calling mode. --- docs/interactive-capabilities/README.md | 12 +- .../capabilities.json | 103 ++- .../capabilities/setting.tools.execution.md | 7 +- .../technical/product-control-open-audit.json | 2 +- .../technical/tauri-command-map.json | 2 +- scripts/check-core-boundaries.test.mjs | 8 + .../cargo-dependency-boundaries.mjs | 3 + .../explicit-test-topology.mjs | 5 + .../core-boundaries/rules/feature-rules.mjs | 43 +- .../rules/source/required-rules.mjs | 2 +- scripts/core-boundaries/self-test.mjs | 2 +- src/apps/desktop/src/api/agentic_api.rs | 2 + src/apps/desktop/src/api/miniapp_agent_api.rs | 1 + src/apps/server/src/rpc_dispatcher.rs | 1 + src/crates/assembly/core/AGENTS.md | 8 + src/crates/assembly/core/Cargo.toml | 7 + .../prompt_builder/prompt_builder_impl.rs | 6 + .../core/src/agentic/agents/registry/tests.rs | 1 + .../coordination/background_outcomes.rs | 1 + .../coordination/control_conversation.rs | 1 + .../src/agentic/coordination/coordinator.rs | 66 ++ .../coordination/host_message_queue_tests.rs | 4 + .../src/agentic/coordination/scheduler.rs | 18 + .../assembly/core/src/agentic/core/message.rs | 5 + .../assembly/core/src/agentic/core/mod.rs | 2 +- .../assembly/core/src/agentic/core/session.rs | 2 +- .../agentic/execution/compression_tests.rs | 2 + .../src/agentic/execution/execution_engine.rs | 33 + .../src/agentic/execution/round_executor.rs | 23 +- .../core/src/agentic/fork_agent/mod.rs | 1 + .../core/src/agentic/persistence/manager.rs | 24 + .../src/agentic/session/session_manager.rs | 153 +++- .../src/agentic/session/session_store_port.rs | 1 + .../core/src/agentic/skill_agent_snapshot.rs | 12 + .../core/src/agentic/tools/framework.rs | 16 + .../implementations/agent_control_tool.rs | 14 + .../tools/implementations/agent_list_tool.rs | 31 +- .../tools/implementations/agent_wait_tool.rs | 30 + .../implementations/analyze_image_tool.rs | 9 + .../appearance_publish_tool.rs | 16 + .../implementations/ask_user_question_tool.rs | 37 + .../tools/implementations/canvas_tools.rs | 34 + .../tools/implementations/code_review_tool.rs | 10 + .../implementations/computer_use_tool.rs | 24 + .../tools/implementations/control_hub_tool.rs | 24 + .../tools/implementations/cron_tool.rs | 36 + .../tools/implementations/delete_file_tool.rs | 13 + .../implementations/desktop_card_tool.rs | 17 + .../implementations/exec_command/command.rs | 23 + .../implementations/exec_command/control.rs | 14 + .../implementations/exec_command/stdin.rs | 15 + .../tools/implementations/file_edit_tool.rs | 13 + .../tools/implementations/file_read_tool.rs | 157 ++-- .../tools/implementations/file_write_tool.rs | 22 +- .../frontend_workbench_tool.rs | 21 + .../implementations/generative_ui_tool.rs | 16 + .../implementations/get_file_diff_tool.rs | 18 + .../tools/implementations/get_time_tool.rs | 20 +- .../tools/implementations/glob_tool.rs | 47 +- .../tools/implementations/grep_tool.rs | 53 +- .../tools/implementations/list_models_tool.rs | 26 + .../implementations/list_workspaces_tool.rs | 35 + .../agentic/tools/implementations/ls_tool.rs | 27 + .../tools/implementations/mcp_tools.rs | 56 ++ .../implementations/miniapp_finalize_tool.rs | 16 +- .../implementations/miniapp_init_tool.rs | 12 + .../implementations/miniapp_publish_tool.rs | 16 + .../src/agentic/tools/implementations/mod.rs | 4 + .../openbitfun_control_tool.rs | 25 + .../tools/implementations/page_deploy_tool.rs | 15 + .../implementations/page_publish_tool.rs | 16 + .../tools/implementations/playbook_tool.rs | 13 + .../implementations/port_forward_tool.rs | 22 + .../implementations/ptc_run_code_tool.rs | 114 +++ .../implementations/review_platform_tool.rs | 35 + .../implementations/session_control_tool.rs | 45 + .../implementations/session_history_tool.rs | 29 + .../implementations/session_message_tool.rs | 15 + .../tools/implementations/skill_tool.rs | 20 + .../implementations/task/agent_control.rs | 26 + .../task/launch_review_agent.rs | 17 + .../agentic/tools/implementations/task/mod.rs | 14 + .../implementations/thread_goal_tools.rs | 27 + .../tools/implementations/todo_write_tool.rs | 23 + .../tools/implementations/view_image_tool.rs | 14 + .../tools/implementations/web/fetch.rs | 19 +- .../agentic/tools/implementations/web/mod.rs | 6 +- .../tools/implementations/web/search.rs | 27 + .../tools/implementations/worktree_tool.rs | 21 + .../tools/openbitfun_control_config.rs | 26 + .../agentic/tools/pipeline/tool_pipeline.rs | 621 +++++++++++--- .../core/src/agentic/tools/pipeline/types.rs | 4 + .../product_runtime/call_deferred_tool.rs | 13 + .../agentic/tools/product_runtime/catalog.rs | 384 ++++++++- .../product_runtime/get_tool_spec_tool.rs | 14 + .../product_runtime/loaded_spec_state.rs | 129 ++- .../core/src/agentic/tools/tool_adapter.rs | 4 + .../src/agentic/tools/tool_context_runtime.rs | 16 + .../core/src/plugin_host_http_routes_impl.rs | 1 + .../assembly/core/src/product_runtime.rs | 5 + .../assembly/core/src/service/cron/service.rs | 1 + .../core/src/service/snapshot/manager.rs | 4 + .../src/service/workspace/legacy_compat.rs | 4 + .../src/service/worktree/session_binding.rs | 1 + .../core/src/service_agent_runtime.rs | 5 + .../contracts/config-contracts/src/types.rs | 68 +- src/crates/contracts/core-types/src/lib.rs | 2 + .../core-types/src/tool_presentation.rs | 74 ++ .../generated/product-control-catalog.json | 107 ++- .../src/product_control_owner_registry.rs | 6 + src/crates/contracts/runtime-ports/AGENTS.md | 5 + src/crates/contracts/runtime-ports/Cargo.toml | 6 + src/crates/contracts/runtime-ports/src/lib.rs | 9 + .../contracts/runtime-ports/src/ptc_tool.rs | 93 +++ .../tests/ptc_tool_port_contracts.rs | 54 ++ .../execution/agent-runtime/src/prompt.rs | 7 + .../execution/agent-runtime/src/session.rs | 33 +- .../agent-runtime/src/skill_agent_snapshot.rs | 1 + .../prompt_contracts.rs | 19 + .../execution/runtime-services/src/lib.rs | 1 + .../execution/tool-contracts/src/framework.rs | 34 +- .../execution/tool-contracts/src/lib.rs | 9 + .../execution/tool-contracts/src/ptc.rs | 721 ++++++++++++++++ .../tool-contracts/src/tool_snapshot.rs | 3 + .../tool-contracts/tests/tool_contracts.rs | 24 +- .../tool-execution/src/exec_command.rs | 12 +- .../services/services-integrations/Cargo.toml | 11 + .../services/services-integrations/src/lib.rs | 3 + .../services-integrations/src/ptc_runtime.rs | 770 ++++++++++++++++++ .../interactive-capabilities/catalog.json | 37 +- .../generated/interactive-capabilities.json | 103 ++- .../pages/ai/ExecutionSettingsPage.tsx | 56 +- .../RuntimeSettings.presentation.test.ts | 4 + .../scenes/settings/settingsDataPreload.ts | 3 +- .../app/scenes/settings/settingsRegistry.ts | 3 +- .../grouping/activityGrouping.test.ts | 4 +- .../tool-cards/toolCapsuleModel.test.ts | 10 +- .../flow_chat/tool-cards/toolCardMetadata.ts | 15 +- .../api/generated/productControl.ts | 4 +- .../src/infrastructure/config/types/index.ts | 2 + .../locales/en-US/scenes/capabilities.json | 3 +- .../src/locales/en-US/settings/runtime.json | 9 + .../locales/zh-CN/scenes/capabilities.json | 3 +- .../src/locales/zh-CN/settings/runtime.json | 9 + .../locales/zh-TW/scenes/capabilities.json | 3 +- .../src/locales/zh-TW/settings/runtime.json | 9 + 146 files changed, 5297 insertions(+), 312 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/ptc_run_code_tool.rs create mode 100644 src/crates/contracts/core-types/src/tool_presentation.rs create mode 100644 src/crates/contracts/runtime-ports/src/ptc_tool.rs create mode 100644 src/crates/contracts/runtime-ports/tests/ptc_tool_port_contracts.rs create mode 100644 src/crates/execution/tool-contracts/src/ptc.rs create mode 100644 src/crates/services/services-integrations/src/ptc_runtime.rs 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 = () => { + + +
+