Release v1.126.0

This commit is contained in:
coso
2026-08-12 12:28:58 +08:00
parent 4ee8d3577e
commit b48569b83b
142 changed files with 17423 additions and 932 deletions
+5 -1
View File
@@ -99,6 +99,10 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile --config.node-linker=hoisted
- name: Configure sandboxed rusty_v8 artifacts
shell: bash
run: node scripts/lib/rusty-v8-artifacts.mjs --github-env
- name: Run Windows Agent Plugin path contract tests
shell: bash
run: |
@@ -119,7 +123,7 @@ jobs:
node scripts/prepare-sherpa-onnx-runtime.mjs \
--target "x86_64-pc-windows-msvc"
- name: Build Electron Windows test package
- name: Build Electron Windows test package with app-server and code-mode-host sidecars
shell: bash
env:
CARGO_PROFILE_RELEASE_LTO: "off"
+11
View File
@@ -324,6 +324,11 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
@@ -334,6 +339,12 @@ jobs:
shared-key: quality-windows-shell-runtime
cache-on-failure: true
- name: Configure sandboxed rusty_v8 artifacts
run: node scripts/lib/rusty-v8-artifacts.mjs --github-env
- name: Check Windows app-server and code-mode-host sidecars
run: cargo check --manifest-path lime-rs/Cargo.toml -p app-server --bin app-server -p tool-runtime --bin code-mode-host
- name: Test Windows shell runtime fallback
run: cargo test --manifest-path lime-rs/Cargo.toml -p lime-agent --test windows_shell_runtime -- --test-threads=1
+17 -17
View File
@@ -1,38 +1,38 @@
## Lime v1.125.0
## Lime v1.126.0
Simplified Chinese release notes are the primary version.
### New Features
- Aligned the plugin package path with Agent Plugins v1.0.0: root `plugin.json`, direct-child Skills, and root `mcp.json`.
- Added the Codex Apps extension adapter, standard Apps JSON catalog, and typed `app/list`, `app/read`, and `app/installed` flow.
- Added typed App Server JSON-RPC and GUI integration for `command/exec` and `review/start`.
- Added the Desktop Code Mode flow, allowing models to orchestrate frozen tool snapshots through native custom `exec` and `wait` tools with asynchronous cells, notifications, shared storage, waiting, termination, and cancellation.
- Added a standalone `code-mode-host` process that executes JavaScript in sandbox-enabled V8 isolates; App Server only owns the process client and never falls back to in-process V8 in production.
- Added official OpenAI Responses custom-tool lowering and model `tool_mode` plus `custom_tools` capability/readiness gates.
### Fixes
- Fixed Thread/Turn/Item, filesystem, process, background terminal, review, and Agent state projection drift across App Server, Electron, and GUI.
- Fixed MCP Plugin placeholder lowering, path containment, HTTP header filtering, sibling isolation, and persistent `PLUGIN_DATA` behavior.
- Fixed protocol schema, generated client, model capability, and provider lowering drift.
- Fixed provider tool-call name repair, argument coercion, and JSON Schema validation so malformed calls fail closed before handlers while preserving exactly one lifecycle terminal.
- Fixed cross-process correlation for nested Code Mode tools, notifications, cell close, cancellation, and timeouts, preventing late callbacks from leaking into later sampling steps.
- Fixed canonical request, stream, and usage projection drift across OpenAI Responses, Chat Completions, Anthropic, Gemini, Vertex, Azure, and Ollama transports.
### Improvements and Refactoring
- Physically removed the retired Plugin package, worker, manager, renderer runtime, v0 filesystem/process/plugin wires, and detached facades.
- Converged business capabilities on the single `Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection -> GUI` chain.
- Removed retired Plugin Lab/sidebar copy, old technical Plugin standards, and zero-reference governance surfaces; return paths are guarded.
- Converged the Agent session loop on thread-owned resources and registries, unifying actor replacement, interruption, shutdown, and active-cell cleanup.
- Restricted the Code Mode V8 provider to the host-internal owner; dev, Electron assets, and Windows builds now produce `app-server` and `code-mode-host` together.
- Added separate SHA-256 values for both sidecars to the Electron release manifest and made packaged verification enforce binary presence and integrity.
### Testing and Quality
- Passed protocol contracts, Rust related tests, governance scans, docs boundary checks, Agent fixtures, GUI smoke, and macOS Electron Gate B.
- Added Windows runner coverage for environment variables, UNC/extended paths, junction/reparse containment, data persistence, and Squirrel Gate B.
- Windows runner artifacts remain a release gate; macOS and historical Windows evidence are not substituted.
- Added loopback request capture across providers for endpoints, authentication, canonical content, tool definitions, generation lowering, and terminal streams.
- Added regression coverage for the Code Mode protocol/process, V8 runtime, provider lowering, session lifecycle, sidecar assets, and resource integrity.
- A real Electron Gate B now proves distinct Electron, App Server, and `code-mode-host` parent/child processes together with custom-exec resampling, canonical Tool Items, and a visible GUI terminal state.
### Documentation
- Updated architecture, command boundaries, Plugin v3 contract, Codex parity matrix, cleanup ledger, and release workflow documentation.
- Made the root README the English canonical entry while retaining a standalone Chinese page.
- Updated the Code Mode process owner, provider/tool trust boundary, dual-sidecar build path, product-scope matrix, and execution plans.
- Clarified that remote environments, Codex TUI, and surfaces without a Desktop consumer are outside Lime's current product path.
### Other
- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.125.0`.
- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.126.0`.
**Full changes**: `v1.124.0` -> `v1.125.0`
**Full changes**: `v1.125.0` -> `v1.126.0`
+17 -17
View File
@@ -1,36 +1,36 @@
## Lime v1.125.0
## Lime v1.126.0
### 新功能
- 对齐 Agent Plugins v1.0.0 标准包:根 `plugin.json`、直接子目录 Skills 和根 `mcp.json`
- 新增 Codex Apps extension adapter、标准 Apps JSON catalog 与 `app/list``app/read``app/installed` 主链
- 补齐 App Server `command/exec``review/start` 的 typed JSON-RPC、事件投影和 GUI 接入
- 新增 Code Mode 桌面主链:模型可通过原生 custom `exec``wait` 编排冻结的工具快照,并支持异步 cell、通知、共享 store、等待、终止与取消
- 引入独立 `code-mode-host` 进程,在 sandbox-enabled V8 isolate 中执行 JavaScriptApp Server 只持有 process client,不在生产路径回退进程内 V8
- 扩展官方 OpenAI Responses custom tool lowering、模型 `tool_mode``custom_tools` capability/readiness 门禁
### 修复
- 修复 Thread/Turn/Item、文件、进程、后台终端、审核和 Agent 状态在 App Server、Electron 与 GUI 之间的投影漂移
- 修复 MCP Plugin placeholder、路径 containment、HTTP header 过滤、失败隔离和 `PLUGIN_DATA` 持久化行为
- 修复协议 schema、生成客户端、模型能力与 provider lowering 的一致性问题
- 修复 provider tool call 的名称、参数类型与 JSON Schema 校验链,畸形调用在 handler 前 fail closed,并保持唯一工具生命周期终态
- 修复 Code Mode nested tool、notify、cell close、取消与 timeout 的跨进程关联,避免迟到回调串入后续 sampling step
- 修复 OpenAI Responses、Chat Completions、Anthropic、Gemini、Vertex、Azure 与 Ollama transport 的 canonical request/stream/usage 投影漂移
### 优化与重构
- 物理删除旧 Plugin package、worker、manager、renderer runtime、旧 v0 filesystem/process/plugin wire 和 detached facade
-业务能力收敛到 `Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection -> GUI` 单一产品链
- 清理旧 Plugin Lab/sidebar 文案、旧技术标准文档和无引用治理 surface,禁止旧路径回流
- 将 Agent session loop 收敛为 thread-owned resources/registry,统一 actor replace、interrupt、shutdown 与 active cell 清理
- Code Mode V8 provider 限定为 host 内部 ownerdev、Electron assets 与 Windows 构建成组产出 `app-server``code-mode-host`
- Electron release manifest 分别记录双 sidecar SHA-256packaged verifier 强制检查二进制存在性与完整性
### 测试与质量
- 通过协议 contracts、Rust related tests、治理扫描、文档边界、Agent fixture、GUI smoke 和 macOS Electron Gate B
- 增加 Windows runner 的环境变量、UNC/extended path、junction/reparse、数据持久化与 Squirrel Gate B 验证入口
- 发布门禁持续跟踪 Windows runner artifact,未用 macOS 或旧 Windows 证据替代
- 增加多 provider loopback request capture,验证 endpoint、认证、canonical content、工具定义、generation lowering 与 terminal stream
- 增加 Code Mode protocol/process、V8 runtime、provider lowering、session lifecycle、sidecar assets 与资源完整性回归
- 真实 Electron Gate B 已证明 Electron、App Server 与 `code-mode-host` 为独立父子进程,并完成 custom exec 回采样、canonical Tool Item 与 GUI 可见终态
### 文档
- 更新架构、命令边界、Plugin v3 合同、Codex parity matrix、清理账本和发布流程文档
- 根 README 采用英文 canonical 入口,并保留中文独立页面
- 更新 Code Mode process owner、provider/tool trust boundary、双 sidecar 构建链、产品范围矩阵与执行计划
- 明确 remote environment、Codex TUI 与无 Desktop consumer 的 surface 不进入 Lime current 产品链
### 其他
- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.125.0`
- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.126.0`
**完整变更**: `v1.124.0` -> `v1.125.0`
**完整变更**: `v1.125.0` -> `v1.126.0`
+196 -3
View File
@@ -459,6 +459,12 @@ provider capability upper bound 必须委托同一 adapter availability,禁止
白名单而产生 ready/capability 漂移。`namespace_tools`、hosted `image_generation` 与 hosted `web_search`
只有在 canonical schema、request lowering、stream reducer 和 route gate 全部可执行后才能置为 true;普通
function/client tool 支持不能代替这些 provider capability。
每个 current chat transport 都必须由真实 loopback request capture 同时证明 endpoint、认证头、canonical
system/user/media/tool call/tool result/tool definition/generation lowering 与协议原生 terminal stream,不能用纯
lowering 单测或 reducer fixture 冒充发网证据。OpenAI Chat Completions、OpenAI Responses HTTP、Anthropic
Messages、Gemini GenerateContent、Vertex Gemini、Azure OpenAI Responses 与 Ollama Responses 已具备这套证据;
Responses WebSocket 另有 handshake、`response.create` 与 HTTP replay capture。Anthropic 只保留服务端实际返回的
input/output usage,不合成未提供的 `total_tokens`
Gemini GenerateContent、Vertex Gemini、Azure OpenAI Responses 与 Ollama Responses 已是 current adapter。Azure 使用 resource
root、`/openai``/openai/v1` 或完整 `/openai/v1/responses` endpoint,认证仅允许 `api-key`typed
`api-version` 缺省为 `v1`deployment URL、Bearer/NoAuth、Chat Completions、WebSocket 与 hosted tools 均 fail closed。
@@ -611,6 +617,28 @@ current provider 不得绕过 `RuntimeTool::execute_call` 直接调用 executor
current provider 的工具面按 model sampling step 冻结,不按整个用户 Turn 永久冻结。每次发 provider request 前必须生成一个 `RuntimeToolStepSnapshot`,同一 snapshot 同时拥有 model-visible definitions 与 exact executor;本 step 返回的 tool call 只能调用该 definitions allowlist 中的名称,未广告名称仍产生 canonical failed lifecycle,但不得进入真实 native/gateway/MCP executor。MCP snapshot 的唯一 owner 是 `tool-runtime::mcp_connection`:它按 server 隔离 discovery error/timeout,并把 prefixed definition、per-tool caller policy、dispatch route 与 immutable connection handle 一起冻结;同一步不得回查 live registry。`tool_search` 只更新本 Turn 的 deferred selection,旧 snapshot 不变,下一 sampling step 才可重新 capture。MCP bridge 的已归一化 tool timeout 固化在 connection client 中,因此 registry replace 后旧 step 继续使用旧 handle/timeout,新 step 才看到新配置。
provider tool-call repair 也只能消费该 sampling step snapshot。`model-provider` 必须把 wire 中的
`raw_arguments` 与解析后的 input 一起投影到 canonical `LlmEvent::ToolCall`malformed JSON、空名称和
scalar arguments 不能提前升级为整步 provider error。`agent-runtime` 随后调用 `tool-runtime::repair_tool_call`
只允许按本 step definitions、ASCII case 和 current native alias 得到 canonical name,并记录参数 diff。
参数 normalization 后必须继续使用同一 `RuntimeToolDefinition.input_schema`:只允许把 schema 明确声明为
`integer`/`number` 的合法 JSON 数值字符串确定性转为 number,不能按字段名、描述或自然语言猜测;随后用完整
JSON Schema 校验 canonical arguments。schema 编译失败或参数仍不匹配都必须 fail closed,不能进入 handler。
repair success 在写入 assistant transcript 前替换为 canonical name/argumentsrepair failure 统一变为
model-visible `invalid` call,由专用 `before_handler` executor 产生 exactly-one started/completed lifecycle 和
failed tool result。即使 snapshot 中存在同名动态工具,带 typed repair failure metadata 的 `invalid` call 也
不得进入真实 handler。原始 malformed payload 只保留在 typed repair metadata,不复制到 model-visible error
argumentsschema 错误必须 mask 实例值,不能把 provider 参数原文带回模型。Architecture impact: major;本段改变
canonical provider event 与 tool execution trust boundary,但不改变
`model-provider -> agent-runtime -> tool-runtime -> Thread/Turn/Item` owner 方向。架构确认:confirmed;责任开发者
root2026-08-11。
repair 后的 cancellation/timeout 继续复用同一 canonical terminal,不建立 provider-loop 旁路终态。handler 已启动时
turn cancellation 必须先投影唯一 `aborted` completed lifecycle;即使 handler 自有后台工作随后返回 success,也不得
覆盖该终态、追加第二条 completed 或触发下一次 provider sampling。repair 工具成功完成后,后续 provider sampling
step 的 absolute timeout 也不得重放 handler 或 lifecyclecanonical call/result transcript 只供该次 request 使用。
这两条组合竞态由 `agent-runtime::provider_turn` 跨 owner 回归直接守住。
MCP resource、resource template、prompt 与 server status 属于 App Server 管理控制面,不是 model sampling-step inventory。GUI `mcpPrompt/*``mcpResource/*``mcpServerStatus/list` 每次通过 `LocalAppDataSource` 的全局 `lime-mcp::McpClientManager` 对当前 live connection 执行 typed read;它们不得进入 `McpStepSnapshot`、不得通过 caller-unaware registry dispatch 执行,也不得回写或替换 in-flight Tool snapshot。连接初始化返回的 server capabilities 只用于 manager status、tool filtering 与 bridge 装配事实;model bridge 只携带 tool discovery/call/notification 所需能力。MCP client initialize 只能广告已有 typed handler 的 client capabilityLime 没有 `sampling/createMessage` owner,必须与 Codex 一样保持 sampling absent,禁止先广告再由 rmcp 默认返回 method not found。Agent runtime 的唯一 owner 是 `AgentRuntimeState[sessionId] -> McpThreadRuntime`:创建时固定 canonical `threadId`,独立持有 runtime `McpClientManager`、真实 RMCP connection、bridge registry 与 immutable generationruntime 只从管理面提供的 typed enabled server spec 创建连接,绝不复用管理面 `RunningService`。每个 enabled server 并发启动:`required=false` 的失败只使该 server 在候选 generation 中 absent,健康 server 的 bridge 仍可发布;任一 `required=true` 失败则关闭未发布候选的连接并拒绝替换,已发布 generation 与其 pending elicitation 不受影响。配置变化时只在候选 generation 完成启动策略和 snapshot 后原子发布,旧 sampling step 继续通过 `Arc` 持有原 connection handle;删除 session 才按精确 `(sessionId, threadId)` 关闭已发布 runtime,取消 turn 不关闭它。server-originated elicitation 独占 `mcpServer/elicitation/request` reverse JSON-RPC method,不得复用 `agentSession/action/respond`、Approval 或 `request_user_input`。它是 thread-scoped、turn-correlated 的瞬时 reverse requestApp Server 只保留 exact in-memory waiter`thread/read`、Thread/Turn/Item projection 和 durable store 不得写入 pending 或 terminal elicitation。公开 request contract 只有必填非空 `threadId`、可空 `turnId`、必填非空 `serverName` 与 typed `mode: "form"``sessionId``parentToolCallId`、raw MCP request id 和私有 token 均禁止进入 wire。per-call `McpCallScope` 只保留可空 `turnId` correlationconnection 已在 runtime 创建期绑定 session/thread owner,因此每次工具调用不得重传、推断或覆盖 owner。管理面 nested elicitation 因没有 runtime owner 必须在 MCP service 边界 fail closed;不得使用 singleton、最近 active turn、`sessionId` fallback、`parentToolCallId`、progress token 或 server metadata 猜测 owner。router 以 session owner 精确取消:未转发 waiter 直接 Cancel,已转发 waiter 只触发 closed,必须等待 App Server adapter 先发送 `serverRequest/resolved` 再释放 RMCP waiter;同一 server 的不同 session/thread 不串线。MCP 内部 opaque token 只捕获在 adapter taskApp Server outer request id 只出现在 JSON-RPC,二者是双层精确 identity`turnId` 只作 correlation,不参与路由,也不能伪造成 sampling-step capability。MCP operation timeout 由真实 connection handler 的 counted pause state 计算 active time;等待一个或多个用户 elicitation 不扣 tool timeoutturn cancellation 仍立即生效。elicitation capability 继续 absentLime 不广告没有独立协议 capability 的行为。
Codex exact App Server MCP 请求固定为以下两条链,Desktop 不复制 TUI 交互,也不建立第二个 manager:
@@ -986,8 +1014,11 @@ Architecture impact: major; portable package、runtime owner、Electron 边界
MCP server 的执行环境身份只来自 `McpServerConfig.environment_id`,由 `lime-mcp::McpEnvironmentRegistry`
在 transport 启动前解析。当前 registry 只注册 `local`;未知显式身份必须 fail closed,禁止把
`remote` 或其它配置值降级成本机 stdio/HTTP 执行,也禁止从 `cwd` 猜测环境。远程 executor/backend
尚未接入,继续作为 `OPEN_REF`,不得新增 compat fallback。
`remote` 或其它配置值降级成本机 stdio/HTTP 执行,也禁止从 `cwd` 猜测环境。Codex 的远端
`environment/{add,info,status}``thread/environment/*` 依赖 exec-server registry、WebSocket
连接恢复和远端 Thread 选择,Lime Desktop 没有对应产品 consumer,已归入
`product-scope-excluded / forbidden-to-restore`。远程 executor/backend 仍未接入 Lime current,禁止新增
compat fallback 或空壳 JSON-RPC。
本地 stdio 启动还必须复用 Codex 的平台核心环境变量 allowlist,并仅叠加配置显式 `env`
不得把 Desktop Host 的完整环境(尤其凭证变量)隐式继承给 MCP 子进程。启动 deadline
@@ -2233,7 +2264,11 @@ runtime fact 和 canonical read model 承接,terminal interaction 只保留 bo
空字符串是有效 net-zero 结果,不得回退到由 GUI items 拼装的第二份 patch。该链路不复制 Codex TUI,也不改变 provider owner
多模型、多模态 sampling 和媒体 lowering 继续归 Grok-aligned `model-provider`
矩阵中的 `plugin/share/*``plugin/skill/read` 与其余 review notifications 继续为 `planned`
矩阵中的 `plugin/share/*`Codex `marketplace/*`、external-agent migration 与远端
`environment/*`/`thread/environment/*` 均按 Desktop 产品范围裁决为
`product-scope-excluded / forbidden-to-restore``plugin/skill/read` 的 Codex remote-plugin-only
wire 同样 excluded。Lime current 只保留本地 Plugin v3 catalog、Skills catalog/`skill/read`
独立 `skillMarketplace/install` owner
`item/autoApprovalReview/*` 已由 Guardian current owner 承接;`turn/moderationMetadata` 由下一节 current
主链接管。没有新增 `compat`
`deprecated`;旧 Plugin 私有协议、Renderer 伪造 reverse request、raw diagnostic side-channel、未脱敏 terminal
@@ -2453,3 +2488,161 @@ Desktop Settings、App Server 与 tool-runtime 的唯一数据流写入架构事
root, 2026-08-10。Confirmation content: 已核对 `SandboxBackendStatus::Planned``enforced=false`
`prepare_sandbox_command(RestrictedToken)` 拒绝路径、非 Windows/未配置状态,以及 Desktop/TUI、Codex runtime 和
Grok model/multimodal owner 分界。
## 43. Desktop Composer Fuzzy File Search Owner
项目文件 `@` 补全属于对话工作台 Composer 的紧凑选择面板,不是新的文件浏览页,也不是 pending interaction。
唯一产品链为:
```text
CharacterMention current @token
-> project root from App Server project read
-> Renderer typed fuzzyFileSearch gateway
-> Electron app_server_handle_json_lines
-> App Server fuzzyFileSearch processor
-> filesystem search owner
-> relative project path candidates
-> replace only the active @token
```
App Server 校验每个 root 是可读绝对目录,不跟随目录 symlink,并跳过 `.git/.hg/.svn/node_modules/target/dist/build/coverage`
目录;结果限制为 50 条并按 score/path 稳定排序。同 cancellation token 的新请求标记旧扫描取消,Renderer 再用
AbortSignal 与 request version 拒绝迟到响应。空 query/root 返回空结果;GUI 只显示项目相对路径,空格路径加引号,
不会把本地文件伪装为 `plugin://`、connector 或 canonical UserInput Mention。
Codex experimental session request/notification 会建立第二套长生命周期 search registryLime Desktop 没有对应产品
consumer,因此统一为 `product-scope-excluded / forbidden-to-restore`notification inventory 只做脱敏 drift diagnostics
不进入 current projector。Electron 不新增业务 IPCGrok-aligned 多模型 catalog、route、capability/readiness 与多模态
sampling/media lowering owner 不变。
Architecture impact: major;本节新增 Composer 到 App Server filesystem search owner 的跨层数据流,并固定 one-shot
request、双层取消、Desktop/TUI 分界和 session surface 排除。Responsible developer confirmation: root,
2026-08-10。Confirmation content: 已核对 protocol/processor/client/gateway/Composer 依赖方向、绝对 root 与相对结果、
session 回流守卫、Electron 通用转发边界,以及 Grok 多模型/多模态 owner 不变。
## 44. Desktop CodeMode Runtime Boundary
CodeMode 是 Agent runtime 的工具编排模式,不是 Codex TUI 功能。Lime Desktop 只复用其 runtime contract
模型 capability/readiness 与 provider wire 仍分别归 Grok-aligned `model-provider` 控制面和各协议 lowering。
目标唯一数据流为:
```text
Desktop turn request + selected model capability
-> selected profile slot requiredCapabilities
-> authoritative model runtime_features
-> resolved provider protocol/host capability intersection
-> App Server / agent-runtime sampling-step snapshot
-> tool-runtime RuntimeToolMode + frozen CodeMode tool plan
-> model-provider native freeform/custom-tool lowering
-> thread-owned CodeMode session runtime
-> exec/wait -> canonical nested RuntimeTool execution
-> Tool lifecycle + Thread/Turn/Item projection -> GUI
```
当前已落地的 production foundation 包含 planning boundary、Agent loop executable boundary、thread-owned
session service、in-process V8 provider 与 Runtime backend factory 接线。
`RuntimeToolExposure` 与 Codex 一致为
`Direct / Deferred / DeferredModelOnly / DirectModelOnly / CodeModeOnly / Hidden`;规划器从同一个 frozen
tool snapshot 生成 direct model、searchable 和 nested surface,并固定 namespace 拼接、JavaScript identifier
normalization、`exec`/`wait` 保留名与 normalization collision 的确定性 first-winner。普通 `CodeMode` 在 runtime
不可用时仅可按显式策略回退 Direct;`CodeModeOnly` 或禁用 fallback 必须 fail closed。
`tool-runtime::code_mode` 是 transport-neutral session contract owner`execute` 返回带稳定 `cell_id` 与独立
`initial_response` future 的 `RuntimeCodeModeStartedCell``wait/terminate` 返回保留 live/missing 语义的 outcome
同一 handle 提供 `shutdown`session provider 在启动 host 前报告 availability,并以 delegate 承接 nested tool、
notification 与 cell close,非默认 yield/heap limits 在 provider 未实现时 fail closed。模型可见结果固定包含
`Script running/completed/failed/terminated` 状态,yielded 输出必须带 `cell_id`,error 不能吞掉已产生的 output
output token budget 复用 `tool-runtime` 的统一 token truncation。
`agent-runtime::provider_turn::code_mode` 只在 frozen sampling-step snapshot 持有 executable session handle 时,
成组广告 Codex `exec` custom tool 与 `wait` function tool;没有 handle 时继续拒绝 custom call,生产默认 snapshot
不会暴露这两个工具。provider response 必须先完整 materialize,再执行 function/custom/wait,混合结果按原始 call
顺序回写 transcript;同批并行策略仍保留。turn cancellation 在 cell 已启动后必须调用同一 session 的
`terminate`,不得只丢弃 future 留下后台 cell。`wait``yield_time_ms/max_tokens/terminate` 都由该窄模块解析,
普通 tool executor/lifecycle 不冒充 CodeMode runtime。
模型门禁继续复用 Grok-aligned catalog/readiness 唯一事实源。`tool_mode` 与 provider capability 是两个独立事实:
catalog/direct provider config 只接受 `direct / code_mode / code_mode_only` 三个精确 token,缺失或未知值统一为
`Direct`,禁止按 model/provider 名称推断。基础 coding requirement 固定为
`coding/tools/streaming`,只有最终选中的 profile slot 才能追加 `custom_tools`review/fast/local 等未选中
slot 只保留诊断展示,fallback 后按最终 slot 重算。模型侧必须在 authoritative `runtime_features` 显式声明
`custom_tools`。当前只有 Codex 上游同样声明 freeform tool 的精确 canonical 映射 `openai/gpt-5.2` 带该 capability
但该模型没有 `tool_mode` 声明,因此仍按 `Direct` 执行;capability 不能反向开启 CodeMode。resolved route 再与实际 provider protocol/host 求交集:仅官方 OpenAI
Responses route 保留该 featureChat Completions、Anthropic、Gemini、Azure、Ollama 和第三方 Responses
均从 effective snapshot 移除,若选中 slot 要求该能力则以不可重试的
`capability_gap / capability:custom_tools` 在 sampling 前失败。普通聊天没有该额外 requirement,不受影响。
`model-provider` 已建立 provider-neutral `ToolDefinition::Custom``FreeformToolFormat`
`CustomToolCall/CustomToolResult` canonical contract,并只在官方 OpenAI Responses route 的显式
`custom_tools` capability 下 lowering 为原生 `type: "custom"``custom_tool_call`
`custom_tool_call_output`Chat Completions、Anthropic、Gemini、第三方 Responses route 与无 capability
历史均在发网前 fail closed。失败 custom output lowering 必须优先保留格式化 runtime output,不得只发送裸 error。
`agent-runtime::session_loop` 现已成为 canonical thread-owned CodeMode lifecycle owner。actor 创建必须同时绑定
`session_id + thread_id`;同一 session 的 thread identity 漂移直接 fail closed,不保留旧单参数入口。provider
availability 通过后才建立 serviceruntime session 在首次 CodeMode operation 时 lazy createactor replace/interrupt
终止 active cellsshutdown 关闭已初始化 session,未初始化 service 的 shutdown 不得反向创建 runtime session。
task/input handle 只从 actor resources 读取同一 canonical `thread_id` 与 session handle,不另建全局 registry。
production Runtime backend factory 只给 current runtime backend 注入 `RuntimeCodeModeServiceFactory::production()`
mock、external 与 unavailable backend 不注入。factory 只使用 `ProcessCodeModeSessionProvider`,通过同目录
`code-mode-host` 的 length-prefixed stdio protocol 创建 sessionhost 进程内部使用 `V8CodeModeSessionProvider`,每个
cell 在 fresh sandbox-enabled V8 isolate 中执行 async module。host 仅暴露 frozen nested tools、
text/image/audio/generatedImage、store/load、notify、timer、yield 与 exit,不提供 Node、filesystem、network 或
console。session store 只在同一 thread-owned host session 内共享,cell terminate 使用 V8 isolate handlehost
缺失、握手不兼容、崩溃或资源上限不支持均 fail closedproduction 不回退 App Server 进程内 V8。
CodeMode model surface 必须同时满足模型 `tool_mode` 请求、resolved provider `custom_tools` capability 和当前
`RuntimeSessionInputHandle` 持有 executable session,随后才附着 session 并由 provider turn 成组广告 `exec/wait`
普通 `CodeMode` 缺任一条件回落 Direct`CodeModeOnly` 缺任一条件 fail closed`Direct` 即使持有 session 也不广告。
每个 `exec` cell 现在通过 `RuntimeCodeModeService` 建立独立 dispatch routeruntime 在 `execute` 返回
`StartedCell` 前发起 nested callback 时,route gate 会等待 cell identity 绑定,而不是落到最近 turn 或 session-wide
可变 delegate。provider-turn delegate 只从本 sampling step 的 frozen `RuntimeCodeModeTool` 集合按 JavaScript global
name 查找定义,并复用同一个 `RuntimeToolExecutorHandle.bind(...).execute_call(...)`,因此 nested tool 继续经过普通
权限、取消、lifecycle 和 output projection`exec`/`wait` control tool 禁止递归 nested 调用。cell terminal、terminate、
interrupt、cell-close 与 shutdown 都清理 route/gate,不能把下一 turn 的 executor 接到旧 cell。
`exec``wait` 现在都经过 canonical `ToolLifecycleEmitter` 发出 Started/Completed;完成输出保留 cell identity、
CodeMode terminal 状态、`handler_executed` 和格式化 partial output,并由既有 Tool lifecycle 投影形成普通 Tool Item。
nested `notify` 复用同一 turn/call/cell correlation 发出 `ToolOutputDelta`,经 Lime Agent 的既有 Desktop host event
pipeline 进入 App Server/GUI 可消费的事件链;同时以同一 outer `exec` call id 在本 sampling step 的下一次 provider
request 中追加 `custom_tool_call_output`,顺序位于最终 exec output 之前,等价于 Codex
`inject_if_running(CustomToolCallOutput)` 在当前 turn 内的模型可见结果。空通知静默,已取消通知 fail closed。该注入
只存在于当前 provider turn 的下一 sampling request,不经过 `RuntimeSessionInputHandle`,也不作为独立 durable Item
持久化;CodeCell 的 thread-owned trace/evidence owner 尚未建立,不能借用 current-turn `Tool` Item 或公开 ThreadItem
伪造跨 Turn 的 started/closed 阶段。cell route 还保留 closed-cell 终态集合:terminal response、terminate、
interrupt、shutdown 或 host `cell_closed` 后,迟到的 nested invoke/notify 直接返回 closed error,不会重新创建空 gate
并永久等待,也不会落到 fallback delegateprovider-turn delegate 同时以原子 closed 标志拒绝迟到的 Desktop delta 和
provider transcript output。
V8 build 输入固定从 `lime-rs/Cargo.lock` 读取精确 crate version`scripts/lib/rusty-v8-artifacts.mjs` 只接受
Codex `ptrcomp_sandbox_release` 的受支持 platform target,下载 archive/binding/checksum manifest 后逐项校验 SHA-256
再向 Rust test、local CI 和 App Server sidecar build 注入成对的 `RUSTY_V8_ARCHIVE` /
`RUSTY_V8_SRC_BINDING_PATH`。V8 archive 编译期只静态链接进 `code-mode-host`,不复制进 Electron resources。
dev、Electron asset 与 Windows build 使用同一次 Cargo invocation 成组产出 `app-server``code-mode-host`Electron
release bundle 将二者放在同一平台目录,manifest 分别记录 `sha256``codeModeHostSha256`packaged verifier 必须同时
校验。`default_code_mode_host_path` 只解析 App Server/测试二进制同目录,不搜索 PATH 上的替代 runtime。
CodeMode 专项 Electron Gate B 已通过
`.lime/qc/gui-evidence/code-mode-electron-gate-b/code-mode-electron-gate-b-summary.json` 建立:真实 Electron、
preload/contextBridge、IPC、`app_server_handle_json_lines`、App Server runtime backend、official-host Responses route、
production process factory、custom `exec` 回采样、canonical Thread/Turn/Tool Item 与 GUI 可见终态使用同一 identity
Electron、App Server 与 `code-mode-host` PID 分别为 `44199 / 44203 / 44521`host 的父 PID 精确为 App Server。公开 Item
类型只有 `userMessage/dynamicToolCall/agentMessage`mock fallback、invoke/console/page/provider error 均为零。该场景通过
标准 `HTTP_PROXY` 把仍以 `api.openai.com` 为 Host 的请求路由到受控本地 fixture,只证明 official-host capability、
production lowering 与 macOS dev-process isolation,不冒充 live OpenAI、Windows/packaged parity 或公网稳定性证据。
当前 alignment blocker 只剩 thread-owned CodeCell trace/evidence ownerCodex 公开 App Server ThreadItem 本身没有
CodeCell variant,现阶段只消费既有 outer Tool Item/GUI lifecycle surface,不新增 CodeCell GUI card 或产品事件旁路。
不得借用系统 Node、shell eval、Electron renderer 或 Codex TUI 进程执行 JavaScriptCodeCell trace 只在建立真实
consumer 与唯一 trace/evidence owner 后实现。Electron 仍只做 Desktop Host、sidecar lifecycle 与标准 GUI 投影。
Architecture impact: major;本节固定 CodeMode 的唯一 owner、Desktop/TUI 分界、selected-slot requirement、
authoritative model declaration 与 resolved provider route 交集、provider custom contract、session lifecycle contract
和 Agent loop fail-closed 上线顺序。Responsible developer confirmation: root, 2026-08-12。Confirmation content:
已核对 Codex `ToolMode`/`ToolExposure``StartedCell`、session provider/delegate、sandbox V8、freeform `exec`、function
`wait`、yield/terminate/cancel 与 mixed-call transcript 顺序;确认 Lime 当前完成 transport-neutral contract、
canonical thread-owned lazy service、process-owned sandbox V8 host、production factory、三重门禁、per-cell nested
dispatch、outer Tool lifecycle、notify Desktop event/provider-transcript projection、双 sidecar 构建供应链与专项 Electron
Gate B。当前明确保留 thread-owned CodeCell trace/evidence owner blocker;未把受控 fixture 等同于 live provider,也未把
macOS dev Gate B 等同于 Windows/packaged parity。
+16
View File
@@ -193,6 +193,22 @@ Desktop 操作,不新增 Codex 不存在的 rename method。
Office/PDF 文本提取不属于 `fs/readFile`;若产品继续需要,应在独立 current 文档能力 owner 中重建,禁止恢复旧
`fileSystem/readFilePreview`
## Fuzzy File Search 主链
Desktop Composer 项目文件补全只允许走一发式 current 链:
`CharacterMention -> src/lib/api/fuzzyFileSearch.ts -> typed App Server client -> app_server_handle_json_lines -> App Server fuzzyFileSearch -> filesystem search owner`
请求使用 `{ query, roots: [absoluteProjectRoot], cancellationToken }`,返回最多 50 条按 score/path 排序的相对路径、
file/directory、file name 与 match indices。Renderer 使用稳定 cancellation token、AbortSignal 和 request version
丢弃旧响应;选中结果只替换当前 `@token`,空格路径加引号,不创建 connector/plugin `Mention`。Electron 只转发
现有 JSONL,不新增文件搜索 IPC、业务后端或生产 mock fallback。
Codex experimental `fuzzyFileSearch/sessionStart|sessionUpdate|sessionStop`
`fuzzyFileSearch/sessionUpdated|sessionCompleted` 不属于 Lime Desktop 产品面,均为
`product-scope-excluded / forbidden-to-restore`。两个 notification 只允许 method/field-name 级 drift diagnostics
不得进入 current protocol manifest、Composer state、pending interaction 或兼容 wrapper。
## Browser Session 主链
浏览器会话检测、连接、读回、动作与关闭只允许走:
@@ -6,6 +6,116 @@
Fal/Bedrock adapter、真实云 Provider/地区/凭证矩阵,以及
audio/video/file 的逐 Provider chat wire 属于后续细节,不计入本阶段完成度。
## 2026-08-11 P1-01 provider tool-call repair 接线
状态:`completed / current`provider sampling + JSON Schema repair + 组合竞态 + 跨协议 capture 四刀;CodeMode 规划、provider custom canonical contract、catalog/readiness、Agent loop executable boundary、thread-owned lazy service、per-cell nested dispatch、lifecycle/notify projection、late terminal guard、projection scope correction与 production in-process V8/factory 第五至十三刀为 `current foundation`P1-01 总项未关闭)。
主目标:把已经存在于 `tool-runtime` 的 typed repair outcome 接入 current provider sampling
step,确保 provider 返回的 malformed/scalar/unknown tool call 不再提前中止整步,也绝不进入
真实 handler;大小写、current native alias、已知参数别名和 JSON Schema 校验只按本 step 冻结的
definitions 执行。
窄写集:`runtime-core/src/llm_protocol/canonical.rs`
`model-provider/src/current_client{,/stream,/request_capture_tests}.rs``agent-runtime/src/provider_turn{,/tests}.rs`
`tool-runtime/{Cargo.toml,src/tool_call_surface.rs,src/code_mode.rs,src/tool_definition.rs,src/turn_snapshot.rs,src/lib.rs}``lime-rs/Cargo.lock`、本计划、
`internal/aiprompts/architecture.md``internal/refactor/v1/08-third-audit-gap-register.md`
唯一数据流:
`provider raw tool call -> canonical LlmEvent(raw_arguments) -> frozen RuntimeToolStepSnapshot(definition + schema + executor) -> repair_tool_call -> deterministic numeric coercion -> JSON Schema validation -> executable call | invalid no-handler lifecycle -> tool result -> next sampling step`
退出条件:repair success 使用 canonical name/arguments 且只执行一次;malformed/scalar/unknown/schema mismatch
统一产生 exactly-one started/completed lifecycle 和一个 model-visible failed tool result
`handler_executed=false`;坏 schema fail closedprovider error 不再吞掉可恢复调用;repair 后的 cancel/timeout/
late completion 组合不覆盖或重放 canonical terminal。最低验证为四个 Rust owner 的 related/integration、
`smoke:agent-runtime-current-fixture`、治理报告和 diff check。本刀不改 GUI/bridgeElectron Gate B
只作为 Desktop 主链无回归证据,不冒充 schema repair 语义或 live provider 证据。
完成结果:`runtime-core` canonical `ToolCall` 显式保留 `raw_arguments``model-provider` 对 malformed
JSON、scalar arguments 和空工具名只保留 raw call,不再把整个 provider step 提前终止。`agent-runtime`
在写入 transcript 前用冻结的 `RuntimeToolStepSnapshot` 调用 `tool-runtime::repair_tool_call`:大小写、current
native/workspace alias 和已知参数别名修复为 canonical callunknown、空名称、malformed 或 scalar 参数统一
转为带 typed repair metadata 的 `invalid` call。专用 `before_handler` executor 保证 invalid call 不触达真实
handler,同时仍只产生一组 started/completed lifecycle 和一个 model-visible failed result;下一 sampling step
只看到 canonical call/result transcript。
第二刀把 `repair_tool_call` 的 API 直接切换为接收冻结的 `RuntimeToolDefinition`,不保留 names-only
兼容入口。alias normalization 后,只对 schema 明确声明为 `integer`/`number` 的合法 JSON 数值字符串做
递归 deterministic coercion,再使用同一 `input_schema` 完整校验;schema mismatch 与 schema 编译失败都进入
typed `invalid` no-handler lifecycle。validation error 使用 masked instance valuemodel-visible arguments 和
下一 sampling transcript 都不包含原始参数值;没有新增第二份 schema owner 或自然语言猜测修复。
第三刀在 `agent-runtime::provider_turn` 补齐组合竞态:repair 后的 canonical handler 启动后取消 turncanonical
executor 立即生成唯一 `aborted` completed 并释放 turn;测试随后显式释放 handler 自有后台工作,证明迟到 success
无法覆盖终态、追加 lifecycle 或触发第二次 provider sampling。另一场景让 repaired tool 正常完成后挂起下一 provider
step 并触发 absolute timeout,证明 handler 只执行一次,canonical call/result transcript 只进入该 request
lifecycle 不重放。该刀只增加 current owner 跨层回归,不新增生产分支或第二终态 owner。
第四刀补齐全部 current chat transport 的 request-capture 矩阵。既有 Gemini、Vertex、Azure Responses 与 Ollama
Responses 已从真实 loopback 捕获 path/auth/body 并消费 native SSEResponses WebSocket 已捕获 handshake、
`response.create` 与 HTTP replay。新增独立 `request_capture_tests`,从 `CurrentProviderClient::stream` 分别进入
OpenAI Chat Completions、OpenAI Responses HTTP 与 Anthropic Messages,联合断言 canonical system/user/image、
repaired tool call/result transcript、tool schema、generation lowering、native auth/header、exact endpoint 与 SSE
terminal。Anthropic 只断言服务端真实返回的 input/output usage,不伪造 `total_tokens`。该刀不新增 transport、
protocol alias 或生产 capture ownerBedrock/Fal 继续在发网前 fail closed。
第五刀完成 CodeMode 的 planning foundation,但不伪造可执行能力。`tool-runtime` 的 exposure 直接切换为 Codex
六态 `Direct / Deferred / DeferredModelOnly / DirectModelOnly / CodeModeOnly / Hidden`,并新增唯一
`RuntimeToolMode::{Direct, CodeMode, CodeModeOnly}` 决策和 frozen tool plan。规划器精确区分 direct model surface、
searchable surface 与 nested CodeMode surface,复用 Codex namespace 拼接、JavaScript identifier normalization、
`exec`/`wait` 保留名和 normalization collision first-winner 语义;不可用的普通 CodeMode 只在明确允许时回退
DirectCodeModeOnly 或禁用 fallback 时直接 fail closed。当前 `model-provider` 仍只有跨协议 function-tool canonical
contract,仓库也没有 CodeMode session/runtime host,因此本刀不向 provider 曝光假的 `exec`/`wait`,不新增 V8/JS
依赖,也不复制 TUI warning 或远程 host。
验证结果:第一刀 `tool-runtime tool_call_surface` `12/12``model-provider --lib` `236/236`
`agent-runtime provider_turn::tests` `43/43` 通过;第二刀 `tool-runtime tool_call_surface` `15/15`,完整
`tool-runtime 320/320``agent-runtime 193/193` 与 provider-turn `43/43` 通过。第三刀两个新增组合回归
定向 `3/3`、完整 provider-turn `45/45` 与完整 `agent-runtime 195/195` 通过;并行首轮曾由测试自身 `1s`
外层防死锁超时先于业务 `20ms` deadline 被调度,放宽为不改变生产语义的 `5s` 测试护栏后完整回归稳定通过。
`npm run test:rust:related -- lime-rs/crates/agent-runtime/src/provider_turn/tests.rs` 扩展到 `agent-runtime`
`app-server``lime-agent``lime-scheduler``lime-server` 五个 current/反向依赖 crate 并退出 `0`;仅有一条
既有 `app-server` 测试 helper 的 dead-code warning,与本刀无关。
第四刀 request capture 定向 `3/3`、完整 `model-provider 239/239` 通过,覆盖新增 Chat/Responses HTTP/Anthropic
与既有 Gemini/Vertex/Azure/Ollama/Responses WebSocket/reducer 全矩阵;未调用 live provider 或读取真实凭证。
`npm run test:rust:related -- lime-rs/crates/model-provider/src/current_client.rs lime-rs/crates/model-provider/src/current_client/request_capture_tests.rs`
扩展到 `model-provider` 与 12 个反向依赖 crate 并退出 `0`;其中 `agent-runtime 195/195`
`model-provider 239/239``tool-runtime 320/320` 全绿,仍只有一条既有 `app-server` 测试 helper dead-code warning。
第五刀新增 CodeMode planning 定向测试 `3/3`、snapshot 回归 `1/1` 与完整 `tool-runtime 323/323`
通过。`npm run test:rust:related -- lime-rs/crates/tool-runtime/src/{code_mode.rs,tool_definition.rs,turn_snapshot.rs,lib.rs}`
扩展到 `agent-runtime``app-server``lime-agent``lime-mcp``lime-scheduler``lime-server`
`tool-runtime` 七个 current/反向依赖 crate 并退出 `0`,其中 `agent-runtime 195/195``tool-runtime 323/323`
仅有一条既有 `app-server` 测试 helper dead-code warning,与本刀无关。
`npm run test:rust:related -- lime-rs/crates/{runtime-core,model-provider,tool-runtime,agent-runtime}` 对扩展后的
14 个 current/反向依赖 crate 退出码为 `0`,其中 `agent-runtime 193/193``tool-runtime 317/317`
第二刀 `npm run test:rust:related -- lime-rs/crates/{tool-runtime,agent-runtime}` 扩展到 7 个 current/反向依赖
crate 并退出 `0`;其中新增依赖后的 `agent-runtime 193/193``tool-runtime 320/320` 全绿。
`npm run test:contracts` 通过,生成 `959` 个 protocol types 且零漂移,App Server client contract
`301` checks 全绿。`npm run governance:legacy-report` 扫描 `2120` 个 current 文件,零引用候选、分类漂移和
边界违规均为 `0``cargo fmt --all -- --check``git diff --check` 通过。
`npm run smoke:agent-runtime-current-fixture` 最终完整退出 `0`,覆盖真实 Electron Desktop Host、preload/IPC、
`app_server_handle_json_lines`、App Server/runtime/read model 和 GUI,且 `liveProviderUsed=false`。首轮在
Expert Panel Skills Runtime 场景的失败已确认是另一聚合进程使用同一静态 prefix/backend ledger 造成证据串线:
失败摘要的外层 session 与被轮询 read-model thread identity 不同;使用唯一 prefix 的专项复跑退出 `0`,待并发
进程结束后的完整聚合复跑同样退出 `0`。因此 Gate B 只作为 Desktop 主链无回归证据,不冒充 repair 语义或 live
provider 证据。
第二刀在无同类聚合进程并发的条件下再次完整运行该 Gate B 并直接退出 `0`unknown Item、首页热路径、
Coding Workbench、图片命令、cancel/continue、approval、Plan、Skills Runtime、MCP structuredContent、media
reference、Expert Plaza/Panel、typed error success/failure 与 Content Factory 均通过,`liveProviderUsed=false`
分类:canonical raw call、冻结 schema repair、typed invalid no-handler lifecycle 与回归证据均为 `current`;此前
provider malformed call 直接终止整个 step、以及未按冻结 schema 校验便执行 handler 的行为均为
`dead / deleted / forbidden-to-restore`;无新增 `compat``deprecated`。前四刀关闭 provider sampling、
JSON Schema repair、repair 后 cancel/timeout/late completion 的组合竞态与全部 current chat transport 的
request captureCodeMode exposure/mode/plan、provider freeform/custom-tool canonical contract、
catalog/readiness gate、transport-neutral session contract 与测试注入下的 Agent loop `exec/wait` boundary 为
`current foundation`canonical thread-owned lazy service、actor identity/interrupt/shutdown owner 同属
`current foundation`per-cell nested dispatch、outer `exec/wait` canonical Tool lifecycle 与 notify Desktop event
projection 也同属 `current foundation`。P1-01 总项仍保留 standalone CodeMode host 的 OS 进程隔离、thread-owned
CodeCell trace/evidence owner、CodeMode 专项 Electron Gate B,以及 extension/background terminal 组合证据。
本文件是并行执行的协调面,不定义新的 runtime owner。目标是让多个进程同时推进
Codex v2 对齐时保持窄写集、可编译、可回滚和可验证。所有实现仍服从:
@@ -4543,7 +4653,7 @@ Rust fmt 已完成。本切片没有改变 GUI、Electron/preload、Thread/Turn/
### 2026-08-10 Desktop fuzzy file mention current slice
状态:in progress
状态:completed
主目标:把 Composer 已有 `@` 输入面板缺失的项目文件检索接到 Codex exact 一发式
`fuzzyFileSearch { query, roots, cancellationToken }`,形成
@@ -4569,3 +4679,461 @@ release 计划。
模式且存在项目 root 时查询;选中带空格路径时正确加引号并只替换当前 `@token`。同步 generated contracts、五语言、产品
矩阵与回流守卫,并运行 Rust related/public JSON-RPC、Renderer unit/component、`npm run test:contracts`
`npm run governance:legacy-report`、typecheck、GUI smoke 与风险匹配的 Gate A/Gate B。
完成结果:one-shot `fuzzyFileSearch` 已由 `CharacterMention` 接入 Renderer typed gateway、Electron
`app_server_handle_json_lines`、App Server JSON-RPC 与 filesystem search owner。Composer 已覆盖 loading/error/empty、
AbortSignal、稳定 cancellation token、request version 和陈旧响应丢弃;项目文件候选使用相对路径,选中后只替换当前
`@token`,空格路径加引号,不创建 connector/plugin `Mention`。协议 schema、generated client、Rust handler、五语言文案、
产品矩阵和负向回流守卫已同步。产品矩阵更新为 `140 implemented / 22 planned / 59 product-scope-excluded`,产品范围完成度
`140 / 162 = 86.4%`
验证结果:Renderer gateway、插入 helper、hook 与组件定向测试 `29/29` 通过;产品矩阵、Renderer 投影覆盖和 notification
drift 定向测试 `20/20` 通过;Rust related 反向依赖扩圈退出码为 `0``npm run typecheck``npm run test:contracts`
generated protocol types `959`、App Server client contract `301` checks)、`npm run governance:legacy-report`(零引用
候选、零分类漂移、零边界违规)和 `npm run verify:gui-smoke` 均通过。通用 GUI smoke 的真实 Electron/App Server shell
Gate B 证据位于 `.lime/qc/project-gates/standalone-shell-01-20260810115558-33013/shell-01-electron-smoke/summary.json`
专用用户闭环 Gate B 已在真实 `http://127.0.0.1:1420/?nativeStartup=1` Electron 页签完成:
`window.__LIME_ELECTRON__ === true` 且 preload invoke 存在;当前项目为仓库根 `lime`,在 Composer 中输入
`保留前缀 @forge 保留后缀` 后看到 `forge.config.mjs` 等项目文件候选,点击首项后得到
`保留前缀 forge.config.mjs 保留后缀`,只替换当前 token 并关闭面板。脱敏 trace 证明
`transport=electron-ipc``command=app_server_handle_json_lines`、JSON-RPC method `fuzzyFileSearch`、单一 root 命中当前仓库且
cancellation token 存在;console error、page error、invoke error 与 production mock fallback 均为 `0`。截图证据:
`.lime/qc/gui-evidence/fuzzy-file-search-forge-candidates.png`
`.lime/qc/gui-evidence/fuzzy-file-search-forge-selected.png`
分类:one-shot method、filesystem search owner、typed gateway、Composer 文件候选和本轮 Gate B 证据为 `current`;没有
`compat``deprecated`;三个 experimental session request 与两个 session notification 为
`product-scope-excluded / forbidden-to-restore`,旧 session registry、第二搜索入口和生产 mock fallback 为
`dead / deleted / forbidden-to-restore`。架构确认:confirmed;责任开发者 root2026-08-10。多模型与多模态 control
plane 未改动,继续归 Grok-aligned `model-provider`
### 2026-08-10 Codex remote environment and migration scope closure
状态:completed。
目标与窄写集:复核剩余 `planned` method 是否属于 Lime Desktop 产品范围。只审计 Codex exact
`environment/{add,info,status}``thread/environment/{connected,disconnected}`
`externalAgentConfig/*``marketplace/*``plugin/share/*` 的协议语义、四层消费者与唯一 owner;不新增
远端 executor、配置迁移器、Plugin share service、marketplace backend、Electron IPC 或 renderer mock fallback。
事实裁决:Codex `environment/*` 通过 App Server `EnvironmentManager` 注册远端 exec-server WebSocket,读取 shell/cwd
并报告连接状态;Lime 当前只有本地执行环境、`TurnEnvironmentParams` identity、tool/MCP provenance 和 world-state cwd
投影,没有远端 registry、连接恢复或桌面环境选择 workflow。Codex external-agent config 是从其他 agent 产品检测和导入
配置/历史的迁移服务,Lime 没有 import wizard、source adapter 或 migration store。Codex `marketplace/*`
`plugin/share/*` 是远端 Plugin marketplace/share service 的管理和 principal mutationLime 的 marketplace 是独立
`skillMarketplace/install`Plugin current owner 只承接本地 v3 catalog/install/activation。已有
`PluginShareContext` DTO 不是 handler、gateway 或 GUI consumer,不能冒充实现。
完成结果:19 个方向化 method 从 `planned` 移入 `product-scope-excluded / forbidden-to-restore`
3 个 environment request、2 个 environment notification、4 个 external-agent request、2 个
external-agent notification、3 个 marketplace request、5 个 plugin/share request。矩阵更新为
`140 implemented / 3 planned / 78 product-scope-excluded`,产品范围完成度为 `140 / 143 = 97.9%`
environment 与 external-agent notification 保留在 upstream drift inventory,但只进入 method/field-name 脱敏 diagnostics
不得进入 Header、timeline、pending interaction 或 current projector;所有 excluded method 均由负向 manifest guard 锁定。
事实源分类:本地环境 identity、world-state、tool-runtime/MCP provenance、Plugin v3 catalog 和 Skills marketplace 为
`current`;本轮 excluded Codex remote/TUI/cloud surface 为 `product-scope-excluded / forbidden-to-restore`;无新增
`compat``deprecated`。未实现的 Windows setup/notifications 仍是 `planned`,必须等 Windows/MSVC toolchain、
restricted-token enforcement 和真实 Electron Gate B 证据,不得用 macOS 代码或 readiness `updateRequired` 伪造完成。
架构确认:confirmed;责任开发者 root2026-08-10。本轮只收敛产品范围与 diagnostics 边界,未改变
`Electron Desktop Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection -> GUI`,也未改变
Grok-aligned `model-provider` owner。下一刀回到 Windows 平台证据,或基于新发现的真实 Desktop consumer 重新做范围审计。
验证结果:JSON 解析通过;method matrix、environment/external-agent diagnostic-only 与 render projection 定向测试共
`22/22` 通过;`npm run test:contracts` 通过(generated protocol types `959`、App Server client contract `301` checks);
`npm run governance:legacy-report``npm run typecheck`、受影响文件 Prettier 和 `git diff --check` 均通过。由于本轮无 GUI、
Electron、Rust runtime producer 改动,不重复 Gate B;已有 fuzzy-file Gate B 证据保持有效。
### 2026-08-11 CodeMode provider custom-tool canonical contract
状态:`completed / current foundation`provider canonical contract 第六刀与 catalog/readiness 第七刀)。
主目标:沿 Codex CodeMode planning foundation 的下一刀,把 provider-neutral freeform/custom-tool contract
落到唯一的 Grok-aligned `model-provider` owner;保持 Lime 为 Desktop 产品,不恢复 Codex TUI 或第二套 runtime。
窄写集:`runtime-core` canonical tool/content/event、`model-provider` current request/lowering/Responses SSE reducer、
provider capability snapshot、`agent-runtime` provider-turn fail-closed、legacy provider-call handler negative boundary
以及对应 Rust request-capture、lowering、stream 和架构/缺口文档。未引入 V8、JavaScript executor、`exec`/`wait` 广告或
compat wrapper。`current_client.rs``lowering.rs``stream.rs``provider_turn.rs` 已超过 1000 行,本刀只在既有
owner 点完成 contract 接线;下一次继续增加 provider/CodeMode 业务逻辑前,必须先把 current request DTO/conversion、
lowering tests 和 Responses custom reducer 分别拆到短领域模块,不能继续向这些大文件堆叠。
完成结果:新增 `ToolDefinition::Custom` + `FreeformToolFormat``CustomToolCall/CustomToolResult` canonical
variants;官方 OpenAI Responses route 在显式 `custom_tools` capability 下 lowering 为原生 `type: "custom"`
`custom_tool_call``custom_tool_call_output`custom input delta/done、output item 与 response.completed 均投影为
typed `CustomToolCall`completed-only custom call 也补齐 exactly-once `ToolInputStart/Delta/End` lifecycle。grammar
fixture 与 Codex 保持 `syntax: "lark"`。Chat Completions、Anthropic、Gemini、第三方 Responses route 和 legacy
provider-call endpoint 对 custom 都 fail closedprovider turn 明确返回“需要可执行 CodeMode session”,不会把
不可执行 custom call 当普通工具。
验证:owner 单测 `model-provider 242/242``agent-runtime 196/196``lime-server 119/119``runtime-core 59/59`
通过;`cargo check -p runtime-core -p model-provider -p agent-runtime -p lime-server` 通过。`npm run
test:rust:related -- <本切片路径>` 对 14 个直接/反向依赖 crate 扩圈退出码 `0`,其中 `tool-runtime 323/323`
`npm run governance:legacy-report` 为零引用候选、零分类漂移、零边界违规,Rust fmt 与 `git diff --check` 通过。
`npm run smoke:agent-runtime-current-fixture` 的真实 Electron/preload/IPC/App Server/read model 多场景 Gate B 通过,
`liveProviderUsed=false`;它证明 current runtime 无回归,不冒充 live provider 或可执行 CodeMode 证据。
新增 provider-turn 定向回归同时证明 custom call 在没有可执行 session 时为不可重试、不可 reroute 的
`InvalidRequest`,普通 tool executor 调用数与普通 lifecycle event 数均为零。
分类:canonical contract、official Responses lowering/stream、capability gate 和 negative boundaries 为 `current`
未实现的 CodeMode session/V8、nested dispatch、yield/resume/terminate、approval/cancel 与 canonical terminal 为
`planned`;无 `compat`/`deprecated`。不得以当前 provider contract 宣称 CodeMode 已可执行。
第七刀完成 Grok-aligned catalog/readiness 接线。模型 taxonomy 新增标准 token `custom_tools`;基础
`coding/tools/streaming` requirement 只合入最终选中 profile slot 的 capability tagsfallback 后不继承原 slot
未选中 review/fast/local 标签不污染当前 route。上游 Codex 八个 freeform 模型中,Lime canonical catalog 只有
`gpt-5.2 -> openai/gpt-5.2` 精确映射,因此只给该条目显式声明,不按 GPT/Codex 名称扩散。App Server effective
snapshot 取 authoritative model declaration 与 resolved provider protocol/host capability 的交集;仅官方 OpenAI
Responses 保留 `custom_tools`,第三方 Responses、Chat Completions、Azure 与 Ollama 均在 sampling 前返回不可重试的
`capability_gap / capability:custom_tools`。普通聊天未要求该 feature 时只裁剪 effective snapshot,不受阻塞。
第七刀写集为 `runtime-core` routing payload/model task、`core/services` runtime-feature taxonomy、
`model-provider` canonical catalog/provider capability、App Server resolved route contract 与本架构/计划。
`services/src/model_registry_service.rs` 已超过 1000 行,本刀只增加既有 taxonomy 映射;下一次继续增加 provider
catalog 解析或推断规则前,必须把 runtime-feature parsing/taxonomy 拆到独立短模块。
第七刀定向验证:selected-slot 与 fallback requirement `2/2`、runtime capability `1/1`、provider route capability
`1/1`、canonical catalog exact mapping `1/1`、App Server official/configured-provider/fail-closed/normal-chat route
`5/5` 通过。独立 `CARGO_TARGET_DIR=/tmp/lime-custom-tools-target` 下 App Server lib `1646/1646` 通过;
`npm run test:rust:related -- <本刀 Rust 路径>` 扩展到 20 个 current/反向依赖 crate 并退出 `0`,其中
`agent-runtime 196/196``model-provider 242/242``tool-runtime 323/323`。canonical JSON exact assertion、
`cargo fmt --all --check``git diff --check``npm run governance:legacy-report` 均通过,治理结果为零引用候选、
分类漂移和边界违规全部为 `0``npm run smoke:agent-runtime-current-fixture` 使用本轮最新 App Server sidecar
完成真实 Electron/preload/IPC/App Server/read model 聚合场景并通过,`liveProviderUsed=false`;它证明普通聊天、
审批、取消/继续、Plan、Skills、MCP、媒体与工作台 current 主链未被 readiness 交集回归,不冒充 live provider
或可执行 CodeMode 证据。第七刀完成度为 `100%`
下一刀进入 thread-owned CodeMode session runtime,仍不得向生产模型广告尚不可执行的 `exec`/`wait`
### 2026-08-11 CodeMode Agent loop executable boundary
状态:`completed / current foundation`(第八刀;P1-01 总项仍未关闭)。
主目标:把第六、七刀的 provider custom contract 接到可验证的 Agent loop/session 生命周期边界,同时保持
production fail closed;本刀不使用 Node、shell eval、Electron renderer 或测试 fake 冒充隔离 JavaScript host。
窄写集:`tool-runtime::code_mode` transport-neutral contract、`agent-runtime::provider_turn::code_mode` 调度模块、
`model-provider` custom failure lowering、对应 owner tests,以及本架构/执行计划。遵守上一刀大文件退出条件:
CodeMode 执行逻辑已从超过 1000 行的 `provider_turn.rs` 拆到短领域模块,新增 lowering 回归也放入独立
`current_client/code_mode_tests.rs`,没有继续向 `lowering.rs` 的 inline tests 堆业务场景。
完成结果:session contract 与 Codex 同构为 `StartedCell(cell_id + initial_response)``execute/wait/terminate/shutdown`
保留 live/missing 的 wait outcome、session provider availability/create/limits 与 nested tool/notification/cell-close
delegate。response adapter 固定 `Script running/completed/failed/terminated`yielded 输出稳定携带 `cell_id`
terminated 是成功终态,失败同时保留 partial output 与 error,并复用统一 token truncation。
Agent loop 只在 frozen sampling-step snapshot 注入 executable session handle 时成组广告 `exec` custom tool 与
`wait` function tool;默认 production snapshot source 没有该 handle,因此不广告、不产生假 capability。
provider stream 先完整 materialize,再按同批并行策略执行 function/custom/wait,结果按原始 call 顺序回写;
`wait` 支持 `yield_time_ms/max_tokens/terminate`turn cancel 对已启动 cell 调用 `terminate`。非 `exec` custom call、
无 session custom call 与 unsupported limits 均 fail closed。Responses lowering 对 failed custom result 优先发送
完整格式化 runtime output,不再只发送裸 error。
定向与 owner 验证:`agent-runtime 202/202``model-provider 243/243``tool-runtime 327/327` 全部通过;
`cargo check -p agent-runtime --lib` 通过。`npm run test:rust:related -- <本刀 13 个 current/反向依赖 crate 的路径>`
最终退出 `0`,其中 `app-server 1646/1646` 通过,仅有既有 test helper `dead_code` warning;拆分后
`cargo fmt --all -- --check``git diff --check` 均通过。新增回归覆盖 session handle 四操作、provider
default/non-default limits、yield/terminal/status、`exec`/`wait` 广告、wait resampling、mixed function/custom
结果顺序、session error recovery、无 session/非 exec fail closed、取消终止 active cell,以及 failed custom
output 的官方 Responses lowering。
production fail-closed 复核:current turn 在 `lime-agent::current_provider_turn` 中只从
`current_tool_step_snapshot_source` 捕获工具快照,该 source 只调用
`RuntimeToolStepSnapshot::with_tool_metadata(...)`;全仓非测试 Rust 源码没有
`with_code_mode_session(...)` 调用。因此 production sampling snapshot 不持有 executable handle
`provider_turn``exec`/`wait` 成组广告分支保持关闭。
聚合门禁:`npm run governance:legacy-report` 通过,扫描 `2120` 个文件,零引用候选、分类漂移候选、边界违规
均为 `0``npm run smoke:agent-runtime-current-fixture` 通过;该门禁重建 App Server sidecar,并实际经过
Electron、preload/IPC、App Server、runtime/read model 与 GUI,覆盖历史恢复、流式终态、停止/继续、approval、
Plan、Skills、MCP、媒体引用、Coding Workbench 等 current fixture 场景;结果明确
`liveProviderUsed=false`,因此属于 Gate B external fixture 证据,不冒充 live provider 或 production CodeMode host。
分类:transport-neutral session/provider/delegate contract、Agent loop adapter、测试注入下的 `exec/wait` executable
boundary 与 custom failure lowering 为 `current foundation`production thread-owned session registry、隔离 JS/V8
host、nested delegate dispatch、notification/cell-close implementation、thread interrupt/shutdown owner、canonical
CodeCell/Tool Item 与 GUI projection 为 `planned`;无 `compat`/`deprecated`,无新增 mock production fallback。
架构确认:confirmed;责任开发者 root2026-08-11。确认范围包括
`tool-runtime -> agent-runtime -> model-provider` 依赖方向、Desktop/TUI 分界、StartedCell 生命周期、取消/终止、
mixed-call transcript 顺序和 production availability fail-closed。第八刀完成度为 `100%`P1-01 不得据此标记完成。
下一刀建立按 canonical Thread identity 持有的 lazy CodeMode servicesession actor interrupt 终止 active cells
shutdown 关闭 sessionprovider availability 未就绪时不创建 owner。随后才能接真实隔离 host 和 nested dispatch
在这两项完成前继续禁止 production 注入 handle 或对外宣称 CodeMode 可用。
### 2026-08-11 CodeMode thread-owned lazy service
状态:`completed / current foundation`(第九刀;P1-01 总项仍未关闭)。
主目标:把 CodeMode session 生命周期收进 canonical Thread 对应的 session actor,建立 lazy create、active cell
interrupt 与 thread shutdown 边界;本刀不接真实隔离 host、nested tool dispatch、canonical Item 或 production
sampling snapshot 注入。
窄写集:`agent-runtime::code_mode``agent-runtime::session_loop` actor/resource/context、App Server 中创建 session
actor 的 current 调用点、对应 owner tests,以及本架构/执行计划。退出条件:session actor 创建必须同时绑定
`session_id + thread_id` 且拒绝 identity 漂移;provider availability 失败不得创建 service/runtime session;首次
CodeMode operation 才能 lazy createinterrupt 必须 terminate active cellsshutdown 必须关闭已初始化 session,未初始化
service 的 shutdown 不得反向触发 create。
完成结果:新增 `agent-runtime::code_mode::RuntimeCodeModeServiceFactory` 与 per-thread
`RuntimeCodeModeService`availability 和 delegate factory 任一失败都使 actor 不持有 CodeMode handleruntime session
`OnceCell` lazy createactive cell 由 service 精确跟踪。`RuntimeSessionResources` 现在由 actor 唯一持有 canonical
`thread_id` 与可选 servicetask context/input handle 只投影该资源。actor replace/interrupt 先终止 active cells,再
结束当前 task;显式 shutdown、command channel 关闭和 registry shutdown 都关闭已初始化 runtime session,未使用的
service 不会启动 provider session。
`RuntimeSessionRegistry::get_or_create` 已直接切换为 `(session_id, thread_id)`,空 identity 与同 session 的 thread
漂移均 fail closed;旧单参数 API 已删除。App Server compact、turn submit/steer/action、session operation 与 shell 等
current actor 创建点全部传入 stored canonical thread identity。production `RuntimeCore` 仍使用
`RuntimeSessionRegistry::default()`,没有注入 CodeMode factory;因此 production snapshot 不持有 handle`exec/wait`
广告保持关闭。
验证结果:第九刀 lifecycle 定向覆盖 availability fail-closed、stable identity、unused shutdown、active cell
interrupt 与 session shutdown;完整 `agent-runtime 205/205``app-server 1646/1646` 已通过。`npm run
test:rust:related -- <第九刀 agent-runtime/App Server 路径>` 扩展到 `agent-runtime`、`app-server`、`lime-agent`、
`lime-scheduler``lime-server` 并退出 `0`,仅有既有 App Server test helper `dead_code` warning。`cargo fmt --all
-- --check` 与 `git diff --check` 通过。此前同一实现状态下的 `npm run smoke:agent-runtime-current-fixture` 已通过真实
Electron/preload/IPC/App Server/runtime/read model/GUI 聚合场景,`liveProviderUsed=false`;它只证明 Desktop current
主链无回归,不冒充 production CodeMode host 或 live provider 证据。
文件边界:`session_loop` 的 registry/resources 已从 actor 拆到短领域模块;`input_queue.rs` 当前 856 行,仅增加
资源引用和 context getter,未继续堆叠 lifecycle 逻辑。下一次修改 input queue 业务规则前,应把 task context/input
handle 与 pending queue 分离到独立模块,使非生成文件回落到 800 行以内。
分类:thread-owned service、canonical actor identity、lazy create、active-cell interrupt 与 shutdown 为
`current foundation`production isolated host、nested delegate dispatch、notification/cell-close implementation、
canonical CodeCell/Tool Item 和 GUI projection 为 `planned`;无 `compat``deprecated`,旧单参数 actor API 为
`dead / deleted / forbidden-to-restore`。第九刀完成度为 `100%`P1-01 不得据此标记完成。
下一刀进入真实隔离 JS/V8 host 与 nested delegate;在两者完成、canonical lifecycle 接线并通过 Desktop Gate B 前,
继续禁止 production 注入 CodeMode factory/handle 或对外宣称 CodeMode 可用。多模型、多模态 catalog/readiness 与
sampling/media lowering 继续归 Grok-aligned `model-provider`,本刀未改变这些 owner。
### 2026-08-12 CodeMode per-cell nested dispatch
状态:`completed / current foundation`(第十刀;P1-01 总项仍未关闭)。
主目标:把 CodeMode runtime 发起的 nested tool callback 精确路由到启动该 cell 的 frozen sampling-step executor
保证它继续复用普通工具的权限、取消、lifecycle 与 output contract;本刀不注入 production factory,也不以 synthetic
session 冒充隔离 JavaScript host。
窄写集:`tool-runtime::code_mode` 的 transport-neutral per-cell delegate extension、`agent-runtime::code_mode`
route/gate 生命周期、`agent-runtime::provider_turn::code_mode` 的 frozen executor delegate、对应 owner tests,以及本
架构/执行计划。未修改超过 800 行的 `session_loop/input_queue.rs`,也未新增 crate、脚本或 parallel runtime owner。
完成结果:session contract 新增 `execute_with_delegate`,默认保持普通 `execute` 语义;thread-owned service 为每个
started cell 注册独立 delegate route,并用 watch gate 解决 runtime callback 先于 `StartedCell` 返回的竞态。terminal
initial response、wait terminal/missing、terminate、actor interrupt、cell close 与 shutdown 都清理 route/gatefallback
只属于 session factory 创建时冻结的 thread delegate,不读取最近 active turn。
provider-turn nested delegate 按 `RuntimeCodeModeTool.global_name` 在当前 sampling step 的 frozen 工具集合中查找,拒绝
未启用工具与 `exec`/`wait` 递归调用;调用继续经过同一个 `RuntimeToolExecutorHandle.bind(...).execute_call(...)`,并
携带 canonical turn/session、working directory、turn context、cancellation token 与 lifecycle emitter。structured output
优先回传给 JS,失败保持 error;没有建立第二套权限、approval 或 handler registry。
验证结果:新增 service race 回归证明 cell route 在 nested callback dispatch 前完成绑定;provider-turn 回归证明 nested
`read` 只执行冻结 executor 一次,并发出同一普通 lifecycle 的 Started/Completed。完整 `agent-runtime 207/207`
`app-server 1646/1646` 通过;`npm run test:rust:related -- <第十刀四个 owner 路径>` 扩展到
`agent-runtime``app-server``lime-agent``lime-mcp``lime-scheduler``lime-server``tool-runtime` 并退出
`0`,其中 `tool-runtime 327/327`,仅有既有 App Server test helper `dead_code` warning。`cargo fmt
--manifest-path lime-rs/Cargo.toml --all` 已应用,本轮继续以 fmt check、diff check 与治理扫描收尾。
分类:per-cell delegate contract、route/gate 生命周期与 frozen normal-tool dispatch 为 `current foundation`production
isolated JS/V8 host、notify 注入、canonical CodeCell/Tool Item/GUI projection 与 App Server factory 注入为 `planned`
`compat`/`deprecated`,未增加 mock production fallback。第十刀完成度为 `100%`P1-01 不得据此标记完成。
下一刀先把 `exec`/`wait` 与 notify 接入 canonical Tool/Thread/Turn/Item 生命周期,继续保持 production unavailable
随后再引入独立 V8 host。新增 `v8`/ICU 及 host binary 属于核心依赖与打包变更,必须单独确认并同步 Cargo/Forge、
macOS/Windows packaging 和 Gate B 证据。多模型、多模态 owner 继续是 Grok-aligned `model-provider`
### 2026-08-12 CodeMode lifecycle 与 notify Desktop projection
状态:`completed / current foundation`(第十一刀;P1-01 总项仍未关闭)。
主目标:让 CodeMode control tools 不再绕过 canonical Tool lifecycle,并把 nested `notify` 接入 Lime Desktop 已有的
增量事件投影;本刀不注入 production factory,不把 GUI notification 冒充 provider transcript,也不建立平行
CodeCell 存储或第二套 lifecycle owner。
窄写集:`tool-runtime::tool_lifecycle` 的 transport-neutral output-delta extension、
`agent-runtime::provider_turn::code_mode` 的 outer control-tool lifecycle/notify delegate、Lime Agent current provider-turn
emitter、对应 owner tests,以及本架构/执行计划。没有修改 Electron/JSON-RPC schema、GUI 文案、provider wire、
workspace manifest 或核心依赖。
完成结果:`exec``wait` 现在都以原 provider call identity 发出 canonical Started/Completed lifecycle;完成输出保留
`code_mode_cell_id``code_mode_output_status``handler_executed=true` 与格式化 partial outputCodeMode failure 不再被
普通 failure normalization 吞掉正文。nested 普通工具仍使用自己的 lifecycleouter `exec` 因此形成
`exec started -> nested started/completed -> exec completed` 的稳定顺序;`wait` 继续复用同一 thread-owned session。
`ToolLifecycleEmitter` 新增默认 no-op 的 `emit_output_delta` host capability,避免 transport-neutral owner 依赖 Agent
protocol。provider-turn delegate 把非空、未取消的 `notify` 投影为 `ToolOutputDeltaEvent`,绑定 outer `exec` call id、
canonical turn id、cell id、`tool_name=exec``notification_kind=code_mode_notify`
`CurrentTurnToolLifecycleEmitter` 再把它接入既有 `AgentEvent::ToolOutputDelta` Desktop host event pipeline。该事件当前只供
App Server/GUI 消费;同一 notify 同时进入本 sampling step 的下一次 provider request,作为 outer call id 对应的
`custom_tool_call_output`,排在最终 exec output 前,补齐 Codex active-turn transcript 语义。该注入不经过
`RuntimeSessionInputHandle`,不创建 durable Item;独立 CodeCell Item/cell-close projection 仍未实现。
验证结果:notify correlation、outer `exec/wait` lifecycle、outer+nested lifecycle 顺序和 Agent output-delta projection
定向回归通过;完整 `agent-runtime 207/207``lime-agent 255/255` 通过。`npm run test:rust:related --
<第十一刀五个 owner 路径>` 扩展到 `agent-runtime 207/207`、`app-server 1646/1646`、`lime-agent 255/255`、
`lime-mcp 160/160``lime-scheduler 24/24``lime-server 119/119``tool-runtime 327/327`,完整退出 `0`
`npm run governance:legacy-report` 扫描 `2120` 个 current 文件,零引用候选、分类漂移和边界违规均为 `0`
`npm run smoke:agent-runtime-current-fixture` 在重建本轮 App Server sidecar 后通过真实 Electron/preload/IPC/
App Server/runtime/read model/GUI 聚合场景,`liveProviderUsed=false`;它证明 Desktop current 主链无回归,不冒充
production CodeMode host 或 live provider 证据。`cargo fmt --manifest-path lime-rs/Cargo.toml --all -- --check`
`git diff --check` 作为最终收尾门禁。
分类:outer control-tool lifecycle、普通 Tool Item projection 与 notify Desktop output-delta 为 `current foundation`
本 sampling step 的 notify provider-transcript projection 也为 `current foundation`production isolated JS/V8 host、
CodeCell trace/evidence owner、App Server factory 注入和 CodeMode 专项 Gate B 为 `planned`。公开 App Server ThreadItem
不新增 CodeCell variant;无 `compat`/`deprecated`
未增加 mock production fallback。
第十一刀完成度为 `100%`,P1-01 不得据此标记完成,production 继续不广告 `exec/wait`
下一刀是 production isolated session provider,但它需要新增 V8/ICU 核心依赖和 Desktop host 打包边界;按仓库高风险
包管理规则,必须取得明确确认后再修改 Cargo/lock/Forge。获批前最有价值的无依赖工作是核对 Codex rollout-trace 的
CodeCell 事实与 Lime current owner;不得把 CodeCell 伪造成 per-turn `Tool` Item 或新的公开 ThreadItem。late-notify
取消/终态守卫可独立落在 thread-owned runtime route,不改变 Thread/Turn/Item owner。
### 2026-08-12 CodeMode late-notify terminal guard
状态:`completed / current foundation`(第十二刀;P1-01 总项仍未关闭)。
主目标:对齐 Codex cell-close 后的终态路由语义,禁止迟到的 nested `invoke`/`notify` 重新创建空 dispatch gate、永久等待、
回落到 session fallback delegate,或继续写入 Desktop/provider transcript;本刀不新增 JS host、协议 schema、依赖或 durable
CodeCell Item。
窄写集:`agent-runtime::code_mode` 的 closed-cell route guard 与回归、`agent-runtime::provider_turn::code_mode` 的 nested
delegate 原子终态守卫、架构说明与本执行计划。terminal response、terminate、actor interrupt、host `cell_closed`
shutdown 都将 cell 标记为 closed;同 cell id 重新启动时先清除旧标记,再安装新的 route。late dispatch 在 route lookup
入口 fail closed,避免重新创建 watch gateprovider-turn delegate 在工具执行前后以及 notify 投影/ transcript 注入前检查
closed/cancelled,避免已关闭 cell 的输出越过终态边界。
验证:`agent-runtime` CodeMode 定向测试 `5/5`(含 late notify 不等待回归),完整受影响矩阵 `agent-runtime 208/208`
`app-server 1646/1646``lime-agent 255/255``lime-scheduler 24/24``lime-server 119/119`,以及此前
`lime-mcp 160/160``tool-runtime 327/327` 与真实 Desktop fixture Gate B 均已通过;`cargo fmt --all -- --check`
`npm run governance:legacy-report`(2120 文件,0 零引用候选/0 分类漂移/0 边界违规)与 `git diff --check` 均通过。独立 CodeCell
terminal owner、production isolated host/factory 和 CodeMode 专项 Gate B 仍为 `planned`,无新增 `compat`/`deprecated`
### 2026-08-12 CodeMode CodeCell projection scope correction
状态:`completed / current governance`(撤回临时 per-turn CodeCell Item projection)。
对照 Codex 后确认,`app-server-protocol` 的公开 `ThreadItem` 没有 `CodeCell` variantCodeCell 生命周期由
`rollout-trace::CodeCellTraceContext` 以 thread/turn/call/runtime-cell identity 写入内部 trace,并由 reducer 关联
model-visible `CustomToolCall`,不进入公开 GUI ThreadItem。Lime 当前没有 rollout-trace 等价 ownerApp Server `RuntimeEvent`
是产品事件链,不能作为 runtime trace 的隐式存储或 GUI 旁路。因此已删除把 CodeCell started/closed 伪造成 per-turn
`Tool` Item 的临时实现与正向测试,保留 outer `exec`/`wait` canonical lifecycle、notify Desktop delta/provider transcript
projection,以及第十二刀 closed-cell late-notify guard。
分类:outer Tool lifecycle、notify projection、closed-cell route guard 为 `current foundation`CodeCell trace owner、生产
isolated session provider/factory、CodeMode 专项 Gate B 为 `planned / alignment-open`;伪造的 per-turn CodeCell Item 为
`dead / deleted / forbidden-to-restore`;无 `compat`/`deprecated`。当前不新增 RuntimeEvent、ThreadItem union、GUI card 或
第二套 trace store。
验证:`agent-runtime 208/208``lime-agent 255/255``tool-runtime 327/327``cargo fmt --all -- --check`
`npm run governance:legacy-report`2120 文件,0/0/0)与 `git diff --check` 通过;`rg` 未发现
`CodeModeCellLifecycle*`/`emit_code_mode_cell` 回流。撤回后重新执行 `npm run smoke:agent-runtime-current-fixture`
真实 Electron/preload/IPC/App Server/runtime/read model/GUI 聚合场景完整通过,`liveProviderUsed=false`;该证据证明
current Desktop 主链无回归,不冒充 production CodeMode host 或 live provider。
下一步:取得 V8/ICU 隔离 host、Cargo/lock 与 Electron Forge 打包边界的明确确认后,先接 production session provider/factory
CodeCell trace 只有在出现真实 consumer 和 owner 后才实现,不得以 GUI Item 或 RuntimeEvent 临时承接。
### 2026-08-12 CodeMode production in-process V8 与 factory
状态:`completed / current foundation`(第十三刀;P1-01 总项仍未关闭)。
主目标:在 Lime Desktop current App Server sidecar 中接入真实 sandbox-enabled V8 session provider,把 production
Runtime backend factory、Grok-aligned model `tool_mode`、resolved provider `custom_tools` capability 与 thread-owned
executable session 三重门禁接成唯一采样链,并建立可重复、校验失败即停止的 V8 编译产物供应链。本刀不恢复公开
CodeCell Item,不把 Electron renderer/TUI/系统 Node 当 JS host,也不把 in-process isolate 宣称为 standalone host。
窄写集:`tool-runtime::code_mode::v8``agent-runtime::code_mode/session_config`、Lime Agent current sampling snapshot、
App Server runtime factory/model registry route、`core/services/model-provider` model metadata、Cargo manifest/lock、
`scripts/lib/rusty-v8-artifacts.mjs` 与现有 Rust/sidecar 构建入口、Windows workflow,以及架构/本执行计划。Forge
resources 仍只包含静态链接后的 `app-server` sidecarV8 archive/binding 不作为运行时资源复制。
退出条件:每个 cell 使用 fresh sandbox V8 isolate,支持 nested tools、notify、timer、yield/wait/terminate、store/load
与 pragma limits;未知 pragma/tool mode、缺 provider capability、缺 session 与 `CodeModeOnly` 不可用均 fail closed
只有 `code_mode|code_mode_only + custom_tools + executable session` 才附着 session 并广告 `exec/wait`。所有 current
Rust 与 Electron sidecar 构建入口从 Cargo.lock 解析精确 V8 version,只消费 Codex `ptrcomp_sandbox_release` 资产并
验证 archive/binding 两项 SHA-256。最低验证为 V8/agent/app-server 定向与相关测试、artifact/sidecar tests、真实
Electron CodeMode Gate B、scripts/legacy/version 治理、rustfmt 与 diff check。
当前完成:V8/ICU process 初始化、fresh isolate cell runtime、session store、nested callback、notify、timer、
yield/wait/terminate/cancel、sandbox verification 和 exec pragma 已落地;production factory 只注入 current runtime
backendmock/external/unavailable 不注入。model registry/direct provider config 只接受 `direct / code_mode /
code_mode_only`,未知值回落 Directsnapshot 三重门禁已补四场景回归。V8 内部无法到达 Lime nested-call contract 的
`ToolKind/CodeModeToolKind` 传播链已按 dead 删除。构建 helper 已在默认 cache 与显式 override 两种路径验证 Codex
release checksumRust layer、local CI、dev/rebuild sidecar、Electron assets build 和 Windows workflow 已接入。
当前验证:`cargo check -p app-server --lib` 无本刀 warningmodel metadata、tool-mode normalization 与三重门禁新增
定向测试 `3/3` 通过;artifact/sidecar/Rust runner Vitest `36/36` 通过;真实无 override 下载与显式 override
checksum 校验均通过。遗漏的 provider model tool-mode ingestion `1/1`、agent-runtime custom exec `3/3` 与 mixed
function/custom result order `1/1` 已补跑。Responses fixture 与 Gate B 结构回归 `19/19` 通过。
专项 `npm run smoke:code-mode-electron-gate-b` 已通过,evidence 位于
`.lime/qc/gui-evidence/code-mode-electron-gate-b/code-mode-electron-gate-b-summary.json`,截图位于同目录 PNG。该 Gate B
证明真实 Electron/preload/IPC、`app_server_handle_json_lines`、runtime backend、official-host Responses route、production
V8 factory、custom `exec` 输出回采样、canonical `dynamicToolCall` completed 与 GUI 可见终态;Provider 请求 Host 仍为
`api.openai.com`,只由标准 `HTTP_PROXY` 路由到受控 fixture。公开 Item 类型为
`userMessage/dynamicToolCall/agentMessage`,没有 `CodeCell`,且 mock fallback、invoke/console/page/provider error 均为零。
它不证明 live OpenAI、standalone OS-process sandbox 或 macOS/Windows packaged parity。
收尾门禁:`npm run test:contracts` 通过 301 项 App Server client contract,并通过 protocol drift、command、harness、
modality、scripts、Electron release workflow 与 docs boundary`npm run verify:gui-smoke` 通过真实 Electron Host、preload、
App Server 初始化、Claw shell 与设置页 smoke`npm run smoke:agent-runtime-current-fixture` 完整通过当前聚合 Electron
场景,`liveProviderUsed=false``npm run governance:scripts``npm run governance:legacy-report`2120 个 current 文件,
0 零引用候选 / 0 分类漂移 / 0 边界违规)、`npm run verify:app-version``cargo fmt --all -- --check`
`git diff --check` 均通过。
隔离边界:当前 provider 与 session actor 位于 App Server sidecar 进程内,V8 sandbox + fresh isolate 只提供 JS
内存边界,不提供 Codex 最新 standalone CodeMode host 的进程故障和 OS 资源隔离。该差距与 thread-owned CodeCell
trace/evidence owner 继续保持 `alignment-open`;在两者关闭前不得宣称 CodeMode 全面对齐完成。
模型目录审计:Codex 当前有 8 个 `apply_patch_tool_type=freeform` 模型,Lime 只对
`openai/gpt-5.2` 存在精确 canonical 映射;`gpt-5.6-sol/terra/luna``gpt-5.5``gpt-5.4/mini`
`codex-auto-review` 尚不在 Grok-aligned catalog。该差距不阻塞本刀 deterministic Gate B,但继续属于多模型控制面
`alignment-open`,只能在 `model-provider` catalog/capability/readiness owner 中补齐,不能按 provider 名称放宽。
### 2026-08-12 CodeMode standalone process host
状态:`completed / current`(第十四刀;P1-01 仅剩 CodeCell trace/evidence owner 未关闭)。
主目标:按 Codex `ProcessOwnedCodeModeSessionProvider -> code-mode-host` 的本地 stdio host 边界,把 production V8 执行
从 App Server sidecar 进程迁到独立 OS 进程。Lime 只实现 Desktop 当前需要的 process-owned stdio transport,不引入
Codex TUI、remote WebSocket/gRPC 控制面或第二套 product runtime;迁移完成后 production factory 直接替换为 process
provider,不保留 in-process fallback。
窄写集:`tool-runtime::code_mode` 的 length-prefixed protocol/process client/host、`code-mode-host` bin、
`agent-runtime::code_mode` production factory、现有 App Server/Electron sidecar build 与 resource packaging、CodeMode
专项 Gate B、对应 Rust/Node tests,以及架构/本计划。现有 V8 runtime 只作为 host 内部执行 ownerApp Server 只持有
process client;不新增 crate、公开 ThreadItem、Renderer backend、compat wrapper 或 mock production fallback。
退出条件:protocol V1 handshake/session open/execute initial response/wait/terminate/shutdown、nested tool/notify/cancel/
cell-close correlation、frame size与 pending request 上限均 fail closedhost 缺失或崩溃不得回退 in-process,必须让当前
session 显式失败且不拖垮 App Server。dev、Rust layer、Electron asset、Forge 和 Windows CI 必须成组构建/校验两个
sidecar binary。专项 Electron Gate B 必须额外证明独立 `code-mode-host` PID、App Server PID 不同、完整 custom exec
回采样与 GUI terminal;随后再运行 contracts、GUI/current fixture、scripts/legacy/version、rustfmt 与 diff 门禁。
完成结果:production factory 已只使用 `ProcessCodeModeSessionProvider`V8 provider 收敛为 host 内部 owner,没有
App Server in-process fallback。protocol V1 覆盖握手、session open/execute 两阶段 response、wait/terminate/shutdown、
nested tool/notify/cancel/cell-close、64 MiB frame、1024 in-flight/pending delegate 上限与连接失败传播。host 路径只从
App Server/测试二进制同目录或显式测试环境解析,缺失时 availability fail closed。
构建与资源结果:dev/Electron assets 在一次 Cargo invocation 中成组构建 `app-server``code-mode-host`;二者复制到
`dist-electron/app-server/<platform>/`manifest 分别记录 `sha256`/`codeModeHostSha256`packaged verifier 强制校验。
macOS arm64 实际资源为 `app-server 252775064 bytes``code-mode-host 66597536 bytes`,均为 `0755` 且双 SHA 复算一致。
验证结果:标准 sandbox V8 环境下 App Server/host 双 binary Cargo check 无 warningprocess Rust tests `6/6`
artifact/sidecar/assets/fixture/package/Gate B script tests `56/56`Rust related 反向依赖矩阵、`npm run test:contracts`
303 项)、rustfmt 与 diff check 通过。Windows quality 现在显式以同一次 Cargo invocation 检查 `app-server`
`code-mode-host`client contract guard 固定该命令;Windows test package 继续由 `electron:build` 成组构建并由 packaged
resource verifier 校验双 binary。专项 `npm run smoke:code-mode-electron-gate-b` 重新通过,evidence thread
`019ff3ca-7f26-71d2-be81-6a16b7895515`Electron/App Server/host PID 为 `44199/44203/44521`host parent PID 为
`44203`。17 项 Gate B assertion 全通过,custom exec 两次 Responses 回采样、canonical `dynamicToolCall`、GUI final text、
IPC trace 均成立,production mock/invoke/console/page/provider error 为零。该证据不冒充 live OpenAI 或 Windows packaged
parity;后者由 release Windows runner 继续验证。
+32 -4
View File
@@ -1,13 +1,13 @@
# Lime v1.125.0 发布执行计划
状态:windows-gate-b-verified / tag-fixed
日期:2026-08-09
状态:release-published-at-4ee8d3577 / warning-fix-local-validated / retag-confirmation-pending / windows-runner-pending
日期:2026-08-11
目标版本:`1.125.0`
目标 tag`v1.125.0`
## 主目标
在不移动或覆盖已存在的 `v1.125.0` tag 的前提下,发布当前工作树中的 Agent runtime、App Server/protocol、Plugin v3、GUI、文档与质量治理改动。
`v1.125.0` 收敛到完整且通过 Windows 实机门禁的 release candidate,发布 Agent runtime、App Server/protocol、Plugin v3、GUI、文档与质量治理改动;已发布 tag 如需重定位,必须先完成新候选验证并取得单独危险操作确认
## Release Candidate
@@ -82,7 +82,7 @@
## Windows target session 投影修复
状态:`fix-validated-locally / windows-runner-pending`
状态:`fix-validated-locally / retag-confirmation-pending / windows-runner-pending`
- runner `31347609969` 使用完整 SHA `22a96e5e1df9b771120ba6ea26ab7f562d5eafcd`Windows path contract、sherpa runtime、Electron Windows package、N-1 Squirrel 安装 smoke 均通过,Plugin Gate B 仍在等待 `mcp_elicitation` 90 秒后失败。
- 失败证据确认 enabled canonical turn 已 `completed`、thread 已 `idle`items 只有 user message 与空 agent messageenabled provider 只有 `/v1/models`,没有 `/v1/chat/completions`,而 disabled boundary provider 收到后续请求。失败截图标题已进入 enabled 会话,但正文仍显示 disabled boundary 结果,证明 renderer 的目标 session 与提交 thread 发生漂移。
@@ -211,3 +211,31 @@
- 修复提交 `8738c8a623a763c50267d208facb4667ca9736c6` 已推送到 `origin/main`。Windows runner [`31444743300`](https://github.com/limecloud/lime/actions/runs/31444743300) 与 Job `93636406363` 均为 `success`Windows path contract、sherpa runtime、Electron x64 Squirrel 构建、N-1 installer 下载、真实安装/升级 smoke 和 installed Plugin Gate B 全部通过。
- Windows Gate B Artifact `9084355493``lime-windows-agent-plugin-gate-b-evidence`)已下载并审计:`ok=true``appVersion=1.125.0``platform=win32``arch=x64`、packaged/preload/App Server JSONL 真链成立、provider request `2`、MCP ledger accepted、reload/cold restore 通过、resource read/HTML load `3/3`、Electron launch `2`、卸载/installed projection 清理/历史恢复全部通过;卸载后没有重启 MCP runtime、重跑 provider 或 tool`productionMockFallbackHitCount=0``missingRequiredMethods=[]``legacyMcpCommandsSeen=[]``consoleErrors=[]`
- 退出条件已满足。Windows runner 检出的产品修复 SHA 为 `8738c8a623a763c50267d208facb4667ca9736c6`;本地与远端 `v1.125.0` tag 均继续固定在 `8647d18fa358e3a9c86e520348d39e4b3eba6041`,未移动或覆盖。
## 标准 Release run `31449908543`
状态:`complete / published-at-4ee8d3577`
- 用户确认后,本地与远端 `v1.125.0` tag 以及 GitHub Release target 已重定位到 `4ee8d3577e3d30df3a310450aca01320139a28d1`
- 标准 Release run [`31449908543`](https://github.com/limecloud/lime/actions/runs/31449908543) 全部成功:macOS arm64/x64、Windows x64 Electron 构建,Windows installed Squirrel smokeElectron/CLI asset 发布和 Cloudflare R2 updater 发布均通过。
- GitHub Release 已有 `13` 个真实资产:Windows Setup/full nupkg、macOS arm64/x64 DMG/ZIP/updater metadata,以及 macOS arm64/x64、Linux x64、Windows x64 CLI 包。
- 该 run 的 Windows 编译日志暴露跨平台 unused/dead-code warning;现有资产对应 warning 修复前的 `4ee8d3577`,不能作为下一候选的 warning-free 证据。
## Windows warning 与 Codex 对齐修复
状态:`fix-validated-locally / retag-confirmation-pending / windows-runner-pending`
- Codex 对照结论:`app-server-transport` Unix socket 与 MCP `OsStr` 仅缺少精确平台导入边界,分类为 `current / cfg-fix`;没有缺失 transport 或 MCP 执行实现。
- `tool-runtime` 确有 Windows 功能遗漏:Lime 公开了 `login` 参数却在 PowerShell 分支始终添加 `-NoProfile`。按 Codex `Shell::derive_exec_args` 对齐为 `login=false` 添加 `-NoProfile``login=true` 加载 profile,同时保留非交互执行参数,并补 Windows 定向回归。
- Skills 的插件版 snapshot、selection、prompt context 已从 turn request、telemetry 与 runtime enable 主链消费;三个 warning 符号只是无插件测试包装层,分类为 `dead`,已删除并让测试直接调用插件版 current owner。
- workflow 审计自由函数已被 turn execution/control/read-model 测试真实消费;未使用的 `RuntimeCore` 同名方法是重复入口,分类为 `dead` 并删除。Codex 没有 Lime 预留的 `workflow.tool/connector/hook/artifact` 事件校验面,仓库也无消费者,整组分类为 `dead` 并删除;保留在用的 run/step current 事件。
- 定向 `rustfmt --check`、workspace `cargo fmt --all -- --check``git diff --check` 通过;host `cargo check --lib -p app-server-transport -p tool-runtime -p lime-mcp -p app-server` 通过且没有 warningWindows target `app-server-transport` check 通过且没有 warning。
- owner 测试通过:`app-server-transport 18/18``lime-mcp environment 7/7``tool-runtime unified_exec 10/10``agent_skills_context 13/13``runtime::workflow 13/13`
- `npm run test:rust:related -- <本轮 7 个 Rust 路径>` 最终退出码为 `0`,全部 `11` 个相关/反向依赖 crate 通过;此前并行 `provider_turn` 回归失败已由其 owner 收口,不再阻塞候选。
- `npm run typecheck``npm run verify:app-version``npm run governance:legacy-report` 通过;版本事实源保持 `1.125.0`,治理报告为零候选、零漂移、零违规。
- `npm run test:contracts` 通过:协议生成零漂移、App Server client `301 checks`、命令/fixture/modality/scripts/Electron release/docs 边界全部通过。
- `npm run smoke:agent-runtime-current-fixture` 通过:覆盖真实 Electron 的 Thread/Turn/Item、工具终态、approval、Skills、MCP、Workbench 与历史恢复;`liveProviderUsed=false`
- `npm run verify:gui-smoke` 通过;Electron/App Server evidence 为 `standalone-shell-01-20260811030633-6629`App Server 版本 `1.125.0`
- `app-server/src/runtime_backend/tests.rs` 仍有一个只在 lib test 构建出现的测试 helper dead-code warning,不属于用户报告的 release build warning,也不进入标准 Release 编译路径;本轮不跨写集夹写,后续由该测试 owner 独立收口。
- 本机其余 Windows target check 被 `ring``zstd-sys``bzip2-sys` 缺少 MSVC C 标准头阻断;最终 warning-free 证据必须来自新 release candidate 的真实 Windows runner。
- 下一步:获得单独危险操作确认后提交全部 release candidate、强制重定位 `v1.125.0` 并重跑标准 Release,最终复核 GitHub Release 资产时间与 Windows 日志。
@@ -0,0 +1,55 @@
# Lime v1.126.0 发布执行计划
状态:release-candidate-ready
日期:2026-08-12
目标版本:`1.126.0`
目标 tag`v1.126.0`
## 主目标
发布 Code Mode process-owned runtime、Agent runtime/session loop、provider canonical transport、App Server read model、
Electron 双 sidecar 资源链、协议/GUI 投影以及对应治理文档;完成 release commit、tag、main/tag 推送与远端复核。
## Release Candidate
- `release metadata``package.json``packages/lime-cli-npm/package.json``lime-rs/Cargo.toml`
`lime-rs/Cargo.lock``RELEASE_NOTES.md``RELEASE_NOTES.en.md`、本计划。
- `candidate changes`:用户确认当前工作树全部纳入,包括已暂存、未暂存和未跟踪的 Rust、TypeScript、脚本、CI、
测试、架构、治理与执行计划文件。
- `excluded changes`:无。
## 退出条件
- 版本事实源与双语单页 release notes 统一到 `1.126.0`,目标 tag 本地/远端不存在。
- Code Mode production 只走 `ProcessCodeModeSessionProvider -> code-mode-host -> sandbox V8`,无 in-process fallback。
- dev、Electron assets、Forge/Windows 构建成组携带并校验 `app-server``code-mode-host`
- `npm run verify:app-version``npm run typecheck`、Rust related、contracts 与 GUI smoke 通过。
- Code Mode Electron Gate B 证明真实 Electron/App Server/standalone host PID、custom exec 回采样与 GUI terminal。
- 完成 release commit、`v1.126.0` tag、main/tag 推送和远端复核。
## 验证记录
- `npm run verify:app-version`:通过,版本事实源一致为 `1.126.0`
- `npm run typecheck`:通过(发布 metadata 更新后复跑)。
- `npm run test:contracts`:通过;App Server client `301` 项检查及 command、harness、modality、scripts、Electron release workflow、docs boundary 子门禁全部通过。
- `npm run test:rust:related -- lime-rs/crates/agent-runtime lime-rs/crates/agent lime-rs/crates/app-server lime-rs/crates/model-provider lime-rs/crates/runtime-core lime-rs/crates/tool-runtime lime-rs/crates/services`:通过;相关 owner 与反向依赖 crate 单测无失败,存在一个既有测试辅助函数 dead-code warning。
- `npm run smoke:agent-runtime-current-fixture`:通过;history/cache、turn terminal、approval、steer、Plan、Skills、MCP、媒体、Workbench 等 current Electron fixture 闭环通过,`liveProviderUsed=false`
- `npm run verify:gui-smoke`:通过;真实 Electron/App Server `1.126.0` 初始化、工作台 reload 与 memory settings 可见,evidence result 为 `pass`
- `npm run governance:legacy-report`:通过;扫描 `2120` 个源码文件,分类漂移 `0`、边界违规 `0`
- standalone Code Mode Cargo check:通过,无 warning。
- Code Mode process Rust tests`4/4` 通过。
- sidecar/assets/fixture/Gate B script tests`50/50` 通过。
- `npm run electron:build:app-server-assets`:通过;macOS arm64 双 sidecar 为 `0755`manifest 双 SHA 复算一致。
- `npm run smoke:code-mode-electron-gate-b`:通过;thread `019ff39f-2ff2-7bb3-b579-cf1dc86ac042`Electron/App Server/
host PID 为 `10485/10494/10926`host parent PID 为 `10494`17 项 assertion 全通过。
- `cargo fmt --all -- --check``git diff --check`:通过。
## 待执行门禁
- 全候选 staged 复核、危险操作确认、commit/tag/push 与远端状态复核。
- Windows 双 sidecar 由 CI 的 Windows runner 执行;本地 macOS Gate B 不冒充 Windows packaged parity。
## 架构确认
架构影响:重大。已更新 `internal/aiprompts/architecture.md` 第 44 节,确认 production process owner、双 sidecar
构建/资源完整性、fail-closed 边界与 Gate B 证据。责任开发者:root,确认日期:2026-08-12。
@@ -226,11 +226,37 @@ Gemini、Bedrock、Fal、Vertex 等可能“路由成功、wire 错误”。
### P1-01Tool lifecycle 与 provider tool repair
Codex tools 还包含 `ToolExposure::{Direct, Deferred, Hidden}`、ToolSearch/loadable spec、
Codex tools 还包含 `ToolExposure::{Direct, Deferred, DeferredModelOnly, DirectModelOnly, CodeModeOnly, Hidden}`、ToolSearch/loadable spec、
pre/post/permission hook、CodeMode、parallel orchestration、argument diff、cancellation/
teardown、output truncation、call/output pairing、extension tool 和 background terminal。
OpenCode 的 `experimental_repairToolCall` 会修正常见参数/大小写错误,无法修复时生成
`invalid` tool callLime 当前无同等 canonical 语义,invalid JSON 直接变成 provider error。
`invalid` tool callLime 已完成前两刀 current 接线:provider canonical event 显式保留
`raw_arguments`sampling step 以冻结 definitions 和 current native alias 执行 typed repair
大小写/已知参数别名成功时在 transcript 前 canonical 化,malformed/scalar/unknown/空名称失败时
统一生成 `invalid` call、exactly-one started/completed lifecycle 和 model-visible failed result
专用 `before_handler` executor 保证真实 handler 调用为零。此前“invalid JSON 直接变成 provider
error”的判断已失效。repair 现在直接消费同一冻结 `RuntimeToolDefinition.input_schema`,只对 schema
显式声明的 integer/number 执行合法 JSON 数值字符串 coercion;完整 schema 校验失败或 schema 本身
无法编译时同样进入脱敏 `invalid` no-handler lifecycle,不基于字段描述或自然语言猜测参数。
组合竞态也已闭合:repair 后 handler 已启动时 cancel 产生唯一 `aborted` completed,迟到 success
不能覆盖终态、追加 lifecycle 或触发下一次 samplingrepair 成功并完成后,下一 provider step 的
absolute timeout 不会重放 handler 或 call/result lifecycle。
跨协议 request capture 也已闭合:OpenAI Chat、OpenAI Responses HTTP 与 Anthropic Messages 新增从
`CurrentProviderClient::stream` 进入的真实 loopback path/auth/body/SSE terminal 联合证据,连同既有
Gemini、Vertex、Azure Responses、Ollama Responses 与 Responses WebSocket capture,覆盖全部 current
chat transportBedrock/Fal 等未实现协议仍在发网前 fail closed。
CodeMode 第一刀 planning foundation 已完成:`tool-runtime` 现在持有与 Codex 同义的六态 exposure、
`Direct / CodeMode / CodeModeOnly` 严格模式决策和 frozen tool plan,能够分别投影 direct model、searchable 与
nested surfacenamespace 拼接、JavaScript identifier normalization、`exec`/`wait` 保留名和 collision winner
均有确定性回归。CodeMode runtime 不可用时,普通模式只有在允许 fallback 时才能降级为 Direct
CodeModeOnly 和禁用 fallback 的请求 fail closed。该切片没有向 provider 广告不可执行的 `exec`/`wait`
本项仍未关闭:provider freeform/custom-tool canonical contract 已完成,但仍缺 Grok-aligned capability/readiness
gate 的更高层 catalog 接线、thread-owned session runtime、`exec`/`wait`、nested dispatch、yield/resume/terminate
与 canonical terminalextension/background terminal 组合证据也仍待补齐。当前 custom contract 只允许官方
OpenAI Responses route 的显式 capability,其他协议/第三方 route 和历史在 lowering 前 fail closed,不得把它
当作 CodeMode runtime 已交付。
退出条件:tool definition、executor、hook snapshot 来自同一 sampling stepmalformed args、
unknown tool、repair success/failure、cancel/timeout、truncated output 和 late completion
@@ -278,6 +304,21 @@ replacement round-trip、malformed tail repair、unknown event preservation、at
provider credential fingerprint、source provenance 和 replay import/export 一致性。evidence
只能消费 current read model,不能反向驱动 runtime。
### P1-07Desktop Composer 项目文件搜索
状态:`completed / current`2026-08-10)。
Desktop Composer 已通过 exact one-shot `fuzzyFileSearch { query, roots, cancellationToken }`
接入 App Server filesystem search owner。项目 root 必须为绝对目录,结果最多 50 条并按
score/path 排序,保留相对路径、file/directory、file name 与 match indices;同 token 新请求取消
旧扫描,Renderer 同时使用 AbortSignal 与 request version 拒绝陈旧响应。选中候选只替换当前
`@token`,空格路径加引号,不生成 connector/plugin `Mention`
Codex experimental `fuzzyFileSearch/sessionStart|sessionUpdate|sessionStop`
`fuzzyFileSearch/sessionUpdated|sessionCompleted` 没有 Desktop consumer,分类为
`product-scope-excluded / forbidden-to-restore`。notification inventory 仅保留 method/field-name
诊断,禁止进入 pending interaction、current projector、generated manifest 或兼容 wrapper。
## 5. P2/P3 明确不能静默跳过
| 域 | 必须逐项决定的 Codex contract | 允许的 Lime 结果 |
@@ -292,7 +333,7 @@ provider credential fingerprint、source provenance 和 replay import/export 一
| 维度 | primary | secondary | 本轮新增验收 |
| -------------------------------------------------------------- | --------------------- | --------- | ----------------------------------------------------------------------------------------- |
| catalog/default/selection/switch/child subset/retry-breaker | Grok Build | - | route readiness、credential identity、effective options、unsupported protocol fail closed |
| endpoint/auth/query/header/variant/body/content/media/lowering | Lime `model-provider` | OpenCode | variant/header/body merge、media capability、repairToolCall/invalid tool、协议穷举 |
| endpoint/auth/query/header/variant/body/content/media/lowering | Lime `model-provider` | OpenCode | variant/header/body merge、media capability、schema-aware repair、跨协议 invalid tool、协议穷举 |
| runtime/session/history/Thread/Turn/Item | Codex | - | 不把 OpenCode session store 或 Grok MvpAgent 带入 Lime |
Grok 的 `SamplerConfig` 与 Codex `ModelProviderInfo` 提醒我们:provider/model 选择还包括
@@ -17,12 +17,12 @@ legacy 同义命令冒充协议对齐。矩阵覆盖 `clientRequest`、`serverRe
## 当前盘点
| 状态 | 数量 | 裁决 |
| ------------------------ | ---: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `implemented` | 139 | 连接握手、核心 Thread/Turn/Item、durable ordered Thread Section、thread subscription/lifecycle/content search/raw item injection/background terminals/elicitation/Guardian continuation、typed Collaboration Mode catalog、experimental feature catalog/enablement、Desktop permission profile catalog、Windows `windowsSandbox/readiness` current readiness、基础 Plugin cataloglist/read/install/uninstall/installed)与 Plugin Search、Hook discovery/lifecycle notifications、Skills list/config/extra roots/watcher、Apps exact catalog/read/installed 与 typed `app/list/updated` watcher、exact memory reset、connection-scoped process lifecycle、exact fs IO/watch、exact command/exec lifecycle、inline review/start 与 entered/exitedReviewMode boundary、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed `error`/`warning``item/commandExecution/terminalInteraction``item/autoApprovalReview/{started,completed}`、独立 `guardianWarning``turn/plan/updated`、精确 `turn/diff/updated``turn/moderationMetadata`、exact MCP resource/tool request/lifecycle notifications、typed approval/MCP server request 与 model control plane |
| `planned` | 28 | Windows `windowsSandbox/setupStart``windows/worldWritableWarning``windowsSandbox/setupCompleted`、Plugin share、environment、fuzzy search、marketplace 与 external agent configWindows readiness 已实现,但 runner enforcement/setup 仍未完成 |
| `product-scope-excluded` | 54 | Codex account/commerce、attestation、remote control、test-only、internal raw response、process-local `server/diagnostics`、deprecated surface、Desktop 不消费的开发/设置 deprecation notice、外部 `config.toml` 专用 MCP reload、未安装远端 Plugin Skill 预览、`configRequirements/read`、单一全局 Provider capability read,以及 Codex realtime WebRTC/session surface |
| 合计 | 221 | `137` client request、`11` server request、`72` server notification、`1` client notification |
| 状态 | 数量 | 裁决 |
| ------------------------ | ---: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `implemented` | 140 | 连接握手、核心 Thread/Turn/Item、durable ordered Thread Section、thread subscription/lifecycle/content search/raw item injection/background terminals/elicitation/Guardian continuation、typed Collaboration Mode catalog、experimental feature catalog/enablement、Desktop permission profile catalog、Windows `windowsSandbox/readiness` current readiness、基础 Plugin cataloglist/read/install/uninstall/installed)与 Plugin Search、Hook discovery/lifecycle notifications、Skills list/config/extra roots/watcher、Apps exact catalog/read/installed 与 typed `app/list/updated` watcher、exact memory reset、connection-scoped process lifecycle、exact fs IO/watch、Composer one-shot `fuzzyFileSearch`exact command/exec lifecycle、inline review/start 与 entered/exitedReviewMode boundary、`currentTime/read``item/permissions/requestApproval``item/tool/call`、typed `error`/`warning``item/commandExecution/terminalInteraction``item/autoApprovalReview/{started,completed}`、独立 `guardianWarning``turn/plan/updated`、精确 `turn/diff/updated``turn/moderationMetadata`、exact MCP resource/tool request/lifecycle notifications、typed approval/MCP server request 与 model control plane |
| `planned` | 3 | Windows `windowsSandbox/setupStart``windows/worldWritableWarning``windowsSandbox/setupCompleted`readiness 已实现,但真实 restricted-token runner、setup lifecycle 与 Windows Gate B 仍未完成 |
| `product-scope-excluded` | 78 | Codex account/commerce、attestation、remote control、test-only、internal raw response、process-local `server/diagnostics`、deprecated surface、Desktop 不消费的开发/设置 deprecation notice、外部 `config.toml` 专用 MCP reload、未安装远端 Plugin Skill 预览、`configRequirements/read`、单一全局 Provider capability readCodex realtime WebRTC/session surface、experimental fuzzy search session request/notification,以及远端 exec-server environment、external-agent migration、Plugin share 与 Codex Plugin marketplace mutations |
| 合计 | 221 | `137` client request、`11` server request、`72` server notification、`1` client notification |
`implemented` 只说明 method boundary 已存在并接入 current owner,不代表字段、恢复、GUI 或 Gate B 已全面 parity。
当前上游 revision 相比上一版只新增 process-local `server/diagnostics`;它不进入 Lime Desktop manifest。
@@ -38,7 +38,7 @@ legacy 同义命令冒充协议对齐。矩阵覆盖 `clientRequest`、`serverRe
6. `modelProvider/capabilities/read` 读取 Codex 单一全局 ProviderLime 的 provider/model 选择绑定 Thread route。该空参数方法没有产品消费者,不能把静态全局值冒充当前 route truth,归入 excluded;能力继续由 executable model catalog、resolved route 与 provider lowering 承接。
7. Codex HEAD 的 durable ordered Thread Section 已进入 Lime current 主链:section/store/order、五个 exact method、typed client、冷启动恢复与 Desktop 分组/置顶均消费同一事实源。旧 `isPinned` metadata、Renderer 时间重排与 localStorage 收藏为 `dead / deleted / forbidden-to-restore`
7a. Codex `server/diagnostics` 只提供无内容的进程本地诊断,Lime Desktop 没有对应用户流程;它保持 `product-scope-excluded / forbidden-to-restore`,不得新增 Electron 诊断后端或把日志指标当作模型/运行时 readiness。
8. `plugin/list``plugin/read``plugin/install``plugin/uninstall``plugin/installed``plugin/search` 已使用 exact Codex method、params/result wire、typed client 与公共 JSON-RPC/Electron 证据接入本地 Plugin catalog ownerCodex remote catalog、share、watcher 和 readiness 仍由 planned method 承接,不由这些基础方法冒充完成。
8. `plugin/list``plugin/read``plugin/install``plugin/uninstall``plugin/installed``plugin/search` 已使用 exact Codex method、params/result wire、typed client 与公共 JSON-RPC/Electron 证据接入本地 Plugin catalog ownerCodex remote catalog watcher、share 和 hosted readiness 不属于 Lime Desktop 产品流,不由这些基础方法冒充完成。
9. `hooks/list` 已接入 `tool-runtime` 唯一 discovery/trust owner、exact v2 contract、公共 JSON-RPC、command Hook sampling lifecycle、canonical Hook Item 恢复与真实 Electron Gate B;旧 raw config、默认信任、空 reporter 和 Renderer drift fallback 为 `dead / deleted / forbidden-to-restore`
10. `skills/list` 已接入 `lime-skills::AgentSkillSnapshot` 唯一 discovery owner、exact `cwds + forceReload -> data[{cwd,skills,errors}]` contract、公共 JSON-RPC、typed clients 与 Renderer catalog projection`skills/changed {}` 通过真实 Electron Gate B 驱动 GUI 自动刷新。singular `skill/list``SkillListResponse``get_local_skills_for_app``dead / deleted / forbidden-to-restore`
11. `skills/config/write``skills/extraRoots/set` 已接入 exact v2 contract、公共 JSON-RPC、typed clients 与同一 `lime-skills::AgentSkillSnapshot` owner。Desktop 将用户级启停配置持久化到 Lime YAML `skills.config`extra roots 只做进程级原子替换;成功设置 roots 发送 `skills/changed {}`。两者不引入 Codex TUI 或第二套管理 catalog。
@@ -54,11 +54,13 @@ legacy 同义命令冒充协议对齐。矩阵覆盖 `clientRequest`、`serverRe
21. `deprecationNotice` 属于 Codex 开发/设置诊断,不进入 Lime Desktop 对话或通知事实源。Lime 对无外部兼容负担的退役实现直接替换/删除,不新增同名兼容通知;`guardianWarning` 已由 Guardian denial circuit breaker 的独立 producer 接入 current App Server v2 主链,不得降级为普通 `warning`
22. `config/mcpServer/reload` 只服务 Codex 外部编辑 `config.toml` 后的 registry reload。Lime Desktop 的 MCP 配置唯一事实源是 App Server `mcpServer/list|create|update|delete`,启停走显式 `mcpServer/start|stop` lifecycleSettings Gate B 已验证该主链;新增 reload 会建立第二份配置 owner,因此归入 `product-scope-excluded`。其余 Codex config read/write/requirements method 仍保持 `planned`,不得由 Lime MCP CRUD 冒充 exact parity。
23. `plugin/skill/read` 的 exact Codex wire 要求 `remoteMarketplaceName``remotePluginId``skillName`,从远端服务读取未安装 Plugin 的 Skill markdown。Lime Desktop 已由 Skills catalog 与 `skill/read` 读取已安装本地 `SKILL.md`;二者同名不同义。远端预览归入 `product-scope-excluded`,本地 Skill owner 保持 current,不新增 alias、remote backend facade 或 compat wrapper`plugin/share/*` 仍保持 planned
23. `plugin/skill/read` 的 exact Codex wire 要求 `remoteMarketplaceName``remotePluginId``skillName`,从远端服务读取未安装 Plugin 的 Skill markdown。Lime Desktop 已由 Skills catalog 与 `skill/read` 读取已安装本地 `SKILL.md`;二者同名不同义。远端预览归入 `product-scope-excluded`,本地 Skill owner 保持 current,不新增 alias、remote backend facade 或 compat wrapper。
24. `collaborationMode/list` 已接入 exact v2 contract、公共 App Server JSON-RPC、typed package client 与 Renderer submit gateway。Desktop Composer 的 Plan 开关只表达选择意图,实际 preset 必须从 App Server catalog 解析;Plan mask 将 reasoning 覆盖为 `medium`model 保持 `null` 并沿用 Grok-aligned `model-provider` 当前选择。不得恢复 Renderer 本地 preset、复制 Codex TUI mode picker,或把 collaboration catalog 扩成第二模型目录。
25. `experimentalFeature/list``experimentalFeature/enablement/set` 已接入 exact v2 contract、公共 App Server JSON-RPC、typed package client 与 Settings Experimental consumer。Lime Desktop 当前只公开真实消费的 `webmcp`,由 App Server catalog 和 `lime_core config.yaml` 统一读写;可选 `threadId` 必须命中已加载 Thread,但 Lime 不建立 Codex project-local feature config。旧 Electron `get_experimental_config` / `save_experimental_config` 及 Renderer 直连 IPC 为 `dead / deleted / forbidden-to-restore`,不得恢复兼容包装或生产 mock fallback。
26. `permissionProfile/list` 已接入 exact v2 contract、公共 App Server JSON-RPC、typed package client 与 Desktop Composer 新回合提交 consumer。Lime Desktop 只公开 `:read-only``:workspace``:danger-full-access` 三个内建 profile;提交前必须从 App Server catalog 解析唯一且 `allowed=true` 的 profile,并以 `turn/start { approvalPolicy, permissions }` 进入 App Server,再 lowering 到 RuntimeRequest sandbox policy。未知/禁止 profile 与 `permissions + sandboxPolicy` 组合均 fail closed。Codex project-local 自定义 profile、`thread/settings/update.permissions` 仍为 planned,不得由本目录读取或旧 sandbox wire 冒充完成。
27. `windowsSandbox/readiness` 已接入 exact v2 contract、公共 App Server JSON-RPC、typed package client 与 Desktop Settings。它复用 `tool-runtime` 的 sandbox plan,只有 `Ready + enforced=true` 才返回 `ready`;当前 Windows plan 仍为 `Planned/enforced=false`,因此 Settings 明确显示 `需要更新`,不提供虚假的 setup 成功。`windowsSandbox/setupStart``windows/worldWritableWarning``windowsSandbox/setupCompleted` 仍为 planned。
28. 一发式 `fuzzyFileSearch { query, roots, cancellationToken }` 已接入 App Server filesystem search owner、typed clients 与 Desktop Composer 项目文件 `@` 补全。路径必须来自绝对项目 root,结果是相对路径、file/directory type、score 与 indicesRenderer 仅替换当前 `@token`,空格路径加引号。Codex experimental `fuzzyFileSearch/sessionStart|sessionUpdate|sessionStop``sessionUpdated|sessionCompleted` 没有 Desktop 产品消费者,统一为 `product-scope-excluded / forbidden-to-restore`;两个 notification 只保留脱敏 drift diagnostics,不进入 pending interaction 或 current projector。
29. 本轮将 Codex 远端 exec-server `environment/{add,info,status}``thread/environment/{connected,disconnected}`、external-agent config migration、Plugin share 和 Codex Plugin marketplace mutations 从 `planned` 裁决为 `product-scope-excluded / forbidden-to-restore`。Lime Desktop 的 environment 只表达本地 cwd/project/workspace/thread/turn 与 tool/MCP provenancePlugin 只由本地 Plugin v3 catalog 承接,Skills marketplace 走独立 `skillMarketplace/install`;这些 surface 均没有 Desktop consumer、唯一 current owner 或 Gate B 证据,不新增空壳协议、远端服务或兼容包装。
## 守卫
@@ -67,32 +69,22 @@ legacy 同义命令冒充协议对齐。矩阵覆盖 `clientRequest`、`serverRe
- 221 个方向化 identity 无遗漏、无重复,状态和方向计数稳定。
- planned 必须写 gapexcluded 必须写 rationale,所有组必须写 owner/evidence/priority。
- `implemented` 必须能在 Lime generated manifest 找到同方向、同名 contract。
- fuzzy search 只允许一发式 request 进入 manifest;五个 experimental session surface 必须保持 excluded 且禁止回流。
- `environment/*``thread/environment/*``externalAgentConfig/*``marketplace/*``plugin/share/*` 必须保持 excluded 且不进入 Lime manifestenvironment 及迁移通知只允许进入脱敏 drift diagnostics。
上游 Codex revision 变化时,先重跑注册表审计并更新矩阵;不得只改 hash 或计数让守卫通过。
## 下一刀
`thread/inject_items` 已对齐 exact method/shape、Codex current `ResponseItem` validation union、active Turn
session actor delivery、durable provider-only history、Responses exact lowering 与非 Responses fail-closed
Guardian reviewer producer/lifecycle 已进入 current 主链;elicitation provider active-time pause consumer 仍是 runtime lifecycle
blocker,但不影响已实现的 Guardian method boundary。多模型控制平面的
`model/verification``model/rerouted` 已接入可信 Responses metadata producer、Turn 级去重、exact v2
notification、schema 与 generated client。reroute 只接受 first-party requested/server mismatch,使用
`highRiskCyberActivity`,并通过 transient sink 实时投影而不进入 EventLog/resume item replay;普通 provider
fallback 继续只产生 `routing.fallback.applied`。下一刀处理 provider adapter/hosted tool 闭环、
Plugin share、Windows 平台证据,或具备真实 Desktop consumer 的 environment/fuzzy search surface。`deprecationNotice`
已按 Desktop 产品范围裁决为 excluded。每完成一个
method,必须同步 exact protocol、handler、typed client、fixture/evidence,再将其移入 `implemented`
Codex 已明确将 `thread/rollback` 标记为即将删除,Lime 不新增该公开方法。本轮将 `permissionProfile/list` 从 planned
移入 implementedApp Server catalog 驱动 Desktop access mode,并把 profile lowering 到 RuntimeRequest sandbox policy;本切片再将 `windowsSandbox/readiness` 接入 App Server + tool-runtime sandbox plan + Desktop Settings,只有真实 `Ready + enforced=true` 才返回 `ready`,当前 Windows runner 仍为 `Planned/enforced=false`
`thread/settings/update.permissions` 与 Codex project-local custom profile 仍保持 planned。当前产品范围完成度为
`139 / 167 = 83.2%`。本切片将 Codex `thread/realtime/*` 的 14 个方向化 method 从 planned 移入 product-scope-excluded,保持 Grok-aligned 多模态 owner,不恢复旧 voice GUI;此前将 Settings/Claw 配置读写收敛到 App Server `config/read``config/batchWrite` + `lime_core config.yaml`,并将 `configRequirements/read` 裁决为 Desktop 产品范围外;同时将 `experimentalFeature/{list,enablement/set}` 从 planned 移入 implemented,以 App Server + `lime_core config.yaml` 取代旧 Electron 实验配置后端;此前将 `collaborationMode/list` 从 planned 移入 implemented,以 App Server preset 取代 Renderer 本地运行语义;随后保留 Codex remote-plugin-only `plugin/skill/read` 的 product-scope-excluded 裁决,保持 Lime Desktop Skills catalog + `skill/read` 为本地 Skill body 唯一事实源;此前本切片将 external-config-only `config/mcpServer/reload` 从 planned 移入 product-scope-excluded,保留 App Server MCP CRUD/lifecycle 为唯一配置事实源;更早本切片将 Guardian denial circuit breaker 接入真实 Guardian reviewer 主链,完成独立 `guardianWarning` producer、durable `guardian.warning`、App Server v2、typed client 与 Desktop notice projection;再前本切片将 `item/autoApprovalReview/{started,completed}` 接入真实 Guardian reviewer 到 Desktop pending interaction 的完整 current 主链;更早本切片将 `turn/moderationMetadata` 接入 trusted first-party Responses metadata 到 Desktop canonical Turn 的完整 current 主链,并将 `deprecationNotice` 按 Desktop 产品范围移入 excluded;更早已将基础 Plugin catalog 五个方法、`currentTime/read``item/permissions/requestApproval``item/tool/call``warning``error``item/commandExecution/terminalInteraction``turn/plan/updated``turn/diff/updated` 从混合 planned 组拆出;`review/start` client request、`enteredReviewMode`/`exitedReviewMode` canonical boundary 与 Desktop Gate B evidence,以及 command/exec、fs、process 与 memory slices 也已同步进入 current owner,并物理删除对应旧 public surface;再前一切片收口已有 Hook lifecycle 与 MCP lifecycle notification 的中央 catalog/产品裁决;相对更早切片新增 Apps 的三个 client request 与一个 server notification,以及
`mcpServer/resource/read``mcpServer/tool/call` 两个 client request
均有 exact contract、公共 JSON-RPC 和 typed client evidence。resource read 的 `threadId` 可选;存在时只读取
对应 Session-owned MCP runtime。tool call 强制真实 `threadId`,经 `ExecutionBackend -> AgentRuntimeState ->
McpThreadRuntime` 执行,不经过全局 management manager。Settings 只浏览工具,不伪造 Thread owner。
此前相对注册表基线新增的 `32` 个方向已全部进入 `implemented`:五个 durable Thread Section
管理/移动 method、基础 Plugin catalog 五个方法、exact `plugin/search``hooks/list`、三个 Apps client request、Apps notification、`collaborationMode/list`、两个 experimental feature method、三个 Host/product reverse requests、typed `warning`/`error`、command terminal interaction、`turn/plan/updated``turn/diff/updated`、两个 MCP method、`turn/moderationMetadata`、两个 Guardian auto-approval review notifications 与独立 `guardianWarning`。Plugin share、environment、fuzzy search、marketplace、external agent config 与 Windows setup/notifications 仍保持 planned`thread/realtime/*``plugin/skill/read``deprecationNotice` 已按 Desktop 产品范围移入 excluded。
Gemini GenerateContent transport 虽已完成 request/stream/tool/history
闭环,但没有新增 exact Codex App Server method,因此不改变本矩阵计数;不得把 `83.2%` 解释成多模型或
整个 Codex 对齐工程的完成度。
上一刀已完成 `fuzzyFileSearch` one-shot、Windows readiness 与 Desktop reasoning identity 等 current slices;多模型
catalog、provider capability/readiness、retry/circuit breaker 与多模态 lowering 仍由 Grok-aligned
`model-provider` 负责,不以 Codex TUI surface 充数。
本轮产品范围裁决已将远端 exec-server environment、external-agent migration、Plugin share 和 Codex Plugin
marketplace mutations 从 `planned` 移入 `product-scope-excluded / forbidden-to-restore`。当前矩阵为
`140 implemented / 3 planned / 78 product-scope-excluded`,产品范围完成度 `140 / 143 = 97.9%`;剩余三项
Windows setup/notifications 仍要求 Windows toolchain、真实 restricted-token enforcement 和 Electron Gate B,不能由
macOS 证据伪造完成。
下一刀只回到 Windows 平台证据,或在发现新的真实 Desktop consumer 后重新开启范围审计。任何新增方法必须同步
exact protocol、handler、typed client、fixture/evidence 和产品守卫;不得恢复 excluded surface、TUI 入口或兼容包装。
@@ -21,8 +21,8 @@
},
"byStatus": {
"implemented": 140,
"product-scope-excluded": 59,
"planned": 22
"product-scope-excluded": 78,
"planned": 3
}
},
"groups": [
@@ -444,15 +444,20 @@
"methods": ["config/mcpServer/reload"]
},
{
"id": "environment-planned",
"id": "environment-excluded",
"direction": "clientRequest",
"productArea": "environment",
"status": "planned",
"priority": "P2",
"owner": "app-server environment owner",
"status": "product-scope-excluded",
"priority": "none",
"owner": "product-scope",
"counterpartRule": "none",
"gap": "Remote environment registry and status are not modeled on the current App Server boundary.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "Codex environment/add|info|status manages named remote exec-server WebSocket environments and their shell/cwd readiness. Lime Desktop has no remote exec-server registry, connection recovery, or environment picker; its local execution owner already carries cwd and environment identity through RuntimeCore/tool-runtime. Adding these methods would create a second execution backend rather than a user workflow.",
"evidence": [
"lime-rs/crates/app-server/src/processor/turn.rs",
"lime-rs/crates/tool-runtime/src/tool_call.rs",
"lime-rs/crates/tool-runtime/src/mcp_connection/scope.rs",
"internal/aiprompts/architecture.md"
],
"methods": ["environment/add", "environment/info", "environment/status"]
},
{
@@ -477,15 +482,19 @@
]
},
{
"id": "external-agent-config-planned",
"id": "external-agent-config-excluded",
"direction": "clientRequest",
"productArea": "config-import",
"status": "planned",
"priority": "P4",
"owner": "app-server config import owner",
"status": "product-scope-excluded",
"priority": "none",
"owner": "product-scope",
"counterpartRule": "none",
"gap": "External agent config detection/import has no current typed owner or product evidence.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "Codex externalAgentConfig methods migrate configuration and histories from other agent products. Lime has no import wizard, external-agent source adapter, migration persistence, or Desktop consumer; importing this TUI/cloud migration surface would add an unrelated configuration owner.",
"evidence": [
"internal/refactor/v1/08-third-audit-gap-register.md",
"internal/aiprompts/architecture.md",
"src/lib/api/agentRuntime/appServerNotificationDrift.ts"
],
"methods": [
"externalAgentConfig/detect",
"externalAgentConfig/import",
@@ -530,7 +539,6 @@
"priority": "P1",
"owner": "app-server filesystem search owner",
"counterpartRule": "same-name",
"gap": "Exact one-shot fuzzy search is consumed by the Desktop Composer file mention flow.",
"evidence": [
"lime-rs/crates/app-server-protocol/src/protocol/v2/fuzzy_file_search.rs",
"lime-rs/crates/app-server/src/fuzzy_file_search.rs",
@@ -577,15 +585,20 @@
"methods": ["hooks/list"]
},
{
"id": "marketplace-planned",
"id": "marketplace-excluded",
"direction": "clientRequest",
"productArea": "plugins",
"status": "planned",
"priority": "P4",
"owner": "plugin registry",
"status": "product-scope-excluded",
"priority": "none",
"owner": "product-scope",
"counterpartRule": "none",
"gap": "Marketplace mutations are not aligned to the Codex plugin registry contract.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "Codex marketplace mutations manage remote Plugin marketplace repositories. Lime Desktop's marketplace is the Skills catalog and skillMarketplace/install flow; it is a different owner and wire. Mapping Codex marketplace mutations onto it would misrepresent Skill installation as Plugin marketplace management.",
"evidence": [
"lime-rs/crates/app-server/src/processor/plugin.rs",
"packages/app-server-client/src/request-client-methods.ts",
"src/lib/api/skills.ts",
"internal/aiprompts/architecture.md"
],
"methods": [
"marketplace/add",
"marketplace/remove",
@@ -704,15 +717,19 @@
]
},
{
"id": "plugins-share-planned",
"id": "plugins-share-excluded",
"direction": "clientRequest",
"productArea": "plugins",
"status": "planned",
"priority": "P1",
"owner": "Plugin share owner",
"status": "product-scope-excluded",
"priority": "none",
"owner": "product-scope",
"counterpartRule": "none",
"gap": "Remote plugin share mutations still lack current Desktop lifecycle and evidence.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "Codex plugin/share methods operate on a remote sharing service and target principals. Lime Desktop has only local Plugin v3 discovery/install/activation and no share service, principal model, checkout store, or GUI workflow; the generated PluginShareContext DTO is not a consumer.",
"evidence": [
"packages/app-server-client/src/generated/protocol-types.ts",
"lime-rs/crates/app-server/src/processor/plugin.rs",
"internal/aiprompts/architecture.md"
],
"methods": [
"plugin/share/checkout",
"plugin/share/delete",
@@ -992,15 +1009,18 @@
"methods": ["error", "warning"]
},
{
"id": "external-agent-config-notification-planned",
"id": "external-agent-config-notification-excluded",
"direction": "serverNotification",
"productArea": "config-import",
"status": "planned",
"priority": "P4",
"owner": "app-server config import owner",
"status": "product-scope-excluded",
"priority": "none",
"owner": "product-scope",
"counterpartRule": "none",
"gap": "External agent config import progress has no current typed lifecycle.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "These notifications only report the excluded external-agent migration flow. Lime records method and field names as bounded drift diagnostics and has no import task UI or migration read model.",
"evidence": [
"src/lib/api/agentRuntime/appServerNotificationDrift.ts",
"internal/refactor/v1/08-third-audit-gap-register.md"
],
"methods": [
"externalAgentConfig/import/completed",
"externalAgentConfig/import/progress"
@@ -1203,15 +1223,19 @@
"methods": ["skills/changed"]
},
{
"id": "environment-notification-planned",
"id": "environment-notification-excluded",
"direction": "serverNotification",
"productArea": "environment",
"status": "planned",
"priority": "P2",
"owner": "app-server environment owner",
"status": "product-scope-excluded",
"priority": "none",
"owner": "Renderer notification drift diagnostics",
"counterpartRule": "none",
"gap": "Thread environment connection transitions have no current typed owner.",
"evidence": ["internal/refactor/v1/08-third-audit-gap-register.md"],
"rationale": "Connection lifecycle notifications belong to Codex remote exec-server environments. Lime has no corresponding remote environment owner or Desktop state surface; they remain method-only diagnostics and must not enter Header, timeline, or pending interaction projections.",
"evidence": [
"src/lib/api/agentRuntime/appServerNotificationDrift.ts",
"internal/refactor/v2/EVENT-PROJECTIONS.md",
"internal/aiprompts/architecture.md"
],
"methods": [
"thread/environment/connected",
"thread/environment/disconnected"
+26 -26
View File
@@ -19,29 +19,29 @@ Guardian warning 当前已完成独立 producer:同一 turn 内连续 3 次 Gu
## 1. Thread、Turn 与 Hook
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------- | -------- | -------- | --------------------------------------------------------------- |
| 1 | error | TP/HS | current | typed live/durabletrue 重试,false 等权威 Turn |
| 2 | thread/started | HS | current | 建立 Thread metadata,不把空 turns 当完整 history |
| 3 | thread/status/changed | HS/PI | current | notLoaded、idle、systemError、active 与 waiting flags |
| 4 | thread/archived | GN/HS | current | 侧栏归档,当前页只读 |
| 5 | thread/deleted | GN | current | 当前页已删除态,不删除 Project |
| 6 | thread/unarchived | GN/HS | current | 恢复可见/可操作状态 |
| 7 | thread/closed | HS | current | 清除 live spinner 和 pending interaction |
| 8 | skills/changed | GN | current | 瞬时失效并重新读取 Composer current Skill catalog |
| 9 | thread/name/updated | HS | current | Header 与侧栏名称 |
| 10 | thread/goal/updated | HS | current | goal/阶段入口,不映射 Rust ProductionPlan |
| 11 | thread/goal/cleared | HS | current | 清除当前 goal indicator |
| 12 | thread/environment/connected | HS | planned | 受控环境状态,无本地 fallback |
| 13 | thread/environment/disconnected | HS | planned | 断开与受影响能力 |
| 14 | thread/settings/updated | HS | current | 下一 Turn model、reasoning、permission 摘要 |
| 15 | thread/tokenUsage/updated | TP/HS | current | 本 Turn/总用量,节流更新 |
| 16 | turn/started | TL/TP | current | 建立 Turn 与原始 Item 顺序 |
| 17 | hook/started | TL/HS | current | current Hook producer 的 transient activityrun id 保留 |
| 18 | turn/completed | TL/TP | current | 权威 Turn 终态并清理 pending |
| 19 | hook/completed | TL | current | paired Hook producer 的 transient status,不写入 canonical Item |
| 20 | turn/diff/updated | DX | current | Lime exact Turn diffcanonical Turn/Changes 使用同一快照 |
| 21 | turn/plan/updated | TP | current | canonical update_plan checklist,实时/冷恢复一致 |
| # | Method | 目标出口 | 当前裁决 | v2 投影 |
| --: | ------------------------------- | -------- | ---------------------- | --------------------------------------------------------------- |
| 1 | error | TP/HS | current | typed live/durabletrue 重试,false 等权威 Turn |
| 2 | thread/started | HS | current | 建立 Thread metadata,不把空 turns 当完整 history |
| 3 | thread/status/changed | HS/PI | current | notLoaded、idle、systemError、active 与 waiting flags |
| 4 | thread/archived | GN/HS | current | 侧栏归档,当前页只读 |
| 5 | thread/deleted | GN | current | 当前页已删除态,不删除 Project |
| 6 | thread/unarchived | GN/HS | current | 恢复可见/可操作状态 |
| 7 | thread/closed | HS | current | 清除 live spinner 和 pending interaction |
| 8 | skills/changed | GN | current | 瞬时失效并重新读取 Composer current Skill catalog |
| 9 | thread/name/updated | HS | current | Header 与侧栏名称 |
| 10 | thread/goal/updated | HS | current | goal/阶段入口,不映射 Rust ProductionPlan |
| 11 | thread/goal/cleared | HS | current | 清除当前 goal indicator |
| 12 | thread/environment/connected | DX | product-scope-excluded | Codex 远端 exec-server 生命周期;仅保留脱敏 drift diagnostics |
| 13 | thread/environment/disconnected | DX | product-scope-excluded | 同上,不进入 Header、timeline 或 pending interaction |
| 14 | thread/settings/updated | HS | current | 下一 Turn model、reasoning、permission 摘要 |
| 15 | thread/tokenUsage/updated | TP/HS | current | 本 Turn/总用量,节流更新 |
| 16 | turn/started | TL/TP | current | 建立 Turn 与原始 Item 顺序 |
| 17 | hook/started | TL/HS | current | current Hook producer 的 transient activityrun id 保留 |
| 18 | turn/completed | TL/TP | current | 权威 Turn 终态并清理 pending |
| 19 | hook/completed | TL | current | paired Hook producer 的 transient status,不写入 canonical Item |
| 20 | turn/diff/updated | DX | current | Lime exact Turn diffcanonical Turn/Changes 使用同一快照 |
| 21 | turn/plan/updated | TP | current | canonical update_plan checklist,实时/冷恢复一致 |
## 2. Item 生命周期、流与进程
@@ -95,8 +95,8 @@ Guardian warning 当前已完成独立 producer:同一 turn 内连续 3 次 Gu
| 57 | guardianWarning | HS/TL | current | Guardian denial circuit breaker 的高优先级 warning,独立于普通 warning |
| 58 | deprecationNotice | GN | product-scope-excluded | 开发/设置诊断,不污染对话流 |
| 59 | configWarning | GN | current | initialize/turn producertyped path/range 经去重 toast 展示 |
| 60 | fuzzyFileSearch/sessionUpdated | PI | planned | Composer mention 搜索,丢弃陈旧 session |
| 61 | fuzzyFileSearch/sessionCompleted | PI | planned | 终结 loading、显示空/失败态 |
| 60 | fuzzyFileSearch/sessionUpdated | DX | product-scope-excluded | experimental session flow;仅脱敏 drift diagnostics |
| 61 | fuzzyFileSearch/sessionCompleted | DX | product-scope-excluded | 同上;Composer 由 one-shot request 自行终结 loading |
## 5. Realtime、Windows 与登录
@@ -114,7 +114,7 @@ Guardian warning 当前已完成独立 producer:同一 turn 内连续 3 次 Gu
| 71 | windowsSandbox/setupCompleted | GN/PI | planned | setup success/error 与下一步 |
| 72 | account/login/completed | GN/PI | product-scope-excluded | credential 流程不进入对话 |
v2 的实现门槛不是把所有 planned method 同时实现,而是首先将这张表固化为类型检查的 coverage map。新增 Codex method 时,CI 必须要求它先获得裁决,不能落入 default silent return。standalone `process/outputDelta``process/exited` 与全部 `thread/realtime/*` 通知虽保留在 upstream method inventory 和 drift recorder 中,但明确为 `product-scope-excluded`:不得进入 Lime current protocol、Renderer projector、时间线或用户级通知;对应 standalone `process/spawn` 与 Codex realtime/WebRTC 控制面不能借 planned 名义回流。Lime exact `turn/diff/updated` 已由 `apply_patch -> durable fact -> v2 projector -> canonical Turn/Changes` current owner 承接,不等同于 Codex TUI 的 raw diff surface。音频、语音和媒体能力继续由 Grok-aligned `model-provider``voice-core` / `media-runtime` 承接。
v2 的实现门槛不是把所有 planned method 同时实现,而是首先将这张表固化为类型检查的 coverage map。新增 Codex method 时,CI 必须要求它先获得裁决,不能落入 default silent return。standalone `process/outputDelta``process/exited`、两个 experimental fuzzy search session notification 与全部 `thread/realtime/*` 通知虽保留在 upstream method inventory 和 drift recorder 中,但明确为 `product-scope-excluded`:不得进入 Lime current protocol、Renderer projector、时间线、pending interaction 或用户级通知;对应 standalone `process/spawn`、fuzzy session registry 与 Codex realtime/WebRTC 控制面不能借 planned 名义回流。Composer 只消费 one-shot `fuzzyFileSearch` request,并以请求取消/陈旧响应拒绝终结本地 loading。Lime exact `turn/diff/updated` 已由 `apply_patch -> durable fact -> v2 projector -> canonical Turn/Changes` current owner 承接,不等同于 Codex TUI 的 raw diff surface。音频、语音和媒体能力继续由 Grok-aligned `model-provider``voice-core` / `media-runtime` 承接。
## 6. Lime-owned 扩展事件
+2 -2
View File
@@ -6,7 +6,7 @@
当前阶段:V2-05 notification、host capability 与 recoveryV2-00、V2-01、V2-02、V2-03、V2-04 已关闭,direct TurnTimeline、production replay、session history、长列表性能、MCP elicitation、Multi-Agent、三项 product-scope reverse request、media read v2、unknown Item fail-visible recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist、`mcpServer/oauthLogin/completed``mcpServer/startupStatus/updated` 与 unified exec terminal interaction Gate B 已通过
下一刀:V2-05 已关闭 media transient bypass、unknown Item fail-visible recovery、`configWarning` owner 迁移、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist、MCP OAuth completion、MCP startup status、unified exec terminal interaction、Hook lifecycle、`turn/diff/updated` typed/recovery、Guardian auto-approval review typed/recovery 与 Guardian denial circuit breaker `guardianWarning` 主链;standalone `process/*` 与 Codex `thread/realtime/*` 仍为 product-scope-excluded。继续审计具备真实 producer/consumer 的 remaining planned notification、host capability 或 recovery。不重复改写已关闭 owner,也不恢复 raw unified diff、unsandboxed process/spawn、Message synthesis、unknown null drop、extension fallback、v0 media/config owner、旧 MCP Desktop lifecycle event、旧 Team 工具、第二 pending store、第二 Skill catalog owner、Plan ThreadItem、独立 `write_stdin` Tool Item 或由 error 抢占 Turn terminal 的旁路状态机。
下一刀:V2-05 已关闭 media transient bypass、unknown Item fail-visible recovery、`configWarning` owner 迁移、thread-scoped `warning` typed/recovery、`skills/changed` catalog invalidation、typed `error` retry/terminal、`turn/plan/updated` checklist、MCP OAuth completion、MCP startup status、unified exec terminal interaction、Hook lifecycle、`turn/diff/updated` typed/recovery、Guardian auto-approval review typed/recovery 与 Guardian denial circuit breaker `guardianWarning` 主链;Composer 文件补全只消费 one-shot `fuzzyFileSearch`experimental session notifications 已裁决为 diagnostic-only/product-scope-excluded。standalone `process/*` 与 Codex `thread/realtime/*` 仍为 product-scope-excluded。继续审计具备真实 producer/consumer 的 remaining planned notification、host capability 或 recovery。不重复改写已关闭 owner,也不恢复 raw unified diff、unsandboxed process/spawn、fuzzy session registry、Message synthesis、unknown null drop、extension fallback、v0 media/config owner、旧 MCP Desktop lifecycle event、旧 Team 工具、第二 pending store、第二 Skill catalog owner、Plan ThreadItem、独立 `write_stdin` Tool Item 或由 error 抢占 Turn terminal 的旁路状态机。
## 1. 约束与非目标
@@ -197,7 +197,7 @@
动作:
1. 按 EVENT-PROJECTIONS 逐项补 environment、fuzzy search、Windows sandbox 等仍在产品范围内且有真实 Desktop consumer 的 planned surfaceHook、warning 与 `turn/diff/updated` 已有 current ownerstandalone `process/*` 和 Codex `thread/realtime/*` 均为 product-scope-excluded,不得恢复为第二套 current。音频、语音和媒体能力继续走 Grok-aligned provider/runtime owner。
1. 按 EVENT-PROJECTIONS 逐项补 environment、Windows sandbox 等仍在产品范围内且有真实 Desktop consumer 的 planned surfaceone-shot fuzzy search 已由 Composer current owner 消费,experimental fuzzy session、standalone `process/*` 和 Codex `thread/realtime/*` 均为 product-scope-excluded,不得恢复为第二套 current。Hook、warning 与 `turn/diff/updated` 已有 current owner。音频、语音和媒体能力继续走 Grok-aligned provider/runtime owner。
2. 每项先补 App Server typed protocol 和 producer,再补 Electron gateway、projection、renderer 和 Gate B;不能由前端先造状态。
3. 维护已落地的 unknown notification drift recorderknown-but-excluded 发 DXunknown notification fail visiblestandalone `process/*` 只保留脱敏字段名诊断,不进入 Renderercurrent `turn/diff/updated` 走 canonical Turn/Changesunknown reverse request fail closed。
4. 移除已被 direct ItemRenderer 替代的旧 Message hydration、stream merge、legacy event parser、compat DTO 和测试夹具。
+1 -1
View File
@@ -56,7 +56,7 @@ v2 把 Lime 的对话界面重构为 Codex App Server v2 的完整语义投影
| 层 | 当前事实 | v2 缺口 |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Rust Item 协议 | Codex 顶层 Item 与 Lime-owned DynamicTool 均进入 typed current contractMCP result/error 与 `inputAudio` 保持结构化 | UserMessage audio/localAudio 不在 Lime 产品协议范围;其余 planned Item 不得由 metadata 推断 |
| v2 notification | 72 项 coverage、pinned schema/hash/method drift gate、unknown recorder,以及 typed `configWarning` / thread-scoped `warning` producer、GUI 与 warning recovery 已落地 | 其余 planned notification 尚无完整 producer、GUI outlet 与 Gate B |
| v2 notification | 72 项 coverage、分离的 pinned Item schema/current method revision、unknown recorder,以及 typed `configWarning` / thread-scoped `warning` producer、GUI 与 warning recovery 已落地experimental fuzzy session notification 仅进入脱敏 diagnostics | 其余 planned notification 尚无完整 producer、GUI outlet 与 Gate B |
| reverse request | command/file/userInput/MCP/permission 共用唯一 pending ownercurrentTime 与 dynamic tool 由 Electron Host 处理 | product-scope-excluded request 保持 fail closed,不新增 Renderer 或 compat handler |
| canonical lower | 18 类上游 type 均投影为 typed Itemunknown camelCase type 进入 `unknown_item`,畸形/旧 shape fail closed | 不再允许 extension 或 raw JSON fallback;专项 malformed/Gate B 仍需扩圈 |
| 时间线 | MessageList 直接消费 canonical Turn render projection;长列表窗口化和真实 Electron 性能证据已关闭 | V2-05 新 notification 仍须逐项证明不会破坏 sequence 与终态 |
@@ -60,8 +60,14 @@
{ "method": "thread/name/updated", "outlets": ["header"] },
{ "method": "thread/goal/updated", "outlets": ["header"] },
{ "method": "thread/goal/cleared", "outlets": ["header"] },
{ "method": "thread/environment/connected", "outlets": ["header"] },
{ "method": "thread/environment/disconnected", "outlets": ["header"] },
{
"method": "thread/environment/connected",
"outlets": ["diagnostics"]
},
{
"method": "thread/environment/disconnected",
"outlets": ["diagnostics"]
},
{ "method": "thread/settings/updated", "outlets": ["header"] },
{
"method": "thread/tokenUsage/updated",
+369 -112
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -6,7 +6,7 @@ exclude = [
resolver = "2"
[workspace.package]
version = "1.125.0"
version = "1.126.0"
edition = "2021"
authors = ["coso"]
repository = "https://github.com/aiclientproxy/lime"
@@ -59,6 +59,8 @@ futures = "0.3"
async-stream = "0.3"
async-trait = "0.1"
clap = { version = "4.5", features = ["derive"] }
deno_core_icudata = "0.77.0"
v8 = { version = "=150.4.0", features = ["v8_enable_sandbox"] }
# 错误处理
anyhow = "1"
@@ -598,7 +598,11 @@ mod tests {
oneshot::Receiver<anyhow::Result<Value>>,
) {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create(session_id).await;
let thread_id = format!("thread-{session_id}");
let session = registry
.get_or_create(session_id, &thread_id)
.await
.expect("bind action-required session actor");
let (id_tx, id_rx) = oneshot::channel();
let id_tx = Arc::new(StdMutex::new(Some(id_tx)));
let (result_tx, result_rx) = oneshot::channel();
@@ -0,0 +1,503 @@
use futures::future::join_all;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{watch, OnceCell};
use tool_runtime::code_mode::{
NoopRuntimeCodeModeSessionDelegate, ProcessCodeModeSessionProvider, RuntimeCodeModeCellId,
RuntimeCodeModeExecuteRequest, RuntimeCodeModeFuture, RuntimeCodeModeSession,
RuntimeCodeModeSessionDelegate, RuntimeCodeModeSessionHandle, RuntimeCodeModeSessionLimits,
RuntimeCodeModeSessionProvider, RuntimeCodeModeStartedCell, RuntimeCodeModeWaitOutcome,
RuntimeCodeModeWaitRequest,
};
type DelegateFactory =
dyn Fn(&str) -> Result<Arc<dyn RuntimeCodeModeSessionDelegate>, String> + Send + Sync;
#[derive(Clone)]
pub struct RuntimeCodeModeServiceFactory {
provider: Arc<dyn RuntimeCodeModeSessionProvider>,
delegate_factory: Arc<DelegateFactory>,
limits: RuntimeCodeModeSessionLimits,
}
impl RuntimeCodeModeServiceFactory {
pub fn production() -> Self {
Self::new(
Arc::new(ProcessCodeModeSessionProvider::default()),
|_thread_id| Ok(Arc::new(NoopRuntimeCodeModeSessionDelegate)),
)
}
pub fn new(
provider: Arc<dyn RuntimeCodeModeSessionProvider>,
delegate_factory: impl Fn(&str) -> Result<Arc<dyn RuntimeCodeModeSessionDelegate>, String>
+ Send
+ Sync
+ 'static,
) -> Self {
Self {
provider,
delegate_factory: Arc::new(delegate_factory),
limits: RuntimeCodeModeSessionLimits::default(),
}
}
pub fn with_limits(mut self, limits: RuntimeCodeModeSessionLimits) -> Self {
self.limits = limits;
self
}
pub fn create(&self, thread_id: &str) -> Result<RuntimeCodeModeService, String> {
self.provider.availability()?;
let delegate = (self.delegate_factory)(thread_id)?;
Ok(RuntimeCodeModeService::new(
thread_id,
Arc::clone(&self.provider),
delegate,
self.limits.clone(),
))
}
}
#[derive(Clone)]
pub struct RuntimeCodeModeService {
inner: Arc<RuntimeCodeModeServiceInner>,
}
struct RuntimeCodeModeServiceInner {
thread_id: Arc<str>,
provider: Arc<dyn RuntimeCodeModeSessionProvider>,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
limits: RuntimeCodeModeSessionLimits,
session: OnceCell<RuntimeCodeModeSessionHandle>,
active_cells: Arc<Mutex<HashSet<RuntimeCodeModeCellId>>>,
dispatch_routes: Arc<Mutex<HashMap<RuntimeCodeModeCellId, CodeModeDelegate>>>,
dispatch_gates: Arc<Mutex<HashMap<RuntimeCodeModeCellId, watch::Sender<bool>>>>,
closed_cells: Arc<Mutex<HashSet<RuntimeCodeModeCellId>>>,
shutting_down: AtomicBool,
}
type CodeModeDelegate = Arc<dyn RuntimeCodeModeSessionDelegate>;
struct RuntimeCodeModeDispatchDelegate {
fallback: CodeModeDelegate,
routes: Arc<Mutex<HashMap<RuntimeCodeModeCellId, CodeModeDelegate>>>,
gates: Arc<Mutex<HashMap<RuntimeCodeModeCellId, watch::Sender<bool>>>>,
closed_cells: Arc<Mutex<HashSet<RuntimeCodeModeCellId>>>,
}
impl RuntimeCodeModeService {
fn new(
thread_id: &str,
provider: Arc<dyn RuntimeCodeModeSessionProvider>,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
limits: RuntimeCodeModeSessionLimits,
) -> Self {
let dispatch_routes = Arc::new(Mutex::new(HashMap::new()));
let dispatch_gates = Arc::new(Mutex::new(HashMap::new()));
let closed_cells = Arc::new(Mutex::new(HashSet::new()));
let delegate = Arc::new(RuntimeCodeModeDispatchDelegate {
fallback: delegate,
routes: Arc::clone(&dispatch_routes),
gates: Arc::clone(&dispatch_gates),
closed_cells: Arc::clone(&closed_cells),
});
Self {
inner: Arc::new(RuntimeCodeModeServiceInner {
thread_id: Arc::from(thread_id),
provider,
delegate,
limits,
session: OnceCell::new(),
active_cells: Arc::new(Mutex::new(HashSet::new())),
dispatch_routes,
dispatch_gates,
closed_cells,
shutting_down: AtomicBool::new(false),
}),
}
}
pub fn thread_id(&self) -> &str {
&self.inner.thread_id
}
pub fn session_handle(&self) -> RuntimeCodeModeSessionHandle {
RuntimeCodeModeSessionHandle::new(Arc::new(self.clone()))
}
pub async fn interrupt_active_cells(&self) {
let Some(session) = self.inner.session.get().cloned() else {
return;
};
let cell_ids = self
.inner
.active_cells
.lock()
.expect("code mode active cell set poisoned")
.iter()
.cloned()
.collect::<Vec<_>>();
let results = join_all(cell_ids.into_iter().map(|cell_id| {
let session = session.clone();
async move {
let result = session.terminate(cell_id.clone()).await;
(cell_id, result)
}
}))
.await;
let mut active_cells = self
.inner
.active_cells
.lock()
.expect("code mode active cell set poisoned");
for (cell_id, result) in results {
if result.is_ok() {
active_cells.remove(&cell_id);
self.remove_dispatch_route(&cell_id);
self.mark_cell_closed(&cell_id);
}
}
}
pub async fn shutdown(&self) -> Result<(), String> {
if self.inner.shutting_down.swap(true, Ordering::AcqRel) {
return Ok(());
}
let result = match self
.inner
.session
.get_or_try_init(|| async {
Err::<RuntimeCodeModeSessionHandle, String>(
"code mode session is shutting down".to_string(),
)
})
.await
{
Ok(session) => session.shutdown().await,
Err(_) => Ok(()),
};
self.inner
.active_cells
.lock()
.expect("code mode active cell set poisoned")
.clear();
self.inner
.dispatch_routes
.lock()
.expect("code mode dispatch routes poisoned")
.drain()
.for_each(|(cell_id, delegate)| delegate.cell_closed(&cell_id));
self.inner
.dispatch_gates
.lock()
.expect("code mode dispatch gates poisoned")
.clear();
result
}
async fn session(&self) -> Result<RuntimeCodeModeSessionHandle, String> {
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err("code mode session is shutting down".to_string());
}
self.inner
.session
.get_or_try_init(|| async {
if self.inner.shutting_down.load(Ordering::Acquire) {
return Err("code mode session is shutting down".to_string());
}
let session = self
.inner
.provider
.create_session_with_limits(
Arc::clone(&self.inner.delegate),
self.inner.limits.clone(),
)
.await?;
if self.inner.shutting_down.load(Ordering::Acquire) {
let _ = session.shutdown().await;
return Err("code mode session is shutting down".to_string());
}
Ok(session)
})
.await
.cloned()
}
fn register_cell(&self, cell_id: RuntimeCodeModeCellId, delegate: Option<CodeModeDelegate>) {
self.inner
.closed_cells
.lock()
.expect("code mode closed cells poisoned")
.remove(&cell_id);
self.inner
.active_cells
.lock()
.expect("code mode active cell set poisoned")
.insert(cell_id.clone());
if let Some(delegate) = delegate {
self.inner
.dispatch_routes
.lock()
.expect("code mode dispatch routes poisoned")
.insert(cell_id.clone(), delegate);
}
dispatch_gate(&self.inner.dispatch_gates, &cell_id).send_replace(true);
}
fn remove_dispatch_route(&self, cell_id: &RuntimeCodeModeCellId) {
let delegate = self
.inner
.dispatch_routes
.lock()
.expect("code mode dispatch routes poisoned")
.remove(cell_id);
if let Some(delegate) = delegate {
delegate.cell_closed(cell_id);
}
self.inner
.dispatch_gates
.lock()
.expect("code mode dispatch gates poisoned")
.remove(cell_id);
}
fn finish_cell(&self, cell_id: &RuntimeCodeModeCellId) {
self.inner
.active_cells
.lock()
.expect("code mode active cell set poisoned")
.remove(cell_id);
self.remove_dispatch_route(cell_id);
self.mark_cell_closed(cell_id);
}
fn mark_cell_closed(&self, cell_id: &RuntimeCodeModeCellId) {
self.inner
.closed_cells
.lock()
.expect("code mode closed cells poisoned")
.insert(cell_id.clone());
}
fn execute_with_delegate(
&self,
request: RuntimeCodeModeExecuteRequest,
delegate: Option<CodeModeDelegate>,
) -> RuntimeCodeModeFuture<'_, RuntimeCodeModeStartedCell> {
Box::pin(async move {
let started = self.session().await?.execute(request).await?;
let cell_id = started.cell_id.clone();
self.register_cell(cell_id.clone(), delegate);
let service = self.clone();
let response_cell_id = cell_id.clone();
Ok(RuntimeCodeModeStartedCell::new(
cell_id,
Box::pin(async move {
let result = started.initial_response().await;
let finished = match &result {
Ok(response) => response.is_terminal(),
Err(_) => true,
};
if finished {
service.finish_cell(&response_cell_id);
}
result
}),
))
})
}
}
impl RuntimeCodeModeSession for RuntimeCodeModeService {
fn execute<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
RuntimeCodeModeService::execute_with_delegate(self, request, None)
}
fn execute_with_delegate<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
delegate: Option<CodeModeDelegate>,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
RuntimeCodeModeService::execute_with_delegate(self, request, delegate)
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
let cell_id = request.cell_id.clone();
let outcome = self.session().await?.wait(request).await?;
if matches!(&outcome, RuntimeCodeModeWaitOutcome::MissingCell(_))
|| outcome.clone().into_response().is_terminal()
{
self.finish_cell(&cell_id);
}
Ok(outcome)
})
}
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
let outcome = self.session().await?.terminate(cell_id.clone()).await?;
self.finish_cell(&cell_id);
Ok(outcome)
})
}
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()> {
Box::pin(RuntimeCodeModeService::shutdown(self))
}
}
fn dispatch_gate(
gates: &Mutex<HashMap<RuntimeCodeModeCellId, watch::Sender<bool>>>,
cell_id: &RuntimeCodeModeCellId,
) -> watch::Sender<bool> {
gates
.lock()
.expect("code mode dispatch gates poisoned")
.entry(cell_id.clone())
.or_insert_with(|| watch::channel(false).0)
.clone()
}
async fn wait_for_dispatch_route(
routes: &Mutex<HashMap<RuntimeCodeModeCellId, CodeModeDelegate>>,
gates: &Mutex<HashMap<RuntimeCodeModeCellId, watch::Sender<bool>>>,
closed_cells: &Mutex<HashSet<RuntimeCodeModeCellId>>,
cell_id: &RuntimeCodeModeCellId,
cancellation_token: &tokio_util::sync::CancellationToken,
) -> Result<Option<CodeModeDelegate>, String> {
if closed_cells
.lock()
.expect("code mode closed cells poisoned")
.contains(cell_id)
{
return Err(format!("code mode cell {cell_id} is already closed"));
}
if let Some(delegate) = routes
.lock()
.expect("code mode dispatch routes poisoned")
.get(cell_id)
.cloned()
{
return Ok(Some(delegate));
}
let mut ready = dispatch_gate(gates, cell_id).subscribe();
loop {
if *ready.borrow_and_update() {
if closed_cells
.lock()
.expect("code mode closed cells poisoned")
.contains(cell_id)
{
return Err(format!("code mode cell {cell_id} is already closed"));
}
return Ok(routes
.lock()
.expect("code mode dispatch routes poisoned")
.get(cell_id)
.cloned());
}
tokio::select! {
changed = ready.changed() => {
if changed.is_err() {
return Ok(None);
}
}
_ = cancellation_token.cancelled() => {
return Err("code mode nested dispatch cancelled".to_string());
}
}
}
}
impl RuntimeCodeModeSessionDelegate for RuntimeCodeModeDispatchDelegate {
fn invoke_tool<'a>(
&'a self,
invocation: tool_runtime::code_mode::RuntimeCodeModeNestedToolCall,
cancellation_token: tokio_util::sync::CancellationToken,
) -> RuntimeCodeModeFuture<'a, serde_json::Value> {
Box::pin(async move {
let delegate = wait_for_dispatch_route(
&self.routes,
&self.gates,
&self.closed_cells,
&invocation.cell_id,
&cancellation_token,
)
.await?;
match delegate {
Some(delegate) => delegate.invoke_tool(invocation, cancellation_token).await,
None => {
self.fallback
.invoke_tool(invocation, cancellation_token)
.await
}
}
})
}
fn notify<'a>(
&'a self,
tool_call_id: String,
cell_id: RuntimeCodeModeCellId,
text: String,
cancellation_token: tokio_util::sync::CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async move {
let delegate = wait_for_dispatch_route(
&self.routes,
&self.gates,
&self.closed_cells,
&cell_id,
&cancellation_token,
)
.await?;
match delegate {
Some(delegate) => {
delegate
.notify(tool_call_id, cell_id, text, cancellation_token)
.await
}
None => {
self.fallback
.notify(tool_call_id, cell_id, text, cancellation_token)
.await
}
}
})
}
fn cell_closed(&self, cell_id: &RuntimeCodeModeCellId) {
self.closed_cells
.lock()
.expect("code mode closed cells poisoned")
.insert(cell_id.clone());
if let Some(delegate) = self
.routes
.lock()
.expect("code mode dispatch routes poisoned")
.remove(cell_id)
{
delegate.cell_closed(cell_id);
}
self.gates
.lock()
.expect("code mode dispatch gates poisoned")
.remove(cell_id);
self.fallback.cell_closed(cell_id);
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,511 @@
use super::*;
use crate::session_loop::{
RuntimeSessionClosureTask, RuntimeSessionRegistry, RuntimeSessionTaskFailure,
RuntimeSessionTaskOutcome,
};
use std::future::pending;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::oneshot;
use tokio::time::{timeout, Duration};
use tokio_util::sync::CancellationToken;
use tool_runtime::code_mode::{
NoopRuntimeCodeModeSessionDelegate, RuntimeCodeModeNestedToolCall, RuntimeCodeModeResponse,
RuntimeCodeModeSessionProviderFuture,
};
#[derive(Default)]
struct RecordingSession {
operations: Mutex<Vec<String>>,
}
impl RecordingSession {
fn operations(&self) -> Vec<String> {
self.operations
.lock()
.expect("recording session operations poisoned")
.clone()
}
}
impl RuntimeCodeModeSession for RecordingSession {
fn execute<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
Box::pin(async move {
self.operations
.lock()
.expect("record execute")
.push(format!("execute:{}", request.tool_call_id));
let cell_id = RuntimeCodeModeCellId::new("cell-active");
Ok(RuntimeCodeModeStartedCell::new(
cell_id,
Box::pin(pending()),
))
})
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
Ok(RuntimeCodeModeWaitOutcome::LiveCell(
RuntimeCodeModeResponse::Yielded {
cell_id: request.cell_id,
output: "pending".to_string(),
},
))
})
}
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
self.operations
.lock()
.expect("record terminate")
.push(format!("terminate:{cell_id}"));
Ok(RuntimeCodeModeWaitOutcome::LiveCell(
RuntimeCodeModeResponse::Terminated {
cell_id,
output: String::new(),
},
))
})
}
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()> {
Box::pin(async move {
self.operations
.lock()
.expect("record shutdown")
.push("shutdown".to_string());
Ok(())
})
}
}
struct RecordingProvider {
session: RuntimeCodeModeSessionHandle,
creates: AtomicUsize,
availability_error: Option<String>,
}
impl RecordingProvider {
fn new(session: Arc<RecordingSession>) -> Self {
Self {
session: RuntimeCodeModeSessionHandle::new(session),
creates: AtomicUsize::new(0),
availability_error: None,
}
}
fn unavailable(session: Arc<RecordingSession>) -> Self {
Self {
availability_error: Some("isolated host is unavailable".to_string()),
..Self::new(session)
}
}
}
impl RuntimeCodeModeSessionProvider for RecordingProvider {
fn availability(&self) -> Result<(), String> {
self.availability_error.clone().map_or(Ok(()), Err)
}
fn create_session<'a>(
&'a self,
_delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
Box::pin(async move {
self.creates.fetch_add(1, Ordering::SeqCst);
Ok(self.session.clone())
})
}
}
fn factory(provider: Arc<RecordingProvider>) -> RuntimeCodeModeServiceFactory {
RuntimeCodeModeServiceFactory::new(provider, |_thread_id| {
Ok(Arc::new(NoopRuntimeCodeModeSessionDelegate))
})
}
#[derive(Default)]
struct DelegatingSession {
delegate: Mutex<Option<Arc<dyn RuntimeCodeModeSessionDelegate>>>,
}
impl RuntimeCodeModeSession for DelegatingSession {
fn execute<'a>(
&'a self,
_request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
let delegate = self
.delegate
.lock()
.expect("delegating session delegate poisoned")
.clone()
.expect("provider delegate installed before execute");
Box::pin(async move {
let cell_id = RuntimeCodeModeCellId::new("cell-nested");
Ok(RuntimeCodeModeStartedCell::new(
cell_id.clone(),
Box::pin(async move {
let nested = delegate
.invoke_tool(
RuntimeCodeModeNestedToolCall {
cell_id: cell_id.clone(),
runtime_tool_call_id: "nested-call-1".to_string(),
tool_name: "read".to_string(),
input: Some(serde_json::json!({"path": "README.md"})),
},
CancellationToken::new(),
)
.await?;
Ok(RuntimeCodeModeResponse::Result {
cell_id,
output: nested.to_string(),
error_text: None,
})
}),
))
})
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
Ok(RuntimeCodeModeWaitOutcome::MissingCell(
RuntimeCodeModeResponse::Result {
cell_id: request.cell_id,
output: String::new(),
error_text: Some("cell not found".to_string()),
},
))
})
}
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
Ok(RuntimeCodeModeWaitOutcome::MissingCell(
RuntimeCodeModeResponse::Result {
cell_id,
output: String::new(),
error_text: Some("cell not found".to_string()),
},
))
})
}
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()> {
Box::pin(async { Ok(()) })
}
}
#[derive(Default)]
struct RecordingNestedDelegate {
calls: Mutex<Vec<RuntimeCodeModeNestedToolCall>>,
}
impl RuntimeCodeModeSessionDelegate for RecordingNestedDelegate {
fn invoke_tool<'a>(
&'a self,
invocation: RuntimeCodeModeNestedToolCall,
_cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, serde_json::Value> {
self.calls
.lock()
.expect("nested delegate calls poisoned")
.push(invocation);
Box::pin(async { Ok(serde_json::json!({"content": "ok"})) })
}
fn notify<'a>(
&'a self,
_tool_call_id: String,
_cell_id: RuntimeCodeModeCellId,
_text: String,
_cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn cell_closed(&self, _cell_id: &RuntimeCodeModeCellId) {}
}
struct DelegatingProvider {
session: Arc<DelegatingSession>,
}
impl RuntimeCodeModeSessionProvider for DelegatingProvider {
fn create_session<'a>(
&'a self,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
self.session
.delegate
.lock()
.expect("delegating provider delegate poisoned")
.replace(delegate);
let session: Arc<dyn RuntimeCodeModeSession> = self.session.clone();
let session = RuntimeCodeModeSessionHandle::new(session);
Box::pin(async move { Ok(session) })
}
}
#[tokio::test]
async fn registry_requires_stable_thread_identity_and_skips_unavailable_provider() {
let runtime = Arc::new(RecordingSession::default());
let provider = Arc::new(RecordingProvider::unavailable(Arc::clone(&runtime)));
let registry = RuntimeSessionRegistry::with_code_mode(factory(Arc::clone(&provider)));
let session = registry
.get_or_create("session-identity", "thread-identity")
.await
.expect("bind canonical identity");
assert_eq!(session.thread_id(), "thread-identity");
assert!(registry
.get_or_create("session-identity", "thread-other")
.await
.is_err());
let task = RuntimeSessionClosureTask::new(
"turn-identity",
Vec::new(),
move |context, _input, _cancel| {
Box::pin(async move {
if context.thread_id() != "thread-identity" {
return Err(RuntimeSessionTaskFailure {
message: "task context lost canonical thread identity".to_string(),
reason_code: None,
});
}
if context.code_mode_session().is_some() {
return Err(RuntimeSessionTaskFailure {
message: "unavailable provider created a CodeMode owner".to_string(),
reason_code: None,
});
}
Ok(())
})
},
);
let submission = session
.submit(Arc::new(task), false)
.await
.expect("submit identity task");
assert_eq!(
submission.completion.await.expect("identity completion"),
Ok(RuntimeSessionTaskOutcome::Completed)
);
registry
.shutdown("session-identity")
.await
.expect("shutdown identity actor");
assert_eq!(provider.creates.load(Ordering::SeqCst), 0);
assert!(runtime.operations().is_empty());
}
#[tokio::test]
async fn unused_service_shutdown_does_not_create_a_runtime_session() {
let runtime = Arc::new(RecordingSession::default());
let provider = Arc::new(RecordingProvider::new(Arc::clone(&runtime)));
let registry = RuntimeSessionRegistry::with_code_mode(factory(Arc::clone(&provider)));
registry
.get_or_create("session-unused", "thread-unused")
.await
.expect("bind unused service");
registry
.shutdown("session-unused")
.await
.expect("shutdown unused service");
assert_eq!(provider.creates.load(Ordering::SeqCst), 0);
assert!(runtime.operations().is_empty());
}
#[tokio::test]
async fn actor_interrupt_terminates_active_cells_and_shutdown_closes_the_session() {
let runtime = Arc::new(RecordingSession::default());
let provider = Arc::new(RecordingProvider::new(Arc::clone(&runtime)));
let registry = RuntimeSessionRegistry::with_code_mode(factory(Arc::clone(&provider)));
let session = registry
.get_or_create("session-active", "thread-active")
.await
.expect("bind active service");
let (started_tx, started_rx) = oneshot::channel();
let started_tx = Arc::new(Mutex::new(Some(started_tx)));
let task = RuntimeSessionClosureTask::new(
"turn-active",
Vec::new(),
move |context, _input, _cancel| {
let started_tx = Arc::clone(&started_tx);
Box::pin(async move {
let code_mode =
context
.code_mode_session()
.ok_or_else(|| RuntimeSessionTaskFailure {
message: "CodeMode service is unavailable".to_string(),
reason_code: None,
})?;
let started = code_mode
.execute(RuntimeCodeModeExecuteRequest {
tool_call_id: "call-active".to_string(),
source: "await tools.read({ path: 'README.md' })".to_string(),
enabled_tools: Vec::new(),
yield_time_ms: None,
max_output_tokens: None,
cancellation_token: None,
})
.await
.map_err(|message| RuntimeSessionTaskFailure {
message,
reason_code: None,
})?;
if let Some(sender) = started_tx.lock().expect("started sender poisoned").take() {
let _ = sender.send(started.cell_id.clone());
}
started
.initial_response()
.await
.map(|_| ())
.map_err(|message| RuntimeSessionTaskFailure {
message,
reason_code: None,
})
})
},
);
let submission = session
.submit(Arc::new(task), false)
.await
.expect("submit active CodeMode task");
assert_eq!(
started_rx.await.expect("active cell id"),
RuntimeCodeModeCellId::new("cell-active")
);
assert!(session.interrupt().await.expect("interrupt active actor"));
assert_eq!(
submission.completion.await.expect("interrupt completion"),
Ok(RuntimeSessionTaskOutcome::Interrupted)
);
registry
.shutdown("session-active")
.await
.expect("shutdown active service");
assert_eq!(provider.creates.load(Ordering::SeqCst), 1);
assert_eq!(
runtime.operations(),
vec![
"execute:call-active".to_string(),
"terminate:cell-active".to_string(),
"shutdown".to_string(),
]
);
}
#[tokio::test]
async fn execute_binds_a_cell_delegate_before_nested_dispatch() {
let session = Arc::new(DelegatingSession::default());
let provider = Arc::new(DelegatingProvider {
session: Arc::clone(&session),
});
let service = factory_for_delegating_provider(provider)
.create("thread-nested")
.expect("create CodeMode service");
let handle = service.session_handle();
let nested = Arc::new(RecordingNestedDelegate::default());
let started = handle
.execute_with_delegate(
RuntimeCodeModeExecuteRequest {
tool_call_id: "call-nested".to_string(),
source: "await tools.read({ path: 'README.md' })".to_string(),
enabled_tools: Vec::new(),
yield_time_ms: None,
max_output_tokens: None,
cancellation_token: None,
},
Some(nested.clone()),
)
.await
.expect("start nested cell");
let response = started.initial_response().await.expect("nested response");
assert!(response.is_terminal());
let calls = nested.calls.lock().expect("nested calls").clone();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].cell_id, RuntimeCodeModeCellId::new("cell-nested"));
assert_eq!(calls[0].tool_name, "read");
service.shutdown().await.expect("shutdown nested service");
}
#[tokio::test]
async fn late_nested_dispatch_after_cell_completion_fails_without_waiting() {
let session = Arc::new(DelegatingSession::default());
let provider = Arc::new(DelegatingProvider {
session: Arc::clone(&session),
});
let service = factory_for_delegating_provider(provider)
.create("thread-late-dispatch")
.expect("create CodeMode service");
let handle = service.session_handle();
let started = handle
.execute_with_delegate(
RuntimeCodeModeExecuteRequest {
tool_call_id: "call-late".to_string(),
source: "1".to_string(),
enabled_tools: Vec::new(),
yield_time_ms: None,
max_output_tokens: None,
cancellation_token: None,
},
Some(Arc::new(RecordingNestedDelegate::default())),
)
.await
.expect("start late-dispatch cell");
let cell_id = started.cell_id.clone();
started
.initial_response()
.await
.expect("complete late-dispatch cell");
let delegate = session
.delegate
.lock()
.expect("delegating session delegate poisoned")
.clone()
.expect("provider delegate installed before late dispatch");
let result = timeout(
Duration::from_millis(100),
delegate.notify(
"late-notify".to_string(),
cell_id.clone(),
"late output".to_string(),
CancellationToken::new(),
),
)
.await
.expect("late notify must not wait on a recreated gate");
assert_eq!(
result.expect_err("closed cell must reject late notify"),
format!("code mode cell {cell_id} is already closed")
);
service
.shutdown()
.await
.expect("shutdown late-dispatch service");
}
fn factory_for_delegating_provider(
provider: Arc<DelegatingProvider>,
) -> RuntimeCodeModeServiceFactory {
RuntimeCodeModeServiceFactory::new(provider, |_thread_id| {
Ok(Arc::new(NoopRuntimeCodeModeSessionDelegate))
})
}
+1
View File
@@ -1,4 +1,5 @@
pub mod action_required;
pub mod code_mode;
pub mod event_stream;
pub mod identity;
pub mod provider_trace;
+308 -41
View File
@@ -18,11 +18,11 @@ use agent_protocol::world_state::{RuntimeWorldState, WORLD_STATE_TURN_METADATA_K
use futures::future::join_all;
use futures::StreamExt;
use model_provider::current_client::{
CanonicalLlmEvent, CurrentProvider, CurrentProviderContent, CurrentProviderError,
CurrentProviderMessage, CurrentProviderRequest, CurrentProviderRole, CurrentProviderStream,
CurrentProviderTool, CurrentProviderToolCall, CurrentProviderToolResult, CurrentProviderUsage,
FailureClassification, FinishReason, GenerationOptions, ModelVerification, ProviderMetadata,
ToolResultValue, Usage,
CanonicalLlmEvent, CurrentProvider, CurrentProviderContent, CurrentProviderCustomToolCall,
CurrentProviderError, CurrentProviderMessage, CurrentProviderRequest, CurrentProviderRole,
CurrentProviderStream, CurrentProviderTool, CurrentProviderToolCall, CurrentProviderToolResult,
CurrentProviderUsage, FailureClassification, FinishReason, GenerationOptions,
ModelVerification, ProviderMetadata, ToolResultValue, Usage,
};
use model_provider::provider_stream::RuntimeReplyModelRequestPolicy;
use model_provider::provider_stream::RuntimeReplyProviderTraceMetadata;
@@ -34,8 +34,15 @@ use std::sync::Arc;
use std::time::Duration;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use tool_runtime::code_mode::{
RuntimeCodeModeSessionHandle, RuntimeCodeModeTool, CODE_MODE_EXEC_TOOL_NAME,
CODE_MODE_WAIT_TOOL_NAME,
};
use tool_runtime::hook_lifecycle::RuntimeHookReporter;
use tool_runtime::tool_call::{ToolCall, ToolEnvironment};
use tool_runtime::tool_call_surface::{
repair_tool_call, runtime_tool_call_canonical_name, ToolCallRepairOutcome,
};
use tool_runtime::tool_definition::{RuntimeToolDefinition, RuntimeToolExposure};
use tool_runtime::tool_executor::{
RuntimeToolExecutionContext, RuntimeToolExecutionContextInput, RuntimeToolExecutionError,
@@ -45,8 +52,10 @@ use tool_runtime::tool_executor::{
use tool_runtime::tool_lifecycle::ToolLifecycleEmitter;
use tool_runtime::turn_snapshot::{RuntimeHookSnapshot, RuntimeToolIdentity, RuntimeToolSnapshot};
mod code_mode;
mod input;
mod output_lifecycle;
use code_mode::PendingProviderToolCall;
#[cfg(test)]
use input::runtime_inter_agent_text;
use input::runtime_session_input_message;
@@ -58,6 +67,8 @@ use tool_runtime::tool_result_projection::NormalizedToolOutput;
const LOCAL_TOOL_ENVIRONMENT_ID: &str = "local";
const PROVIDER_TOOL_ENVIRONMENT_ID: &str = "provider";
const INVALID_TOOL_CALL_NAME: &str = "invalid";
const TOOL_CALL_REPAIR_METADATA_KEY: &str = "tool_call_repair";
const DEFAULT_FIRST_VISIBLE_OUTPUT_TIMEOUT: Duration = Duration::from_secs(60);
const DEFAULT_PROVIDER_STEP_TIMEOUT: Duration = Duration::from_secs(300);
@@ -67,6 +78,8 @@ pub struct RuntimeToolStepSnapshot {
pub executor: RuntimeToolExecutorHandle,
serial_tool_names: Arc<HashSet<String>>,
tool_environment_ids: Arc<HashMap<String, String>>,
code_mode_session: Option<RuntimeCodeModeSessionHandle>,
code_mode_tools: Arc<Vec<RuntimeCodeModeTool>>,
}
impl RuntimeToolStepSnapshot {
@@ -79,6 +92,8 @@ impl RuntimeToolStepSnapshot {
executor,
serial_tool_names: Arc::new(HashSet::new()),
tool_environment_ids: Arc::new(HashMap::new()),
code_mode_session: None,
code_mode_tools: Arc::new(Vec::new()),
}
}
@@ -93,9 +108,21 @@ impl RuntimeToolStepSnapshot {
executor,
serial_tool_names: Arc::new(serial_tool_names.into_iter().collect()),
tool_environment_ids: Arc::new(tool_environment_ids.into_iter().collect()),
code_mode_session: None,
code_mode_tools: Arc::new(Vec::new()),
}
}
pub fn with_code_mode_session(
mut self,
session: RuntimeCodeModeSessionHandle,
tools: Vec<RuntimeCodeModeTool>,
) -> Self {
self.code_mode_session = Some(session);
self.code_mode_tools = Arc::new(tools);
self
}
fn supports_parallel_tool_calls(&self, tool_name: &str) -> bool {
!self.serial_tool_names.contains(tool_name)
}
@@ -106,6 +133,10 @@ impl RuntimeToolStepSnapshot {
.map(String::as_str)
.unwrap_or(LOCAL_TOOL_ENVIRONMENT_ID)
}
fn code_mode_session(&self) -> Option<&RuntimeCodeModeSessionHandle> {
self.code_mode_session.as_ref()
}
}
pub type RuntimeToolStepSnapshotFuture<'a> =
@@ -433,15 +464,22 @@ where
}
}
let tools = tool_step_snapshot
let mut tools = tool_step_snapshot
.definitions
.iter()
.map(|definition| CurrentProviderTool {
name: definition.name.clone(),
description: definition.description.clone(),
input_schema: definition.input_schema.clone(),
.map(|definition| {
CurrentProviderTool::function(
definition.name.clone(),
definition.description.clone(),
definition.input_schema.clone(),
)
})
.collect::<Vec<_>>();
if tool_step_snapshot.code_mode_session().is_some() {
tools.extend(code_mode::advertised_tools(
&tool_step_snapshot.code_mode_tools,
));
}
let request_metadata = session_config
.thread_id
@@ -472,7 +510,7 @@ where
)
});
if let Some(trace) = provider_trace_attempt.as_ref() {
let tool_names = tools.iter().map(|tool| tool.name.clone());
let tool_names = tools.iter().map(|tool| tool.name().to_string());
emit_provider_trace(
&mut on_event,
provider_trace_metadata.as_ref(),
@@ -552,7 +590,7 @@ where
}
};
let mut assistant_content = Vec::new();
let mut calls = Vec::new();
let mut pending_calls = Vec::new();
let mut provider_executed_calls = HashMap::<String, ToolCall>::new();
let mut completed = false;
let mut tool_arguments = HashMap::<String, String>::new();
@@ -854,6 +892,7 @@ where
id,
name,
input,
raw_arguments,
provider_executed,
provider_metadata,
} => {
@@ -884,10 +923,70 @@ where
provider_executed_calls.insert(id, call);
continue;
}
let call = CurrentProviderToolCall::new(id, name, input)
.with_provider_metadata(provider_metadata);
let call = if name == CODE_MODE_WAIT_TOOL_NAME
&& tool_step_snapshot.code_mode_session().is_some()
{
let raw_arguments = raw_arguments.unwrap_or_else(|| {
serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string())
});
CurrentProviderToolCall::from_raw(id, name, raw_arguments)
.with_provider_metadata(provider_metadata)
} else {
prepare_provider_tool_call(
&tool_step_snapshot,
id,
name,
input,
raw_arguments,
provider_metadata,
)
};
assistant_content.push(CurrentProviderContent::ToolCall(call.clone()));
calls.push(call);
if call.name == CODE_MODE_WAIT_TOOL_NAME
&& tool_step_snapshot.code_mode_session().is_some()
{
pending_calls.push(PendingProviderToolCall::CodeModeWait(call));
} else {
pending_calls.push(PendingProviderToolCall::Function(call));
}
}
CanonicalLlmEvent::CustomToolCall {
id,
name,
input,
namespace,
provider_metadata,
} => {
if name != CODE_MODE_EXEC_TOOL_NAME {
return Err(provider_attempt_error(
format!("unsupported custom tool call: {name}"),
emitted_any,
Some(FailureClassification::InvalidRequest),
false,
None,
consumed_pending_input,
));
}
if tool_step_snapshot.code_mode_session().is_none() {
return Err(provider_attempt_error(
"custom tool call requires an executable CodeMode session",
emitted_any,
Some(FailureClassification::InvalidRequest),
false,
None,
consumed_pending_input,
));
};
emitted_any = true;
has_user_visible_output = true;
step_emitted_tool_call = true;
let mut custom_call =
CurrentProviderCustomToolCall::new(id.clone(), name.clone(), input.clone());
custom_call.namespace = namespace;
custom_call.provider_metadata = provider_metadata;
assistant_content
.push(CurrentProviderContent::CustomToolCall(custom_call.clone()));
pending_calls.push(PendingProviderToolCall::Custom(custom_call));
}
CanonicalLlmEvent::ToolResult {
id,
@@ -1019,7 +1118,7 @@ where
&mut pending_text_item_ids,
&mut on_event,
);
let text_phase = if calls.is_empty() {
let text_phase = if pending_calls.is_empty() {
CurrentProviderTextPhase::FinalAnswer
} else {
CurrentProviderTextPhase::Commentary
@@ -1036,7 +1135,7 @@ where
finish_reason: finish_reason.clone(),
text_output_chars: step_text_output_chars,
reasoning_output_chars: step_reasoning_output_chars,
tool_call_count: calls.len().min(u32::MAX as usize) as u32,
tool_call_count: pending_calls.len().min(u32::MAX as usize) as u32,
usage: step_usage.clone(),
});
record_session_token_usage(pending_input.as_ref(), step_usage.as_ref()).await;
@@ -1051,7 +1150,7 @@ where
if assistant_message_pushed {
initial_messages.push(CurrentProviderMessage::assistant(assistant_content));
}
if calls.is_empty() {
if pending_calls.is_empty() {
let pending_messages = match pending_input.as_ref() {
Some(input) => {
input.mark_mailbox_delivery_for_next_turn().await;
@@ -1204,27 +1303,98 @@ where
None => Vec::new(),
};
let results = execute_calls(
&tool_step_snapshot,
&turn_id,
&session_config.id,
session_config.turn_context.as_ref(),
&working_directory,
cancel_token.clone(),
tool_lifecycle_emitter.clone(),
calls,
model_request_policy
.as_ref()
.and_then(RuntimeReplyModelRequestPolicy::parallel_tool_calls)
.unwrap_or(false),
)
.await;
initial_messages.push(CurrentProviderMessage::tool(
results
.into_iter()
.map(CurrentProviderContent::ToolResult)
.collect(),
));
let function_calls = pending_calls
.iter()
.filter_map(|call| match call {
PendingProviderToolCall::Function(call) => Some(call.clone()),
PendingProviderToolCall::Custom(_) | PendingProviderToolCall::CodeModeWait(_) => {
None
}
})
.collect::<Vec<_>>();
let custom_calls = pending_calls
.iter()
.filter_map(|call| match call {
PendingProviderToolCall::Function(_) | PendingProviderToolCall::CodeModeWait(_) => {
None
}
PendingProviderToolCall::Custom(call) => Some(call.clone()),
})
.collect::<Vec<_>>();
let wait_calls = pending_calls
.iter()
.filter_map(|call| match call {
PendingProviderToolCall::Function(_) | PendingProviderToolCall::Custom(_) => None,
PendingProviderToolCall::CodeModeWait(call) => Some(call.clone()),
})
.collect::<Vec<_>>();
let code_mode_notification_sink = code_mode::CodeModeNotificationSink::default();
let allow_parallel = model_request_policy
.as_ref()
.and_then(RuntimeReplyModelRequestPolicy::parallel_tool_calls)
.unwrap_or(false);
let (function_results, custom_results, wait_results) = tokio::join!(
execute_calls(
&tool_step_snapshot,
&turn_id,
&session_config.id,
session_config.turn_context.as_ref(),
&working_directory,
cancel_token.clone(),
tool_lifecycle_emitter.clone(),
function_calls,
allow_parallel,
),
code_mode::execute_calls(
&tool_step_snapshot,
&turn_id,
&session_config.id,
session_config.turn_context.as_ref(),
&working_directory,
tool_lifecycle_emitter.clone(),
code_mode_notification_sink.clone(),
custom_calls,
cancel_token.clone(),
allow_parallel,
),
code_mode::execute_wait_calls(
&tool_step_snapshot,
&turn_id,
&working_directory,
tool_lifecycle_emitter.clone(),
wait_calls,
cancel_token.clone(),
allow_parallel,
),
);
let mut function_results = function_results.into_iter();
let custom_notifications = custom_results.notifications;
let mut custom_results = custom_results.results.into_iter();
let mut wait_results = wait_results.into_iter();
let mut result_content = custom_notifications
.into_iter()
.map(CurrentProviderContent::CustomToolResult)
.collect::<Vec<_>>();
result_content.extend(pending_calls.into_iter().map(|call| {
match call {
PendingProviderToolCall::Function(_) => CurrentProviderContent::ToolResult(
function_results
.next()
.expect("function result count must match pending calls"),
),
PendingProviderToolCall::Custom(_) => CurrentProviderContent::CustomToolResult(
custom_results
.next()
.expect("custom result count must match pending calls"),
),
PendingProviderToolCall::CodeModeWait(_) => CurrentProviderContent::ToolResult(
wait_results
.next()
.expect("wait result count must match pending calls"),
),
}
}));
initial_messages.push(CurrentProviderMessage::tool(result_content));
initial_messages.extend(pending_messages);
}
}
@@ -1738,7 +1908,19 @@ async fn execute_calls(
) -> Vec<CurrentProviderToolResult> {
let parallel_execution = Arc::new(tokio::sync::RwLock::new(()));
let execute = |call: CurrentProviderToolCall| {
let (definition, step_executor, advertised, environment_id) =
let (definition, step_executor, advertised, environment_id) = if call.name
== INVALID_TOOL_CALL_NAME
&& call
.provider_metadata
.contains_key(TOOL_CALL_REPAIR_METADATA_KEY)
{
(
invalid_runtime_tool_definition(),
RuntimeToolExecutorHandle::new(Arc::new(InvalidStepToolExecutor)),
false,
LOCAL_TOOL_ENVIRONMENT_ID.to_string(),
)
} else {
match runtime_tool_definition_for_call(&tool_step_snapshot.definitions, &call) {
Some(definition) => (
definition,
@@ -1752,7 +1934,8 @@ async fn execute_calls(
false,
LOCAL_TOOL_ENVIRONMENT_ID.to_string(),
),
};
}
};
let supports_parallel = allow_parallel
&& tool_step_snapshot.supports_parallel_tool_calls(&call.name)
&& advertised;
@@ -1802,6 +1985,56 @@ async fn execute_calls(
results
}
fn prepare_provider_tool_call(
tool_step_snapshot: &RuntimeToolStepSnapshot,
id: String,
name: String,
input: serde_json::Value,
raw_arguments: Option<String>,
mut provider_metadata: ProviderMetadata,
) -> CurrentProviderToolCall {
let raw_arguments = raw_arguments
.unwrap_or_else(|| serde_json::to_string(&input).unwrap_or_else(|_| "{}".to_string()));
let outcome = repair_tool_call(
&tool_step_snapshot.definitions,
&name,
&raw_arguments,
&runtime_tool_call_canonical_name,
);
match outcome {
ToolCallRepairOutcome::Ready(repair) => {
if repair.requested_name != repair.resolved_name || !repair.argument_changes.is_empty()
{
provider_metadata.insert(
TOOL_CALL_REPAIR_METADATA_KEY.to_string(),
serde_json::to_value(ToolCallRepairOutcome::Ready(repair.clone()))
.expect("tool call repair outcome must serialize"),
);
}
CurrentProviderToolCall::new(
id,
repair.resolved_name,
serde_json::Value::Object(repair.arguments),
)
.with_provider_metadata(provider_metadata)
}
ToolCallRepairOutcome::Invalid(failure) => {
provider_metadata.insert(
TOOL_CALL_REPAIR_METADATA_KEY.to_string(),
serde_json::to_value(ToolCallRepairOutcome::Invalid(failure.clone()))
.expect("tool call repair failure must serialize"),
);
CurrentProviderToolCall::new(
id,
INVALID_TOOL_CALL_NAME,
serde_json::Value::Object(failure.model_arguments()),
)
.with_provider_metadata(provider_metadata)
}
}
}
fn runtime_tool_definition_for_call(
definitions: &[RuntimeToolDefinition],
call: &CurrentProviderToolCall,
@@ -1820,8 +2053,42 @@ fn unavailable_runtime_tool_definition(call: &CurrentProviderToolCall) -> Runtim
)
}
fn invalid_runtime_tool_definition() -> RuntimeToolDefinition {
RuntimeToolDefinition::new(
INVALID_TOOL_CALL_NAME,
"Provider tool call rejected before handler execution",
serde_json::json!({
"type": "object",
"required": ["tool", "error"],
"properties": {
"tool": { "type": "string" },
"error": { "type": "string" }
}
}),
)
}
struct UnavailableStepToolExecutor;
struct InvalidStepToolExecutor;
impl RuntimeToolExecutor for InvalidStepToolExecutor {
fn execute<'a>(
&'a self,
request: RuntimeToolExecutionRequest<'a>,
) -> RuntimeToolExecutionFuture<'a> {
Box::pin(async move {
let message = request
.params
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("Provider tool call was invalid")
.to_string();
Err(RuntimeToolExecutionError::new(message, None).before_handler())
})
}
}
impl RuntimeToolExecutor for UnavailableStepToolExecutor {
fn execute<'a>(
&'a self,
@@ -0,0 +1,539 @@
use super::RuntimeToolStepSnapshot;
use futures::future::join_all;
use model_provider::current_client::{
CurrentProviderCustomToolCall, CurrentProviderTool, CurrentProviderToolCall,
CurrentProviderToolResult, FreeformToolFormat,
};
use serde::Deserialize;
use serde_json::Value;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio_util::sync::CancellationToken;
use tool_runtime::code_mode::{
code_mode_exec_tool_description, code_mode_wait_tool_definition, parse_code_mode_exec_source,
RuntimeCodeModeCellId, RuntimeCodeModeExecuteRequest, RuntimeCodeModeFuture,
RuntimeCodeModeNestedToolCall, RuntimeCodeModeSessionDelegate, RuntimeCodeModeSessionHandle,
RuntimeCodeModeTool, RuntimeCodeModeToolResult, RuntimeCodeModeWaitOutcome,
RuntimeCodeModeWaitRequest, CODE_MODE_EXEC_FREEFORM_GRAMMAR, CODE_MODE_EXEC_TOOL_NAME,
DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS, DEFAULT_CODE_MODE_WAIT_YIELD_TIME_MS,
};
use tool_runtime::tool_call::{ToolCall, ToolEnvironment};
use tool_runtime::tool_definition::RuntimeToolExposure;
use tool_runtime::tool_executor::{
RuntimeToolExecutionContext, RuntimeToolExecutionContextInput, RuntimeToolExecutorHandle,
};
use tool_runtime::tool_lifecycle::{ToolLifecycleEmitter, ToolOutputDeltaEvent};
use tool_runtime::tool_result_projection::NormalizedToolOutput;
pub(super) enum PendingProviderToolCall {
Function(CurrentProviderToolCall),
Custom(CurrentProviderCustomToolCall),
CodeModeWait(CurrentProviderToolCall),
}
#[derive(Clone, Default)]
pub(super) struct CodeModeNotificationSink {
outputs: Arc<Mutex<Vec<CurrentProviderToolResult>>>,
}
impl CodeModeNotificationSink {
fn push(&self, output: CurrentProviderToolResult) {
self.outputs
.lock()
.expect("code mode notification sink")
.push(output);
}
pub(super) fn drain(&self) -> Vec<CurrentProviderToolResult> {
std::mem::take(&mut *self.outputs.lock().expect("code mode notification sink"))
}
}
pub(super) struct CodeModeExecutionBatch {
pub(super) results: Vec<CurrentProviderToolResult>,
pub(super) notifications: Vec<CurrentProviderToolResult>,
}
#[derive(Debug, Deserialize)]
struct RuntimeCodeModeWaitArgs {
cell_id: String,
#[serde(default = "default_code_mode_wait_yield_time_ms")]
yield_time_ms: u64,
#[serde(default)]
max_tokens: Option<usize>,
#[serde(default)]
terminate: bool,
}
fn default_code_mode_wait_yield_time_ms() -> u64 {
DEFAULT_CODE_MODE_WAIT_YIELD_TIME_MS
}
pub(super) fn advertised_tools(tools: &[RuntimeCodeModeTool]) -> Vec<CurrentProviderTool> {
let wait = code_mode_wait_tool_definition();
vec![
CurrentProviderTool::custom(
CODE_MODE_EXEC_TOOL_NAME,
code_mode_exec_tool_description(tools),
FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: CODE_MODE_EXEC_FREEFORM_GRAMMAR.to_string(),
},
),
CurrentProviderTool::function(wait.name, wait.description, wait.input_schema),
]
}
pub(super) async fn execute_calls(
tool_step_snapshot: &RuntimeToolStepSnapshot,
turn_id: &str,
session_id: &str,
turn_context: Option<&agent_protocol::turn_context::TurnContextOverride>,
working_directory: &std::path::Path,
lifecycle_emitter: Arc<dyn ToolLifecycleEmitter>,
notification_sink: CodeModeNotificationSink,
calls: Vec<CurrentProviderCustomToolCall>,
cancel_token: Option<CancellationToken>,
allow_parallel: bool,
) -> CodeModeExecutionBatch {
let Some(session) = tool_step_snapshot.code_mode_session().cloned() else {
return CodeModeExecutionBatch {
results: calls
.into_iter()
.map(|call| {
custom_provider_result(
call,
RuntimeCodeModeToolResult::failure(
RuntimeCodeModeCellId::new("unavailable"),
"custom tool call requires an executable CodeMode session",
),
)
})
.collect(),
notifications: Vec::new(),
};
};
let enabled_tools = Arc::clone(&tool_step_snapshot.code_mode_tools);
let execute = |call| {
let nested_delegate: Arc<dyn RuntimeCodeModeSessionDelegate> =
Arc::new(RuntimeCodeModeNestedToolDelegate {
executor: tool_step_snapshot.executor.clone(),
tools: Arc::clone(&enabled_tools),
turn_id: turn_id.to_string(),
session_id: session_id.to_string(),
turn_context: turn_context.cloned(),
working_directory: working_directory.to_path_buf(),
lifecycle_emitter: lifecycle_emitter.clone(),
notification_sink: notification_sink.clone(),
closed: Arc::new(AtomicBool::new(false)),
});
execute_call(
session.clone(),
Arc::clone(&enabled_tools),
nested_delegate,
turn_id.to_string(),
working_directory.to_path_buf(),
lifecycle_emitter.clone(),
cancel_token.clone(),
call,
)
};
let results = if allow_parallel && calls.len() > 1 {
join_all(calls.into_iter().map(execute)).await
} else {
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(execute(call).await);
}
results
};
CodeModeExecutionBatch {
results,
notifications: notification_sink.drain(),
}
}
pub(super) async fn execute_wait_calls(
tool_step_snapshot: &RuntimeToolStepSnapshot,
turn_id: &str,
working_directory: &std::path::Path,
lifecycle_emitter: Arc<dyn ToolLifecycleEmitter>,
calls: Vec<CurrentProviderToolCall>,
cancel_token: Option<CancellationToken>,
allow_parallel: bool,
) -> Vec<CurrentProviderToolResult> {
let Some(session) = tool_step_snapshot.code_mode_session().cloned() else {
return calls
.into_iter()
.map(|call| {
wait_provider_result(
call,
RuntimeCodeModeToolResult::failure(
RuntimeCodeModeCellId::new("unavailable"),
"wait requires an executable CodeMode session",
),
)
})
.collect();
};
let execute = |call| {
execute_wait_call(
session.clone(),
turn_id.to_string(),
working_directory.to_path_buf(),
lifecycle_emitter.clone(),
cancel_token.clone(),
call,
)
};
if allow_parallel && calls.len() > 1 {
join_all(calls.into_iter().map(execute)).await
} else {
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(execute(call).await);
}
results
}
}
async fn execute_wait_call(
session: RuntimeCodeModeSessionHandle,
turn_id: String,
working_directory: std::path::PathBuf,
lifecycle_emitter: Arc<dyn ToolLifecycleEmitter>,
cancel_token: Option<CancellationToken>,
call: CurrentProviderToolCall,
) -> CurrentProviderToolResult {
let started_at = Instant::now();
let tool_call = ToolCall::new(
turn_id,
call.id.clone(),
call.name.clone(),
call.arguments.clone(),
vec![ToolEnvironment::new("local", working_directory)],
lifecycle_emitter.clone(),
);
tool_call.emit_started().await;
let result = match serde_json::from_value::<RuntimeCodeModeWaitArgs>(call.arguments.clone()) {
Ok(args) => {
let cell_id = RuntimeCodeModeCellId::new(args.cell_id);
let max_tokens = args
.max_tokens
.unwrap_or(DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS);
let outcome = if args.terminate {
session.terminate(cell_id.clone()).await
} else {
let wait = session.wait(RuntimeCodeModeWaitRequest {
cell_id: cell_id.clone(),
yield_time_ms: args.yield_time_ms,
});
match cancel_token.as_ref() {
Some(cancel_token) => {
tokio::select! {
biased;
_ = cancel_token.cancelled() => session.terminate(cell_id.clone()).await,
outcome = wait => outcome,
}
}
None => wait.await,
}
};
match outcome {
Ok(outcome) => outcome
.into_response()
.into_tool_result_with_max_tokens(max_tokens),
Err(error) => RuntimeCodeModeToolResult::failure(cell_id, error),
}
}
Err(error) => RuntimeCodeModeToolResult::failure(
RuntimeCodeModeCellId::new("invalid"),
format!("failed to parse wait arguments: {error}"),
),
};
tool_call
.emit_completed(normalized_code_mode_output(&result, started_at))
.await;
wait_provider_result(call, result)
}
async fn execute_call(
session: RuntimeCodeModeSessionHandle,
enabled_tools: Arc<Vec<RuntimeCodeModeTool>>,
nested_delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
turn_id: String,
working_directory: std::path::PathBuf,
lifecycle_emitter: Arc<dyn ToolLifecycleEmitter>,
cancel_token: Option<CancellationToken>,
call: CurrentProviderCustomToolCall,
) -> CurrentProviderToolResult {
let started_at = Instant::now();
let fallback_cell_id = RuntimeCodeModeCellId::new(call.id.clone());
let tool_call = ToolCall::new(
turn_id,
call.id.clone(),
call.name.clone(),
Value::String(call.input.clone()),
vec![ToolEnvironment::new("local", working_directory)],
lifecycle_emitter.clone(),
);
tool_call.emit_started().await;
let parsed = match parse_code_mode_exec_source(&call.input) {
Ok(parsed) => parsed,
Err(error) => {
let result = RuntimeCodeModeToolResult::failure(fallback_cell_id, error);
tool_call
.emit_completed(normalized_code_mode_output(&result, started_at))
.await;
return custom_provider_result(call, result);
}
};
let max_output_tokens = parsed
.max_output_tokens
.unwrap_or(DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS);
let started = match session
.execute_with_delegate(
RuntimeCodeModeExecuteRequest {
tool_call_id: call.id.clone(),
source: parsed.code,
enabled_tools: enabled_tools.as_ref().clone(),
yield_time_ms: parsed.yield_time_ms,
max_output_tokens: parsed.max_output_tokens,
cancellation_token: cancel_token.clone(),
},
Some(nested_delegate),
)
.await
{
Ok(started) => started,
Err(error) => {
let result = RuntimeCodeModeToolResult::failure(fallback_cell_id, error);
tool_call
.emit_completed(normalized_code_mode_output(&result, started_at))
.await;
return custom_provider_result(call, result);
}
};
let cell_id = started.cell_id.clone();
let response = match cancel_token.as_ref() {
Some(cancel_token) => {
tokio::select! {
biased;
_ = cancel_token.cancelled() => session
.terminate(cell_id.clone())
.await
.map(RuntimeCodeModeWaitOutcome::into_response),
response = started.initial_response() => response,
}
}
None => started.initial_response().await,
};
let result = match response {
Ok(response) => response.into_tool_result_with_max_tokens(max_output_tokens),
Err(error) => RuntimeCodeModeToolResult::failure(cell_id, error),
};
tool_call
.emit_completed(normalized_code_mode_output(&result, started_at))
.await;
custom_provider_result(call, result)
}
fn normalized_code_mode_output(
result: &RuntimeCodeModeToolResult,
duration: Instant,
) -> NormalizedToolOutput {
let mut metadata = HashMap::from([
(
"code_mode_cell_id".to_string(),
Value::String(result.cell_id.as_str().to_string()),
),
("code_mode".to_string(), Value::Bool(true)),
]);
metadata.insert(
"code_mode_output_status".to_string(),
Value::String(if result.success { "success" } else { "failure" }.to_string()),
);
metadata.insert(
tool_runtime::tool_result_projection::TOOL_HANDLER_EXECUTED_METADATA_KEY.to_string(),
Value::Bool(true),
);
NormalizedToolOutput {
success: result.success,
text: result.output.clone(),
structured_content: None,
error: result.error.clone(),
duration_ms: u64::try_from(duration.elapsed().as_millis()).unwrap_or(u64::MAX),
truncation: None,
sidecar_reference: None,
metadata,
agent_control_projection_facts: Vec::new(),
agent_control_state_facts: Vec::new(),
}
}
struct RuntimeCodeModeNestedToolDelegate {
executor: RuntimeToolExecutorHandle,
tools: Arc<Vec<RuntimeCodeModeTool>>,
turn_id: String,
session_id: String,
turn_context: Option<agent_protocol::turn_context::TurnContextOverride>,
working_directory: std::path::PathBuf,
lifecycle_emitter: Arc<dyn ToolLifecycleEmitter>,
notification_sink: CodeModeNotificationSink,
closed: Arc<AtomicBool>,
}
impl RuntimeCodeModeSessionDelegate for RuntimeCodeModeNestedToolDelegate {
fn invoke_tool<'a>(
&'a self,
invocation: RuntimeCodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, serde_json::Value> {
Box::pin(async move {
if self.closed.load(Ordering::Acquire) {
return Err(format!(
"code mode cell {} is already closed",
invocation.cell_id
));
}
if matches!(
invocation.tool_name.as_str(),
CODE_MODE_EXEC_TOOL_NAME | tool_runtime::code_mode::CODE_MODE_WAIT_TOOL_NAME
) {
return Err("CodeMode cannot invoke its control tools as nested tools".to_string());
}
let tool = self
.tools
.iter()
.find(|tool| tool.global_name == invocation.tool_name)
.ok_or_else(|| {
format!(
"nested tool '{}' was not enabled for this sampling step",
invocation.tool_name
)
})?;
let context = RuntimeToolExecutionContext::new(RuntimeToolExecutionContextInput {
working_directory: self.working_directory.clone(),
session_id: self.session_id.clone(),
cancel_token: Some(cancellation_token),
workspace_sandbox: None,
});
let call = ToolCall::new(
self.turn_id.clone(),
format!("code-mode-{}", invocation.runtime_tool_call_id),
tool.definition.name.clone(),
invocation.input.unwrap_or(serde_json::Value::Null),
vec![ToolEnvironment::new(
"local",
self.working_directory.clone(),
)],
self.lifecycle_emitter.clone(),
);
let runtime_tool = self
.executor
.clone()
.bind(tool.definition.clone(), RuntimeToolExposure::Direct);
let output = runtime_tool
.execute_call(&call, &context, self.turn_context.as_ref())
.await;
if self.closed.load(Ordering::Acquire) {
return Err(format!(
"code mode cell {} is already closed",
invocation.cell_id
));
}
if output.success {
Ok(output
.structured_content
.unwrap_or_else(|| serde_json::Value::String(output.text)))
} else {
Err(output.error.unwrap_or(output.text))
}
})
}
fn notify<'a>(
&'a self,
tool_call_id: String,
cell_id: RuntimeCodeModeCellId,
text: String,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async move {
if cancellation_token.is_cancelled() || self.closed.load(Ordering::Acquire) {
return Err("code mode notification cancelled".to_string());
}
let text = text.trim().to_string();
if text.is_empty() {
return Ok(());
}
let provider_output = CurrentProviderToolResult {
call_id: tool_call_id.clone(),
name: CODE_MODE_EXEC_TOOL_NAME.to_string(),
success: true,
output: text.clone(),
error: None,
};
let metadata = HashMap::from([
(
"code_mode_cell_id".to_string(),
Value::String(cell_id.as_str().to_string()),
),
(
"notification_kind".to_string(),
Value::String("code_mode_notify".to_string()),
),
]);
self.lifecycle_emitter
.emit_output_delta(ToolOutputDeltaEvent {
turn_id: self.turn_id.clone(),
call_id: tool_call_id,
tool_name: CODE_MODE_EXEC_TOOL_NAME.to_string(),
delta: text,
output_kind: Some("code_mode_notify".to_string()),
metadata,
})
.await;
if cancellation_token.is_cancelled() || self.closed.load(Ordering::Acquire) {
return Err("code mode notification cancelled".to_string());
}
self.notification_sink.push(provider_output);
Ok(())
})
}
fn cell_closed(&self, _cell_id: &RuntimeCodeModeCellId) {
self.closed.store(true, Ordering::Release);
}
}
fn custom_provider_result(
call: CurrentProviderCustomToolCall,
result: RuntimeCodeModeToolResult,
) -> CurrentProviderToolResult {
CurrentProviderToolResult {
call_id: call.id,
name: call.name,
success: result.success,
output: result.output,
error: result.error,
}
}
fn wait_provider_result(
call: CurrentProviderToolCall,
result: RuntimeCodeModeToolResult,
) -> CurrentProviderToolResult {
CurrentProviderToolResult {
call_id: call.id,
name: call.name,
success: result.success,
output: result.output,
error: result.error,
}
}
File diff suppressed because it is too large Load Diff
@@ -381,7 +381,10 @@ mod tests {
async fn permission_action_preserves_identity_and_resolves_exactly_once() {
let state = Arc::new(ActionRequiredState::default());
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-permissions").await;
let session = registry
.get_or_create("session-permissions", "thread-permissions")
.await
.expect("bind permission session actor");
let (action_tx, action_rx) = oneshot::channel();
let action_tx = Arc::new(StdMutex::new(Some(action_tx)));
let (result_tx, result_rx) = oneshot::channel();
@@ -485,7 +488,10 @@ mod tests {
async fn permission_response_fails_closed_for_unsafe_session_review() {
let state = Arc::new(ActionRequiredState::default());
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-permissions").await;
let session = registry
.get_or_create("session-permissions", "thread-permissions")
.await
.expect("bind permission session actor");
let (action_tx, action_rx) = oneshot::channel();
let action_tx = Arc::new(StdMutex::new(Some(action_tx)));
let (result_tx, result_rx) = oneshot::channel();
@@ -1,4 +1,5 @@
use agent_protocol::turn_context::TurnContextOverride as AgentTurnContext;
use tool_runtime::code_mode::RuntimeToolMode;
pub struct AgentSessionConfigurationRequest {
pub session_id: String,
@@ -25,6 +26,8 @@ pub struct AgentSessionConfig {
pub system_prompt_override: Option<bool>,
pub include_context_trace: Option<bool>,
pub turn_context: Option<AgentTurnContext>,
pub tool_mode: RuntimeToolMode,
pub supports_custom_tools: bool,
}
pub fn build_agent_session_config(request: AgentSessionConfigurationRequest) -> AgentSessionConfig {
@@ -40,6 +43,8 @@ pub fn build_agent_session_config(request: AgentSessionConfigurationRequest) ->
system_prompt_override: Some(true),
include_context_trace: Some(request.include_context_trace),
turn_context: request.turn_context,
tool_mode: RuntimeToolMode::Direct,
supports_custom_tools: false,
}
}
@@ -59,6 +64,8 @@ pub struct SessionConfigBuilder {
system_prompt_override: Option<bool>,
include_context_trace: Option<bool>,
turn_context: Option<AgentTurnContext>,
tool_mode: RuntimeToolMode,
supports_custom_tools: bool,
}
impl SessionConfigBuilder {
@@ -75,6 +82,8 @@ impl SessionConfigBuilder {
system_prompt_override: None,
include_context_trace: None,
turn_context: None,
tool_mode: RuntimeToolMode::Direct,
supports_custom_tools: false,
}
}
@@ -128,6 +137,16 @@ impl SessionConfigBuilder {
self
}
pub fn tool_mode(mut self, tool_mode: RuntimeToolMode) -> Self {
self.tool_mode = tool_mode;
self
}
pub fn supports_custom_tools(mut self, supported: bool) -> Self {
self.supports_custom_tools = supported;
self
}
pub fn build(self) -> AgentSessionConfig {
AgentSessionConfig {
id: self.id,
@@ -141,6 +160,8 @@ impl SessionConfigBuilder {
system_prompt_override: self.system_prompt_override,
include_context_trace: self.include_context_trace,
turn_context: self.turn_context,
tool_mode: self.tool_mode,
supports_custom_tools: self.supports_custom_tools,
}
}
}
@@ -15,9 +15,10 @@ mod actor;
mod handle;
mod input_queue;
mod inter_agent;
mod registry;
mod resources;
mod step;
pub use actor::RuntimeSessionRegistry;
pub use handle::RuntimeSessionHandle;
pub use input_queue::{
RuntimeSessionClosureTask, RuntimeSessionInput, RuntimeSessionInputActivity,
@@ -29,6 +30,7 @@ pub use inter_agent::{
RuntimeSessionInterAgentDeliveryMode, RuntimeSessionInterAgentInput,
RuntimeSessionInterAgentMessageKind, RuntimeSessionInterAgentResultStatus,
};
pub use registry::RuntimeSessionRegistry;
pub use step::{RuntimeSessionStepContext, RuntimeSessionTokenUsage};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -73,6 +75,7 @@ pub struct RuntimeSessionSnapshot {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeSessionOperationContext {
pub session_id: String,
pub thread_id: String,
pub submission_id: String,
pub active_turn_id: Option<String>,
pub client_user_message_id: Option<String>,
@@ -19,7 +19,10 @@ fn waiting_task(turn_id: &str, kind: RuntimeSessionTaskKind) -> Arc<dyn RuntimeS
#[tokio::test]
async fn session_handle_activity_subscription_observes_steer() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-activity-handle").await;
let session = registry
.get_or_create("session-activity-handle", "thread-activity-handle")
.await
.expect("bind activity session actor");
let active = session
.submit(
waiting_task("turn-active", RuntimeSessionTaskKind::Regular),
@@ -62,7 +65,10 @@ async fn session_handle_activity_subscription_observes_steer() {
#[tokio::test]
async fn queued_user_admission_is_visible_to_late_activity_subscriber() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-queued-activity").await;
let session = registry
.get_or_create("session-queued-activity", "thread-queued-activity")
.await
.expect("bind queued activity session actor");
let active = session
.submit(
waiting_task("turn-review", RuntimeSessionTaskKind::Review),
@@ -2,18 +2,20 @@ use super::input_queue::{
PendingInputQueue, QueuedTask, RuntimeSessionTask, RuntimeSessionTaskContext,
RuntimeSessionTaskMetadata, RuntimeSessionTaskOutcome, RuntimeSessionTaskState,
};
use super::resources::RuntimeSessionResources;
use super::{
RuntimeSessionHandle, RuntimeSessionLoopError, RuntimeSessionOperation,
RuntimeSessionOperationContext, RuntimeSessionOperationResult,
RuntimeSessionOperationSubmission, RuntimeSessionResponseKind, RuntimeSessionSnapshot,
RuntimeSessionSubmitResult, RuntimeSessionTaskFailure,
};
use crate::code_mode::RuntimeCodeModeServiceFactory;
use crate::session_loop::handle::runtime_session_submission;
use futures::FutureExt;
use serde_json::Value;
use std::collections::{HashMap, VecDeque};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{mpsc, oneshot, watch, Mutex};
use tokio::sync::{mpsc, oneshot, watch};
use tokio::task::JoinHandle;
use tokio::time::{timeout_at, Duration, Instant};
use tokio_util::sync::CancellationToken;
@@ -55,111 +57,37 @@ struct TaskFinishedMessage {
result: Result<(), RuntimeSessionTaskFailure>,
}
#[derive(Clone, Default)]
pub struct RuntimeSessionRegistry {
sessions: Arc<Mutex<HashMap<String, RuntimeSessionHandle>>>,
}
impl RuntimeSessionRegistry {
pub async fn get_existing(&self, session_id: &str) -> Option<RuntimeSessionHandle> {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
}
pub async fn get_or_create(&self, session_id: &str) -> RuntimeSessionHandle {
let mut sessions = self.sessions.lock().await;
if let Some(handle) = sessions.get(session_id) {
return handle.clone();
}
let handle = RuntimeSessionActor::spawn(session_id.to_string());
sessions.insert(session_id.to_string(), handle.clone());
handle
}
pub async fn shutdown(&self, session_id: &str) -> Result<(), RuntimeSessionLoopError> {
let mut sessions = self.sessions.lock().await;
let Some(handle) = sessions.get(session_id).cloned() else {
return Ok(());
};
let result = handle.shutdown().await;
sessions.remove(session_id);
if let Err(error) = result {
return Err(error);
}
Ok(())
}
pub async fn notify_inter_agent_communication(
&self,
session_id: &str,
input: super::RuntimeSessionInterAgentInput,
) -> Result<bool, RuntimeSessionLoopError> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(false);
};
handle.notify_inter_agent_communication(input).await?;
Ok(true)
}
pub async fn subscribe_input_activity(
&self,
session_id: &str,
) -> Result<
Option<(
watch::Receiver<super::RuntimeSessionInputActivity>,
Option<super::RuntimeSessionInputActivity>,
)>,
RuntimeSessionLoopError,
> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(None);
};
handle.subscribe_input_activity().await.map(Some)
}
pub async fn snapshot(
&self,
session_id: &str,
) -> Result<Option<RuntimeSessionSnapshot>, RuntimeSessionLoopError> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(None);
};
handle.snapshot().await.map(Some)
}
}
pub(super) struct RuntimeSessionActor;
impl RuntimeSessionActor {
pub(super) fn spawn(session_id: String) -> RuntimeSessionHandle {
pub(super) fn spawn(
session_id: String,
thread_id: String,
code_mode_factory: Option<&RuntimeCodeModeServiceFactory>,
) -> RuntimeSessionHandle {
let (tx, rx) = mpsc::channel(SESSION_COMMAND_BUFFER);
let (finished_tx, finished_rx) = mpsc::unbounded_channel();
let (termination_tx, termination) = watch::channel(false);
let resources = Arc::new(RuntimeSessionResources::new(thread_id, code_mode_factory));
tokio::spawn(run_session_loop(
session_id,
Arc::clone(&resources),
rx,
finished_rx,
finished_tx,
termination_tx,
));
RuntimeSessionHandle { tx, termination }
RuntimeSessionHandle {
tx,
termination,
resources,
}
}
}
async fn run_session_loop(
session_id: String,
resources: Arc<RuntimeSessionResources>,
mut rx: mpsc::Receiver<RuntimeSessionCommand>,
mut finished_rx: mpsc::UnboundedReceiver<TaskFinishedMessage>,
finished_tx: mpsc::UnboundedSender<TaskFinishedMessage>,
@@ -226,6 +154,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -298,6 +227,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -320,6 +250,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -334,6 +265,7 @@ async fn run_session_loop(
| RuntimeSessionOperation::ReloadConfig { handler } => {
let context = RuntimeSessionOperationContext {
session_id: session_id.to_string(),
thread_id: resources.thread_id().to_string(),
submission_id: id.clone(),
active_turn_id: active
.as_ref()
@@ -354,6 +286,7 @@ async fn run_session_loop(
let turn_id = active_task.task.turn_id().to_string();
let context = RuntimeSessionOperationContext {
session_id: session_id.to_string(),
thread_id: resources.thread_id().to_string(),
submission_id: id.clone(),
active_turn_id: Some(turn_id.clone()),
client_user_message_id,
@@ -385,6 +318,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -506,6 +440,7 @@ async fn run_session_loop(
}
}
let interrupted = if let Some(active_task) = active.take() {
resources.interrupt_code_mode().await;
stop_active_task(active_task, RuntimeSessionTaskOutcome::Interrupted)
.await;
true
@@ -517,6 +452,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -539,10 +475,15 @@ async fn run_session_loop(
.send(Ok(RuntimeSessionTaskOutcome::Shutdown));
}
pending_input.clear().await;
let _ = reply.send(Ok(RuntimeSessionOperationResult::Accepted {
id,
turn_id: None,
}));
let result = resources
.shutdown_code_mode()
.await
.map_err(RuntimeSessionLoopError::OperationFailed)
.map(|()| RuntimeSessionOperationResult::Accepted {
id,
turn_id: None,
});
let _ = reply.send(result);
break;
}
}
@@ -565,6 +506,7 @@ async fn run_session_loop(
&mut active,
&mut queued,
Arc::clone(&session_id),
Arc::clone(&resources),
Arc::clone(&pending_input),
finished_tx.clone(),
&mut next_task_key,
@@ -584,6 +526,7 @@ async fn run_session_loop(
.send(Ok(RuntimeSessionTaskOutcome::Shutdown));
}
pending_input.clear().await;
let _ = resources.shutdown_code_mode().await;
termination_tx.send_replace(true);
}
@@ -643,6 +586,7 @@ async fn submit_task(
active: &mut Option<ActiveTask>,
queued: &mut VecDeque<QueuedTask>,
session_id: Arc<str>,
resources: Arc<RuntimeSessionResources>,
pending_input: Arc<PendingInputQueue>,
finished_tx: mpsc::UnboundedSender<TaskFinishedMessage>,
next_task_key: &mut u64,
@@ -655,6 +599,7 @@ async fn submit_task(
let (completion_tx, completion_rx) = oneshot::channel();
if replace_active {
if let Some(active_task) = active.take() {
resources.interrupt_code_mode().await;
stop_active_task(active_task, RuntimeSessionTaskOutcome::Replaced).await;
}
} else if active.is_some() {
@@ -690,6 +635,7 @@ async fn submit_task(
*active = Some(spawn_task(
session_id,
resources,
pending_input,
task,
input,
@@ -710,6 +656,7 @@ async fn submit_task(
fn spawn_task(
session_id: Arc<str>,
resources: Arc<RuntimeSessionResources>,
pending_input: Arc<PendingInputQueue>,
task: Arc<dyn RuntimeSessionTask>,
initial_input: Vec<super::RuntimeSessionInput>,
@@ -727,6 +674,7 @@ fn spawn_task(
metadata,
Arc::clone(&pending_input),
task.mailbox_loader(),
resources,
state,
);
let cancellation_token = CancellationToken::new();
@@ -767,6 +715,7 @@ fn start_next_task(
active: &mut Option<ActiveTask>,
queued: &mut VecDeque<QueuedTask>,
session_id: Arc<str>,
resources: Arc<RuntimeSessionResources>,
pending_input: Arc<PendingInputQueue>,
finished_tx: mpsc::UnboundedSender<TaskFinishedMessage>,
next_task_key: &mut u64,
@@ -776,6 +725,7 @@ fn start_next_task(
};
*active = Some(spawn_task(
session_id,
resources,
pending_input,
task.task,
task.input,
@@ -1,4 +1,5 @@
use super::actor::RuntimeSessionCommand;
use super::resources::RuntimeSessionResources;
use super::{
RuntimeSessionInput, RuntimeSessionInterAgentInput, RuntimeSessionLoopError,
RuntimeSessionOperation, RuntimeSessionOperationResult, RuntimeSessionOperationSubmission,
@@ -14,9 +15,14 @@ use tokio::sync::{mpsc, oneshot, watch};
pub struct RuntimeSessionHandle {
pub(super) tx: mpsc::Sender<RuntimeSessionCommand>,
pub(super) termination: watch::Receiver<bool>,
pub(super) resources: Arc<RuntimeSessionResources>,
}
impl RuntimeSessionHandle {
pub fn thread_id(&self) -> &str {
self.resources.thread_id()
}
pub async fn snapshot(&self) -> Result<RuntimeSessionSnapshot, RuntimeSessionLoopError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
@@ -1,3 +1,4 @@
use super::resources::RuntimeSessionResources;
use super::{
RuntimeSessionInterAgentInput, RuntimeSessionLoopError, RuntimeSessionStepContext,
RuntimeSessionTaskFailure, RuntimeSessionTokenUsage, RuntimeSessionTraceContext,
@@ -244,6 +245,7 @@ pub struct RuntimeSessionTaskContext {
metadata: RuntimeSessionTaskMetadata,
pending_input: Arc<PendingInputQueue>,
mailbox_loader: Option<RuntimeSessionMailboxLoader>,
resources: Arc<RuntimeSessionResources>,
state: Arc<RuntimeSessionTaskState>,
}
@@ -294,6 +296,7 @@ pub struct RuntimeSessionInputHandle {
pub(super) turn_id: Arc<str>,
pub(super) kind: RuntimeSessionTaskKind,
pub(super) mailbox_loader: Option<RuntimeSessionMailboxLoader>,
pub(super) resources: Arc<RuntimeSessionResources>,
pub(super) state: Arc<RuntimeSessionTaskState>,
}
@@ -327,6 +330,7 @@ impl RuntimeSessionTaskContext {
metadata: RuntimeSessionTaskMetadata,
pending_input: Arc<PendingInputQueue>,
mailbox_loader: Option<RuntimeSessionMailboxLoader>,
resources: Arc<RuntimeSessionResources>,
state: Arc<RuntimeSessionTaskState>,
) -> Self {
Self {
@@ -336,6 +340,7 @@ impl RuntimeSessionTaskContext {
metadata,
pending_input,
mailbox_loader,
resources,
state,
}
}
@@ -348,6 +353,16 @@ impl RuntimeSessionTaskContext {
&self.turn_id
}
pub fn thread_id(&self) -> &str {
self.resources.thread_id()
}
pub fn code_mode_session(
&self,
) -> Option<tool_runtime::code_mode::RuntimeCodeModeSessionHandle> {
self.resources.code_mode_session()
}
pub fn kind(&self) -> RuntimeSessionTaskKind {
self.kind
}
@@ -371,6 +386,7 @@ impl RuntimeSessionTaskContext {
turn_id: Arc::clone(&self.turn_id),
kind: self.kind,
mailbox_loader: self.mailbox_loader.clone(),
resources: Arc::clone(&self.resources),
state: Arc::clone(&self.state),
}
}
@@ -477,6 +493,16 @@ impl RuntimeSessionTaskContext {
}
impl RuntimeSessionInputHandle {
pub fn thread_id(&self) -> &str {
self.resources.thread_id()
}
pub fn code_mode_session(
&self,
) -> Option<tool_runtime::code_mode::RuntimeCodeModeSessionHandle> {
self.resources.code_mode_session()
}
pub fn kind(&self) -> RuntimeSessionTaskKind {
self.kind
}
@@ -0,0 +1,118 @@
use super::actor::RuntimeSessionActor;
use super::{RuntimeSessionHandle, RuntimeSessionLoopError, RuntimeSessionSnapshot};
use crate::code_mode::RuntimeCodeModeServiceFactory;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{watch, Mutex};
#[derive(Clone, Default)]
pub struct RuntimeSessionRegistry {
sessions: Arc<Mutex<HashMap<String, RuntimeSessionHandle>>>,
code_mode_factory: Option<RuntimeCodeModeServiceFactory>,
}
impl RuntimeSessionRegistry {
pub fn with_code_mode(code_mode_factory: RuntimeCodeModeServiceFactory) -> Self {
Self {
sessions: Arc::new(Mutex::new(HashMap::new())),
code_mode_factory: Some(code_mode_factory),
}
}
pub async fn get_existing(&self, session_id: &str) -> Option<RuntimeSessionHandle> {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
}
pub async fn get_or_create(
&self,
session_id: &str,
thread_id: &str,
) -> Result<RuntimeSessionHandle, RuntimeSessionLoopError> {
if session_id.trim().is_empty() || thread_id.trim().is_empty() {
return Err(RuntimeSessionLoopError::InvalidTask(
"runtime session actor requires canonical session_id and thread_id".to_string(),
));
}
let mut sessions = self.sessions.lock().await;
if let Some(handle) = sessions.get(session_id) {
if handle.thread_id() != thread_id {
return Err(RuntimeSessionLoopError::InvalidTask(format!(
"runtime session identity mismatch: session `{session_id}` is bound to thread `{}`",
handle.thread_id()
)));
}
return Ok(handle.clone());
}
let handle = RuntimeSessionActor::spawn(
session_id.to_string(),
thread_id.to_string(),
self.code_mode_factory.as_ref(),
);
sessions.insert(session_id.to_string(), handle.clone());
Ok(handle)
}
pub async fn shutdown(&self, session_id: &str) -> Result<(), RuntimeSessionLoopError> {
let mut sessions = self.sessions.lock().await;
let Some(handle) = sessions.get(session_id).cloned() else {
return Ok(());
};
let result = handle.shutdown().await;
sessions.remove(session_id);
if let Err(error) = result {
return Err(error);
}
Ok(())
}
pub async fn notify_inter_agent_communication(
&self,
session_id: &str,
input: super::RuntimeSessionInterAgentInput,
) -> Result<bool, RuntimeSessionLoopError> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(false);
};
handle.notify_inter_agent_communication(input).await?;
Ok(true)
}
pub async fn subscribe_input_activity(
&self,
session_id: &str,
) -> Result<
Option<(
watch::Receiver<super::RuntimeSessionInputActivity>,
Option<super::RuntimeSessionInputActivity>,
)>,
RuntimeSessionLoopError,
> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(None);
};
handle.subscribe_input_activity().await.map(Some)
}
pub async fn snapshot(
&self,
session_id: &str,
) -> Result<Option<RuntimeSessionSnapshot>, RuntimeSessionLoopError> {
let handle = {
let sessions = self.sessions.lock().await;
sessions.get(session_id).cloned()
};
let Some(handle) = handle else {
return Ok(None);
};
handle.snapshot().await.map(Some)
}
}
@@ -0,0 +1,45 @@
use crate::code_mode::{RuntimeCodeModeService, RuntimeCodeModeServiceFactory};
use std::sync::Arc;
use tool_runtime::code_mode::RuntimeCodeModeSessionHandle;
pub(super) struct RuntimeSessionResources {
thread_id: Arc<str>,
code_mode: Option<RuntimeCodeModeService>,
}
impl RuntimeSessionResources {
pub(super) fn new(
thread_id: impl Into<Arc<str>>,
code_mode_factory: Option<&RuntimeCodeModeServiceFactory>,
) -> Self {
let thread_id = thread_id.into();
let code_mode = code_mode_factory.and_then(|factory| factory.create(&thread_id).ok());
Self {
thread_id,
code_mode,
}
}
pub(super) fn thread_id(&self) -> &str {
&self.thread_id
}
pub(super) fn code_mode_session(&self) -> Option<RuntimeCodeModeSessionHandle> {
self.code_mode
.as_ref()
.map(RuntimeCodeModeService::session_handle)
}
pub(super) async fn interrupt_code_mode(&self) {
if let Some(code_mode) = self.code_mode.as_ref() {
code_mode.interrupt_active_cells().await;
}
}
pub(super) async fn shutdown_code_mode(&self) -> Result<(), String> {
match self.code_mode.as_ref() {
Some(code_mode) => code_mode.shutdown().await,
None => Ok(()),
}
}
}
@@ -1,4 +1,5 @@
use super::input_queue::{PendingInputQueue, RuntimeSessionTaskState};
use super::resources::RuntimeSessionResources;
use super::*;
use crate::reply_input::RuntimeReplyInput;
use futures::future::BoxFuture;
@@ -48,6 +49,14 @@ fn inter_agent_input(message_id: &str, content: &str) -> RuntimeSessionInterAgen
}
}
async fn actor(registry: &RuntimeSessionRegistry, session_id: &str) -> RuntimeSessionHandle {
let thread_id = format!("thread-{session_id}");
registry
.get_or_create(session_id, &thread_id)
.await
.expect("bind session actor identity")
}
#[tokio::test]
async fn session_snapshot_is_actor_ordered_and_reports_only_the_live_turn() {
let registry = RuntimeSessionRegistry::default();
@@ -59,7 +68,7 @@ async fn session_snapshot_is_actor_ordered_and_reports_only_the_live_turn() {
None
);
let session = registry.get_or_create("session-snapshot").await;
let session = actor(&registry, "session-snapshot").await;
let started = Arc::new(AtomicUsize::new(0));
let finished = Arc::new(AtomicUsize::new(0));
let submission = session
@@ -104,7 +113,7 @@ async fn session_snapshot_is_actor_ordered_and_reports_only_the_live_turn() {
#[tokio::test]
async fn session_loop_serializes_and_queues_tasks() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-1").await;
let session = actor(&registry, "session-1").await;
let started = Arc::new(AtomicUsize::new(0));
let finished = Arc::new(AtomicUsize::new(0));
let first = session
@@ -143,7 +152,7 @@ async fn session_loop_serializes_and_queues_tasks() {
#[tokio::test]
async fn operation_submission_preserves_identity_and_trace_metadata() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-operation-envelope").await;
let session = actor(&registry, "session-operation-envelope").await;
let context_metadata = Arc::new(Mutex::new(
Option::<(String, Option<String>, Option<RuntimeSessionTraceContext>)>::None,
));
@@ -207,9 +216,7 @@ async fn operation_submission_preserves_identity_and_trace_metadata() {
#[tokio::test]
async fn replacing_task_operations_share_the_session_dispatcher() {
let registry = RuntimeSessionRegistry::default();
let session = registry
.get_or_create("session-typed-task-operations")
.await;
let session = actor(&registry, "session-typed-task-operations").await;
let operations = [
RuntimeSessionTaskKind::Review,
RuntimeSessionTaskKind::Compact,
@@ -252,7 +259,7 @@ async fn replacing_task_operations_share_the_session_dispatcher() {
#[tokio::test]
async fn inline_operations_run_in_submission_order_without_replacing_the_active_task() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-inline-operations").await;
let session = actor(&registry, "session-inline-operations").await;
let active = RuntimeSessionClosureTask::new(
"turn-inline-active",
Vec::new(),
@@ -347,7 +354,7 @@ async fn inline_operations_run_in_submission_order_without_replacing_the_active_
#[tokio::test]
async fn shell_operation_attaches_to_the_active_turn_and_shares_cancellation() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-shell-active").await;
let session = actor(&registry, "session-shell-active").await;
let active = RuntimeSessionClosureTask::new(
"turn-shell-active",
Vec::new(),
@@ -428,7 +435,7 @@ async fn shell_operation_attaches_to_the_active_turn_and_shares_cancellation() {
#[tokio::test]
async fn idle_shell_operation_owns_the_session_task_lifecycle() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-shell-idle").await;
let session = actor(&registry, "session-shell-idle").await;
let auxiliary_calls = Arc::new(AtomicUsize::new(0));
let auxiliary_calls_for_handler = Arc::clone(&auxiliary_calls);
let auxiliary = RuntimeSessionHandler::new(move |_context| {
@@ -501,7 +508,7 @@ async fn idle_shell_operation_owns_the_session_task_lifecycle() {
#[tokio::test]
async fn user_input_starts_an_idle_candidate_with_the_submitted_input() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-user-input-start").await;
let session = actor(&registry, "session-user-input-start").await;
let (seen_tx, seen_rx) = oneshot::channel();
let seen_tx = Arc::new(Mutex::new(Some(seen_tx)));
let task = RuntimeSessionClosureTask::new(
@@ -555,7 +562,7 @@ async fn user_input_starts_an_idle_candidate_with_the_submitted_input() {
#[tokio::test]
async fn user_input_steers_an_active_regular_task_without_starting_the_candidate() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-user-input-steer").await;
let session = actor(&registry, "session-user-input-steer").await;
let ready = Arc::new(Notify::new());
let ready_for_task = Arc::clone(&ready);
let release = Arc::new(Notify::new());
@@ -637,7 +644,7 @@ async fn user_input_steers_an_active_regular_task_without_starting_the_candidate
#[tokio::test]
async fn promoted_task_context_preserves_submission_metadata() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-promoted-metadata").await;
let session = actor(&registry, "session-promoted-metadata").await;
let first = RuntimeSessionClosureTask::new(
"turn-promoted-blocker",
Vec::new(),
@@ -731,7 +738,7 @@ async fn promoted_task_context_preserves_submission_metadata() {
#[tokio::test]
async fn shutdown_and_wait_allows_multiple_waiters() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-shutdown-waiters").await;
let session = actor(&registry, "session-shutdown-waiters").await;
let submission = session
.submit(
Arc::new(RuntimeSessionClosureTask::new(
@@ -767,7 +774,7 @@ async fn shutdown_and_wait_allows_multiple_waiters() {
#[tokio::test]
async fn interrupt_preserves_session_mailbox_for_the_next_task() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-mailbox-interrupt").await;
let session = actor(&registry, "session-mailbox-interrupt").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let first = RuntimeSessionClosureTask::new(
@@ -844,7 +851,7 @@ async fn interrupt_preserves_session_mailbox_for_the_next_task() {
#[tokio::test]
async fn steer_and_mailbox_are_kept_in_separate_queues() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-2").await;
let session = actor(&registry, "session-2").await;
let seen = Arc::new(Mutex::new(Vec::new()));
let seen_for_task = Arc::clone(&seen);
let (ready_sender, ready_receiver) = oneshot::channel();
@@ -901,7 +908,7 @@ async fn steer_and_mailbox_are_kept_in_separate_queues() {
#[tokio::test]
async fn mailbox_loader_is_deferred_until_the_mailbox_boundary() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-loader").await;
let session = actor(&registry, "session-loader").await;
let loader_calls = Arc::new(AtomicUsize::new(0));
let loader_calls_for_loader = Arc::clone(&loader_calls);
let loader_calls_for_task = Arc::clone(&loader_calls);
@@ -955,7 +962,7 @@ async fn mailbox_loader_is_deferred_until_the_mailbox_boundary() {
#[tokio::test]
async fn busy_submission_completes_with_a_failure_instead_of_hanging() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-busy").await;
let session = actor(&registry, "session-busy").await;
let started = Arc::new(AtomicUsize::new(0));
let finished = Arc::new(AtomicUsize::new(0));
let first = session
@@ -992,7 +999,7 @@ async fn busy_submission_completes_with_a_failure_instead_of_hanging() {
#[tokio::test]
async fn steer_for_turn_rejects_a_late_target() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-target").await;
let session = actor(&registry, "session-target").await;
let started = Arc::new(AtomicUsize::new(0));
let finished = Arc::new(AtomicUsize::new(0));
let submission = session
@@ -1017,7 +1024,7 @@ async fn steer_for_turn_rejects_a_late_target() {
#[tokio::test]
async fn steer_returns_the_actor_confirmed_active_turn_id() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-steer-id").await;
let session = actor(&registry, "session-steer-id").await;
let submission = session
.submit(
task(
@@ -1074,7 +1081,7 @@ impl RuntimeSessionTask for HangingAbortTask {
#[tokio::test]
async fn hanging_abort_is_forcefully_reaped() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-hanging-abort").await;
let session = actor(&registry, "session-hanging-abort").await;
let submission = session
.submit(
Arc::new(HangingAbortTask {
@@ -1102,7 +1109,11 @@ async fn hanging_abort_is_forcefully_reaped() {
#[tokio::test]
async fn dropping_last_session_handle_shuts_down_active_and_queued_tasks() {
let session = RuntimeSessionActor::spawn("session-channel-close".to_string());
let session = RuntimeSessionActor::spawn(
"session-channel-close".to_string(),
"thread-channel-close".to_string(),
None,
);
let started = Arc::new(AtomicUsize::new(0));
let finished = Arc::new(AtomicUsize::new(0));
let active = session
@@ -1140,7 +1151,7 @@ async fn dropping_last_session_handle_shuts_down_active_and_queued_tasks() {
#[tokio::test]
async fn stale_completion_cannot_finish_a_promoted_task() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-stale-completion").await;
let session = actor(&registry, "session-stale-completion").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let first = RuntimeSessionClosureTask::new(
@@ -1191,7 +1202,7 @@ async fn stale_completion_cannot_finish_a_promoted_task() {
#[tokio::test]
async fn replace_submission_closes_the_old_task_and_rejects_compact_steer() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-replace").await;
let session = actor(&registry, "session-replace").await;
let first = RuntimeSessionClosureTask::new(
"turn-compact",
Vec::new(),
@@ -1245,7 +1256,7 @@ async fn replace_submission_closes_the_old_task_and_rejects_compact_steer() {
#[tokio::test]
async fn step_context_and_usage_are_turn_scoped_and_monotonic() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-step-context").await;
let session = actor(&registry, "session-step-context").await;
let seen = Arc::new(Mutex::new(Vec::new()));
let usage = Arc::new(Mutex::new(RuntimeSessionTokenUsage::default()));
let seen_for_task = Arc::clone(&seen);
@@ -1302,7 +1313,7 @@ async fn step_context_and_usage_are_turn_scoped_and_monotonic() {
#[tokio::test]
async fn context_rollover_is_consumed_by_the_next_step_snapshot() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-context-rollover").await;
let session = actor(&registry, "session-context-rollover").await;
let seen = Arc::new(Mutex::new(Vec::new()));
let seen_for_task = Arc::clone(&seen);
let task = RuntimeSessionClosureTask::new(
@@ -1342,7 +1353,7 @@ async fn context_rollover_is_consumed_by_the_next_step_snapshot() {
#[tokio::test]
async fn final_answer_defers_mailbox_until_steer_reopens_the_turn() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-mailbox-phase").await;
let session = actor(&registry, "session-mailbox-phase").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let seen = Arc::new(Mutex::new(Vec::new()));
@@ -1415,7 +1426,7 @@ async fn final_answer_defers_mailbox_until_steer_reopens_the_turn() {
#[tokio::test]
async fn stale_final_defer_does_not_override_a_steer() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-stale-defer").await;
let session = actor(&registry, "session-stale-defer").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let task = RuntimeSessionClosureTask::new(
@@ -1464,7 +1475,7 @@ async fn stale_final_defer_does_not_override_a_steer() {
#[tokio::test]
async fn response_waiter_uses_the_active_turn_generation() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-response").await;
let session = actor(&registry, "session-response").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let seen = Arc::new(Mutex::new(None));
@@ -1526,7 +1537,7 @@ async fn response_waiter_uses_the_active_turn_generation() {
#[tokio::test]
async fn typed_response_operations_route_to_distinct_waiters() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-typed-responses").await;
let session = actor(&registry, "session-typed-responses").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let seen = Arc::new(Mutex::new(Vec::new()));
@@ -1629,7 +1640,7 @@ async fn typed_response_operations_route_to_distinct_waiters() {
#[tokio::test]
async fn mailbox_loader_failure_preserves_steer_input() {
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-loader-error").await;
let session = actor(&registry, "session-loader-error").await;
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let seen = Arc::new(Mutex::new(Vec::new()));
@@ -1698,6 +1709,7 @@ fn input_handle_with_loader(
turn_id: Arc::from("turn-activity"),
kind: RuntimeSessionTaskKind::Regular,
mailbox_loader: Some(loader),
resources: Arc::new(RuntimeSessionResources::new("thread-activity", None)),
state: Arc::new(RuntimeSessionTaskState::default()),
}
}
@@ -1893,7 +1905,7 @@ async fn handle_notification_is_visible_to_a_late_task_subscriber() {
)
.await
.expect("missing session lookup"));
let session = registry.get_or_create("session-late-activity").await;
let session = actor(&registry, "session-late-activity").await;
assert!(registry
.notify_inter_agent_communication(
"session-late-activity",
@@ -123,6 +123,8 @@ where
pending_input.clone(),
mcp_tool_routes.clone(),
dynamic_tool_routes.clone(),
session_config.tool_mode,
session_config.supports_custom_tools,
);
let lifecycle_emitter = Arc::new(CurrentTurnToolLifecycleEmitter::with_tool_routes(
host_event_sender.clone(),
@@ -21,8 +21,10 @@ use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::{mpsc::UnboundedSender, Mutex};
use tool_runtime::tool_definition::RuntimeToolDefinition;
use tool_runtime::tool_definition::RuntimeToolExposure;
use tool_runtime::tool_executor::RuntimeToolExecutorHandle;
use tool_runtime::tool_extension::RuntimeToolCaller;
use tool_runtime::turn_snapshot::{RuntimeToolIdentity, RuntimeToolSnapshot};
use tool_runtime::turn_tool_surface::{
runtime_turn_tool_scope_from_metadata, runtime_turn_tool_surface_allows_tool_name,
runtime_turn_tool_surface_mode_from_metadata,
@@ -149,6 +151,8 @@ pub(super) fn current_tool_step_snapshot_source(
pending_input: Option<RuntimeSessionInputHandle>,
mcp_tool_routes: McpToolRoutes,
dynamic_tool_routes: DynamicToolRoutes,
tool_mode: tool_runtime::code_mode::RuntimeToolMode,
supports_custom_tools: bool,
) -> RuntimeToolStepSnapshotSourceHandle {
let deferred_tools = DeferredToolSelections::default();
RuntimeToolStepSnapshotSourceHandle::new(Arc::new(CurrentTurnToolStepSnapshotSource {
@@ -163,6 +167,8 @@ pub(super) fn current_tool_step_snapshot_source(
deferred_tools,
mcp_tool_routes,
dynamic_tool_routes,
tool_mode,
supports_custom_tools,
}))
}
@@ -375,6 +381,35 @@ struct CurrentTurnToolStepSnapshotSource {
deferred_tools: DeferredToolSelections,
mcp_tool_routes: McpToolRoutes,
dynamic_tool_routes: DynamicToolRoutes,
tool_mode: tool_runtime::code_mode::RuntimeToolMode,
supports_custom_tools: bool,
}
struct CodeModeStepPlan {
tool_plan: tool_runtime::code_mode::RuntimeCodeModeToolPlan,
attach_session: bool,
}
fn code_mode_step_plan(
runtime_tools: &[RuntimeToolSnapshot],
requested: tool_runtime::code_mode::RuntimeToolMode,
supports_custom_tools: bool,
has_session: bool,
) -> Result<CodeModeStepPlan, String> {
let code_mode_available = supports_custom_tools && has_session;
let tool_plan = tool_runtime::code_mode::plan_runtime_code_mode_tools(
runtime_tools,
requested,
code_mode_available,
false,
)
.map_err(|error| error.to_string())?;
let attach_session = code_mode_available
&& tool_plan.resolution.effective != tool_runtime::code_mode::RuntimeToolMode::Direct;
Ok(CodeModeStepPlan {
tool_plan,
attach_session,
})
}
impl RuntimeToolStepSnapshotSource for CurrentTurnToolStepSnapshotSource {
@@ -426,12 +461,51 @@ impl RuntimeToolStepSnapshotSource for CurrentTurnToolStepSnapshotSource {
pending_input: self.pending_input.clone(),
dynamic_tool_routes: self.dynamic_tool_routes.clone(),
}));
Ok(RuntimeToolStepSnapshot::with_tool_metadata(
let runtime_tools = definitions
.iter()
.cloned()
.map(|definition| {
let supports_parallel = !serial_mcp_tool_names.contains(&definition.name);
RuntimeToolSnapshot::new(
RuntimeToolIdentity::plain(definition.name.clone()),
definition,
RuntimeToolExposure::Direct,
supports_parallel,
true,
)
})
.collect::<Vec<_>>();
let code_mode_session = self
.pending_input
.as_ref()
.and_then(RuntimeSessionInputHandle::code_mode_session);
let CodeModeStepPlan {
tool_plan,
attach_session,
} = code_mode_step_plan(
&runtime_tools,
self.tool_mode,
self.supports_custom_tools,
code_mode_session.is_some(),
)?;
let definitions = tool_plan
.model_visible_tools
.into_iter()
.map(|tool| tool.definition)
.collect();
let nested_tools = tool_plan.nested_tools;
let snapshot = RuntimeToolStepSnapshot::with_tool_metadata(
definitions,
executor,
serial_mcp_tool_names,
mcp_tool_environment_ids,
))
);
if !attach_session {
return Ok(snapshot);
}
let session = code_mode_session
.ok_or_else(|| "CodeMode plan requires an executable session".to_string())?;
Ok(snapshot.with_code_mode_session(session, nested_tools))
})
}
}
@@ -685,4 +759,63 @@ mod tests {
};
assert!(tool_definitions(&state, &policy, Some(&collision), &snapshot, None).is_err());
}
#[test]
fn code_mode_step_requires_model_capability_and_executable_session() {
let tools = vec![RuntimeToolSnapshot::new(
RuntimeToolIdentity::plain("read"),
RuntimeToolDefinition::new("read", "Read a file", json!({})),
RuntimeToolExposure::Direct,
false,
true,
)];
let direct = code_mode_step_plan(
&tools,
tool_runtime::code_mode::RuntimeToolMode::Direct,
true,
true,
)
.expect("direct mode");
assert_eq!(
direct.tool_plan.resolution.effective,
tool_runtime::code_mode::RuntimeToolMode::Direct
);
assert!(!direct.attach_session);
let missing_capability = code_mode_step_plan(
&tools,
tool_runtime::code_mode::RuntimeToolMode::CodeMode,
false,
true,
)
.expect("regular CodeMode falls back to direct");
assert_eq!(
missing_capability.tool_plan.resolution.effective,
tool_runtime::code_mode::RuntimeToolMode::Direct
);
assert!(!missing_capability.attach_session);
let executable = code_mode_step_plan(
&tools,
tool_runtime::code_mode::RuntimeToolMode::CodeMode,
true,
true,
)
.expect("executable CodeMode");
assert_eq!(
executable.tool_plan.resolution.effective,
tool_runtime::code_mode::RuntimeToolMode::CodeMode
);
assert!(executable.attach_session);
assert_eq!(executable.tool_plan.nested_tools[0].global_name, "read");
assert!(code_mode_step_plan(
&tools,
tool_runtime::code_mode::RuntimeToolMode::CodeModeOnly,
true,
false,
)
.is_err());
}
}
@@ -14,6 +14,7 @@ use std::sync::Mutex;
use tokio::sync::mpsc::UnboundedSender;
use tool_runtime::tool_lifecycle::{
ToolLifecycleEmissionFuture, ToolLifecycleEmitter, ToolLifecycleEvent, ToolLifecyclePhase,
ToolOutputDeltaEvent,
};
pub(super) struct CurrentTurnToolLifecycleEmitter {
@@ -262,6 +263,32 @@ impl ToolLifecycleEmitter for CurrentTurnToolLifecycleEmitter {
}
})
}
fn emit_output_delta<'a>(
&'a self,
event: ToolOutputDeltaEvent,
) -> ToolLifecycleEmissionFuture<'a> {
Box::pin(async move {
if event.delta.is_empty() {
return;
}
let mut metadata = event.metadata;
metadata
.entry("turn_id".to_string())
.or_insert_with(|| serde_json::Value::String(event.turn_id));
metadata
.entry("tool_name".to_string())
.or_insert_with(|| serde_json::Value::String(event.tool_name));
let _ = self.event_sender.send(CurrentTurnHostEvent::ToolLifecycle(
AgentEvent::ToolOutputDelta {
tool_id: event.call_id,
delta: event.delta,
output_kind: event.output_kind,
metadata: Some(metadata),
},
));
})
}
}
#[cfg(test)]
@@ -272,6 +299,59 @@ mod tests {
use tool_runtime::tool_call::ToolEnvironment;
use tool_runtime::tool_result_projection::NormalizedToolOutput;
#[tokio::test]
async fn output_delta_keeps_code_mode_call_and_cell_correlation() {
let (event_sender, mut events) = tokio::sync::mpsc::unbounded_channel();
let emitter = CurrentTurnToolLifecycleEmitter::new(
event_sender,
"session-code-mode",
"thread-code-mode",
);
emitter
.emit_output_delta(ToolOutputDeltaEvent {
turn_id: "turn-code-mode".to_string(),
call_id: "exec-call-1".to_string(),
tool_name: "exec".to_string(),
delta: "cell update".to_string(),
output_kind: Some("code_mode_notify".to_string()),
metadata: HashMap::from([(
"code_mode_cell_id".to_string(),
serde_json::Value::String("cell-1".to_string()),
)]),
})
.await;
let CurrentTurnHostEvent::ToolLifecycle(AgentEvent::ToolOutputDelta {
tool_id,
delta,
output_kind,
metadata,
}) = events.recv().await.expect("output delta event")
else {
panic!("expected canonical tool output delta")
};
assert_eq!(tool_id, "exec-call-1");
assert_eq!(delta, "cell update");
assert_eq!(output_kind.as_deref(), Some("code_mode_notify"));
let metadata = metadata.expect("output delta metadata");
assert_eq!(
metadata
.get("code_mode_cell_id")
.and_then(serde_json::Value::as_str),
Some("cell-1")
);
assert_eq!(
metadata.get("turn_id").and_then(serde_json::Value::as_str),
Some("turn-code-mode")
);
assert_eq!(
metadata
.get("tool_name")
.and_then(serde_json::Value::as_str),
Some("exec")
);
}
#[test]
fn exact_snapshot_route_projects_mcp_item() {
let routes = McpToolRoutes::default();
@@ -55,7 +55,10 @@ mod tests {
async fn bridge_emits_typed_permission_action_and_resumes_exact_waiter() {
let state = Arc::new(ActionRequiredState::default());
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-1").await;
let session = registry
.get_or_create("session-1", "thread-1")
.await
.expect("bind permission bridge session actor");
let (event_sender, mut event_receiver) = tokio::sync::mpsc::unbounded_channel();
let (result_tx, result_rx) = tokio::sync::oneshot::channel();
let result_tx = Arc::new(StdMutex::new(Some(result_tx)));
@@ -174,7 +174,10 @@ mod tests {
auto_resolution_ms: Some(60_000),
};
let registry = RuntimeSessionRegistry::default();
let session = registry.get_or_create("session-1").await;
let session = registry
.get_or_create("session-1", "thread-1")
.await
.expect("bind user-input bridge session actor");
let (response_tx, response_rx) = tokio::sync::oneshot::channel();
let response_tx = Arc::new(StdMutex::new(Some(response_tx)));
let task_state = Arc::clone(&state);
@@ -1,4 +1,5 @@
use super::TransportEvent;
#[cfg(unix)]
use crate::transport::websocket::run_websocket_connection;
use std::fs::OpenOptions;
use std::io::ErrorKind;
@@ -7,10 +8,14 @@ use std::path::Path;
use std::path::PathBuf;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
#[cfg(unix)]
use tokio::time::Duration;
use tokio_util::sync::CancellationToken;
#[cfg(unix)]
use tracing::error;
#[cfg(unix)]
use tracing::info;
#[cfg(unix)]
use tracing::warn;
#[cfg(unix)]
+9
View File
@@ -546,6 +546,15 @@ impl Default for RuntimeCore {
}
impl RuntimeCore {
pub(crate) fn with_code_mode_factory(
mut self,
factory: agent_runtime::code_mode::RuntimeCodeModeServiceFactory,
) -> Self {
self.session_loops =
agent_runtime::session_loop::RuntimeSessionRegistry::with_code_mode(factory);
self
}
pub fn with_backend(backend: Arc<dyn ExecutionBackend>) -> Self {
Self::with_backend_and_capability_source(
backend,
@@ -266,22 +266,6 @@ impl RuntimeCore {
}
}
impl RuntimeCore {
pub(in crate::runtime) fn append_workflow_audit_runtime_events(
&self,
request: &ExecutionRequest,
runtime_events: Vec<RuntimeEvent>,
) -> Result<(), RuntimeCoreError> {
append_workflow_audit_runtime_events(
self.event_log_writer.as_deref(),
request.session.session_id.as_str(),
request.session.thread_id.as_str(),
Some(request.turn.turn_id.as_str()),
runtime_events,
)
}
}
pub(in crate::runtime) fn append_workflow_audit_runtime_events(
event_log_writer: Option<&EventLogWriter>,
session_id: &str,
@@ -88,7 +88,11 @@ impl RuntimeCore {
},
)
.with_kind(RuntimeSessionTaskKind::Compact);
let session = self.session_loops.get_or_create(&actor_session_id).await;
let session = self
.session_loops
.get_or_create(&actor_session_id, &thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let result = session
.dispatch(RuntimeSessionOperationSubmission::new(
RuntimeSessionOperation::Compact {
@@ -127,6 +131,14 @@ impl RuntimeCore {
trigger_context: Option<Value>,
) -> Result<RuntimeCoreOutput<ThreadCompactStartResponse>, RuntimeCoreError> {
self.ensure_current_session_hydrated(session_id).await?;
let thread_id = self
.state
.lock()
.expect("runtime core state mutex poisoned")
.sessions
.get(session_id)
.map(|stored| stored.session.thread_id.clone())
.ok_or_else(|| RuntimeCoreError::SessionNotFound(session_id.to_string()))?;
let runtime = self.clone();
let session_id = session_id.to_string();
let event_name = event_name.map(str::to_string);
@@ -180,7 +192,11 @@ impl RuntimeCore {
},
)
.with_kind(RuntimeSessionTaskKind::Compact);
let session = self.session_loops.get_or_create(&actor_session_id).await;
let session = self
.session_loops
.get_or_create(&actor_session_id, &thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let result = session
.dispatch(RuntimeSessionOperationSubmission::new(
RuntimeSessionOperation::Compact {
@@ -291,7 +291,11 @@ impl RuntimeCore {
RuntimeSessionOperation::SetMemoryMode { handler }
}
};
let session = self.session_loops.get_or_create(&session_id).await;
let session = self
.session_loops
.get_or_create(&session_id, &thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let dispatch_result = match session
.dispatch(RuntimeSessionOperationSubmission::new(operation))
.await
@@ -151,7 +151,11 @@ impl RuntimeCore {
)
.with_kind(RuntimeSessionTaskKind::RunShell);
let session = self.session_loops.get_or_create(&session_id).await;
let session = self
.session_loops
.get_or_create(&session_id, &thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let result = session
.dispatch(RuntimeSessionOperationSubmission::new(
RuntimeSessionOperation::RunShell {
@@ -1299,8 +1299,9 @@ async fn direct_message_publishes_activity_only_to_an_existing_recipient_session
);
let recipient_loop = core
.session_loops
.get_or_create(&child.session.session_id)
.await;
.get_or_create(&child.session.session_id, &child.session.thread_id)
.await
.expect("bind recipient session actor");
let submission = recipient_loop
.submit_replacing(Arc::new(task))
.await
@@ -269,7 +269,11 @@ async fn complete_child(core: &RuntimeCore) -> AgentTurn {
#[tokio::test]
async fn terminal_result_notifies_an_existing_parent_session_actor() {
let (_temp, core, _store, root, _root_turn) = setup(ChildOutcome::Completed).await;
let session = core.session_loops.get_or_create(&root.session_id).await;
let session = core
.session_loops
.get_or_create(&root.session_id, &root.thread_id)
.await
.expect("bind parent session actor");
let (ready_tx, ready_rx) = oneshot::channel();
let ready_tx = Arc::new(Mutex::new(Some(ready_tx)));
let (activity_tx, activity_rx) = oneshot::channel();
@@ -252,8 +252,9 @@ mod tests {
);
let actor = runtime
.session_loops
.get_or_create(&session.session_id)
.await;
.get_or_create(&session.session_id, &session.thread_id)
.await
.expect("bind inject-items session actor");
let submission = actor
.submit(Arc::new(task), false)
.await
@@ -834,7 +834,11 @@ impl RuntimeCore {
&session.session_id,
)
.map_err(RuntimeCoreError::Backend)?;
let session_loop = self.session_loops.get_or_create(&session.session_id).await;
let session_loop = self
.session_loops
.get_or_create(&session.session_id, &session.thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let active_turn_id = session_loop
.steer_for_turn_id_with_metadata(
Some(expected_turn_id),
@@ -1857,6 +1861,7 @@ impl RuntimeCore {
provider_history: super::provider_history::ProviderTurnHistory,
) -> Result<SubmittedRuntimeSessionTurn, RuntimeCoreError> {
let session_id = request.session.session_id.clone();
let thread_id = request.session.thread_id.clone();
let turn_id = request.turn.turn_id.clone();
let queue_if_busy = request.queue_if_busy;
let user_input = request.input.clone();
@@ -1941,7 +1946,11 @@ impl RuntimeCore {
.with_mailbox_loader(mailbox_loader)
.with_abort(|_context| Box::pin(async {})),
);
let session = self.session_loops.get_or_create(&session_id).await;
let session = self
.session_loops
.get_or_create(&session_id, &thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let submission = match session
.submit_user_input_with_metadata(
task,
@@ -2686,8 +2695,9 @@ impl RuntimeCore {
.unwrap_or_else(|| json!({ "confirmed": request.confirmed }));
let session = self
.session_loops
.get_or_create(&request.session.session_id)
.await;
.get_or_create(&request.session.session_id, &request.session.thread_id)
.await
.map_err(|error| RuntimeCoreError::Backend(error.to_string()))?;
let result = match request.action_type {
AgentSessionActionType::ToolConfirmation => {
session
@@ -1,7 +1,3 @@
use serde_json::Value;
pub(crate) const ARTIFACT_SNAPSHOT: &str = "artifact.snapshot";
pub(crate) const WORKFLOW_RUN_STARTED: &str = "workflow.run.started";
pub(crate) const WORKFLOW_RUN_RESUMING: &str = "workflow.run.resuming";
pub(crate) const WORKFLOW_RUN_RETRYING: &str = "workflow.run.retrying";
@@ -17,14 +13,6 @@ pub(crate) const WORKFLOW_STEP_COMPLETED: &str = "workflow.step.completed";
pub(crate) const WORKFLOW_STEP_FAILED: &str = "workflow.step.failed";
pub(crate) const WORKFLOW_STEP_CANCELED: &str = "workflow.step.canceled";
pub(crate) const WORKFLOW_TOOL_STARTED: &str = "workflow.tool.started";
pub(crate) const WORKFLOW_TOOL_COMPLETED: &str = "workflow.tool.completed";
pub(crate) const WORKFLOW_CONNECTOR_REQUESTED: &str = "workflow.connector.requested";
pub(crate) const WORKFLOW_CONNECTOR_COMPLETED: &str = "workflow.connector.completed";
pub(crate) const WORKFLOW_HOOK_STARTED: &str = "workflow.hook.started";
pub(crate) const WORKFLOW_HOOK_COMPLETED: &str = "workflow.hook.completed";
pub(crate) const WORKFLOW_ARTIFACT_DELTA: &str = "workflow.artifact.delta";
pub(crate) fn workflow_run_event_is_terminal(event_type: &str) -> bool {
matches!(
event_type,
@@ -38,63 +26,3 @@ pub(crate) fn workflow_step_event_is_terminal(event_type: &str) -> bool {
WORKFLOW_STEP_COMPLETED | WORKFLOW_STEP_FAILED | WORKFLOW_STEP_CANCELED
)
}
pub(crate) fn is_allowed_worker_progress_event(event_type: &str) -> bool {
matches!(
event_type,
WORKFLOW_STEP_PROGRESS
| WORKFLOW_TOOL_STARTED
| WORKFLOW_TOOL_COMPLETED
| WORKFLOW_CONNECTOR_REQUESTED
| WORKFLOW_CONNECTOR_COMPLETED
| WORKFLOW_HOOK_STARTED
| WORKFLOW_HOOK_COMPLETED
| WORKFLOW_ARTIFACT_DELTA
| ARTIFACT_SNAPSHOT
)
}
pub(crate) fn requires_step_binding(event_type: &str) -> bool {
matches!(
event_type,
WORKFLOW_STEP_PROGRESS
| WORKFLOW_TOOL_STARTED
| WORKFLOW_TOOL_COMPLETED
| WORKFLOW_CONNECTOR_REQUESTED
| WORKFLOW_CONNECTOR_COMPLETED
| WORKFLOW_HOOK_STARTED
| WORKFLOW_HOOK_COMPLETED
| WORKFLOW_ARTIFACT_DELTA
)
}
pub(crate) fn validate_required_progress_payload(
event_type: &str,
payload: &Value,
) -> Result<(), String> {
if event_type.starts_with("workflow.tool.")
&& string_field(payload, &["toolName", "tool_name", "name"]).is_none()
{
return Err(format!("{event_type} missing toolName"));
}
if event_type.starts_with("workflow.connector.")
&& string_field(payload, &["connectorRef", "connector_ref"]).is_none()
{
return Err(format!("{event_type} missing connectorRef"));
}
if event_type.starts_with("workflow.hook.")
&& string_field(payload, &["hookKey", "hook_key"]).is_none()
{
return Err(format!("{event_type} missing hookKey"));
}
Ok(())
}
fn string_field(value: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.filter_map(|key| value.get(*key))
.find_map(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
}
@@ -508,6 +508,8 @@ impl RuntimeBackend {
config_metadata.clone(),
&turn_plugin_snapshots,
);
session_config.tool_mode = route_resolution.tool_mode;
session_config.supports_custom_tools = route_resolution.supports_custom_tools;
mention_selection.apply_to_session_config(&mut session_config);
let model_context_window = lime_agent::model_request_policy_from_turn_context(
session_config.turn_context.as_ref(),
@@ -18,23 +18,6 @@ pub(super) struct AgentSkillsTurnContext {
pub snapshot: Option<AgentSkillSnapshot>,
}
pub(super) fn agent_skills_context_for_turn(
system_prompt: Option<String>,
user_input: &str,
metadata_values: &[&Value],
working_dir: Option<&Path>,
project_root: Option<&Path>,
) -> AgentSkillsTurnContext {
agent_skills_context_for_turn_with_plugins(
system_prompt,
user_input,
metadata_values,
working_dir,
project_root,
&[],
)
}
pub(super) fn agent_skills_context_for_turn_with_plugins(
system_prompt: Option<String>,
user_input: &str,
@@ -128,19 +111,6 @@ pub(super) fn selected_agent_skill_names_for_turn_with_plugins(
.collect()
}
pub(super) fn build_agent_skill_snapshot_for_turn(
working_dir: Option<&Path>,
project_root: Option<&Path>,
metadata_values: &[&Value],
) -> AgentSkillSnapshot {
build_agent_skill_snapshot_for_turn_with_plugins(
working_dir,
project_root,
metadata_values,
&[],
)
}
pub(super) fn build_agent_skill_snapshot_for_turn_with_plugins(
working_dir: Option<&Path>,
project_root: Option<&Path>,
@@ -199,19 +169,6 @@ pub(super) fn selected_agent_skill_selections_with_plugins(
select_implicit_agent_skills(user_input, snapshot)
}
pub(super) fn selected_agent_skill_body_selections_for_prompt(
user_input: &str,
metadata_values: &[&Value],
snapshot: &AgentSkillSnapshot,
) -> Vec<AgentSkillSelection> {
selected_agent_skill_body_selections_for_prompt_with_plugins(
user_input,
metadata_values,
snapshot,
&[],
)
}
pub(super) fn selected_agent_skill_body_selections_for_prompt_with_plugins(
user_input: &str,
metadata_values: &[&Value],
@@ -571,12 +528,13 @@ fn append_agent_skills_context_to_system_prompt(
working_dir: Option<&Path>,
project_root: Option<&Path>,
) -> Option<String> {
agent_skills_context_for_turn(
agent_skills_context_for_turn_with_plugins(
system_prompt,
user_input,
metadata_values,
working_dir,
project_root,
&[],
)
.system_prompt
}
@@ -639,12 +597,18 @@ mod tests {
fn body_evaluation_uses_the_real_skill_body_budget_before_prompt_injection() {
let workspace = TempDir::new().expect("workspace");
write_skill(&workspace, "writer", "Writer", "Write clearly.");
let snapshot = build_agent_skill_snapshot_for_turn(
let snapshot = build_agent_skill_snapshot_for_turn_with_plugins(
Some(workspace.path()),
Some(workspace.path()),
&[],
&[],
);
let selections = selected_agent_skill_body_selections_for_prompt_with_plugins(
"$writer",
&[],
&snapshot,
&[],
);
let selections = selected_agent_skill_body_selections_for_prompt("$writer", &[], &snapshot);
let evaluations = selected_agent_skill_body_evaluations(&selections, &snapshot);
@@ -712,7 +712,10 @@ async fn respond_action_tool_confirmation_resumes_pending_agent_tool_future() {
let stream_backend = backend.clone();
let stream_events_for_sink = stream_events.clone();
let registry = RuntimeSessionRegistry::default();
let session_loop = registry.get_or_create(&action_session.session_id).await;
let session_loop = registry
.get_or_create(&action_session.session_id, &action_session.thread_id)
.await
.expect("bind confirmation session actor");
let turn_id = action_turn.turn_id.clone();
let task = RuntimeSessionClosureTask::new(
turn_id,
@@ -328,6 +328,8 @@ mod tests {
system_prompt_override: Some(true),
include_context_trace: Some(true),
turn_context: None,
tool_mode: tool_runtime::code_mode::RuntimeToolMode::Direct,
supports_custom_tools: false,
};
selection.apply_to_session_config(&mut session_config);
@@ -2,7 +2,7 @@ use super::request_context::RuntimeModelSelection;
use lime_agent::SessionProviderConfig;
use lime_core::database::DbConnection;
use lime_core::models::model_registry::{
EnhancedModelMetadata, ModelCapabilityProvenance, ModelModality,
EnhancedModelMetadata, ModelCapabilityProvenance, ModelModality, ModelRuntimeFeature,
};
use lime_core::models::RuntimeProviderCredential;
use lime_services::api_key_provider_service::ApiKeyProviderService;
@@ -12,17 +12,69 @@ use serde_json::{json, Value};
#[derive(Debug, Clone)]
pub(super) struct RuntimeModelRegistryMetadata {
payload: Value,
tool_mode: tool_runtime::code_mode::RuntimeToolMode,
supports_custom_tools: bool,
}
impl RuntimeModelRegistryMetadata {
#[cfg(test)]
pub(super) fn from_payload(payload: Value) -> Self {
Self { payload }
let tool_mode = runtime_tool_mode_from_payload(&payload);
let supports_custom_tools = payload_supports_custom_tools(&payload);
Self {
payload,
tool_mode,
supports_custom_tools,
}
}
pub(super) fn payload(&self) -> &Value {
&self.payload
}
pub(super) fn tool_mode(&self) -> tool_runtime::code_mode::RuntimeToolMode {
self.tool_mode
}
pub(super) fn supports_custom_tools(&self) -> bool {
self.supports_custom_tools
}
}
fn runtime_tool_mode(value: Option<&str>) -> tool_runtime::code_mode::RuntimeToolMode {
match value.map(str::trim) {
Some("code_mode") => tool_runtime::code_mode::RuntimeToolMode::CodeMode,
Some("code_mode_only") => tool_runtime::code_mode::RuntimeToolMode::CodeModeOnly,
_ => tool_runtime::code_mode::RuntimeToolMode::Direct,
}
}
#[cfg(test)]
fn runtime_tool_mode_from_payload(payload: &Value) -> tool_runtime::code_mode::RuntimeToolMode {
runtime_tool_mode(
payload
.pointer("/model/tool_mode")
.or_else(|| payload.pointer("/model/toolMode"))
.or_else(|| payload.get("tool_mode"))
.or_else(|| payload.get("toolMode"))
.and_then(Value::as_str),
)
}
#[cfg(test)]
fn payload_supports_custom_tools(payload: &Value) -> bool {
[
"/model/runtime_features",
"/model/runtimeFeatures",
"/model_capabilities/runtime_features",
"/modelCapabilities/runtimeFeatures",
]
.into_iter()
.filter_map(|pointer| payload.pointer(pointer))
.filter_map(Value::as_array)
.flatten()
.filter_map(Value::as_str)
.any(|feature| feature == "custom_tools")
}
pub(super) async fn resolve_runtime_model_registry_metadata(
@@ -33,10 +85,23 @@ pub(super) async fn resolve_runtime_model_registry_metadata(
route_credential: Option<&RuntimeProviderCredential>,
) -> Result<RuntimeModelRegistryMetadata, String> {
if let Some(config) = direct_provider_config {
let tool_mode = runtime_tool_mode(
config
.model_capabilities
.as_ref()
.and_then(|value| value.get("tool_mode").or_else(|| value.get("toolMode")))
.and_then(Value::as_str),
);
let capability_snapshot = config.model_capabilities.as_ref().map(|value| {
let snapshot = runtime_core::capability_snapshot_from_model_capabilities(value);
snapshot
});
let supports_custom_tools = capability_snapshot.as_ref().is_some_and(|snapshot| {
snapshot
.runtime_features
.iter()
.any(|feature| feature == "custom_tools")
});
let chat_wire_was_lowered = capability_snapshot.as_ref().is_some_and(|snapshot| {
snapshot
.input_modalities
@@ -71,7 +136,14 @@ pub(super) async fn resolve_runtime_model_registry_metadata(
"modelAlias": null,
"model_alias": null,
"reasoning": null,
"toolMode": match tool_mode {
tool_runtime::code_mode::RuntimeToolMode::Direct => "direct",
tool_runtime::code_mode::RuntimeToolMode::CodeMode => "code_mode",
tool_runtime::code_mode::RuntimeToolMode::CodeModeOnly => "code_mode_only",
},
}),
tool_mode,
supports_custom_tools,
});
}
@@ -130,6 +202,16 @@ pub(super) async fn resolve_runtime_model_registry_metadata(
"reasoning_effort": model.capabilities.reasoning_effort,
})
});
let tool_mode = runtime_tool_mode(
chat_wire_model
.as_ref()
.and_then(|model| model.tool_mode.as_deref()),
);
let supports_custom_tools = chat_wire_model.as_ref().is_some_and(|model| {
model
.runtime_features
.contains(&ModelRuntimeFeature::CustomTools)
});
Ok(RuntimeModelRegistryMetadata {
payload: json!({
@@ -157,7 +239,10 @@ pub(super) async fn resolve_runtime_model_registry_metadata(
"modelAlias": model_alias,
"model_alias": model_alias,
"reasoning": reasoning,
"toolMode": chat_wire_model.as_ref().and_then(|model| model.tool_mode.clone()),
}),
tool_mode,
supports_custom_tools,
})
}
@@ -243,6 +328,45 @@ mod tests {
assert_eq!(model.input_modalities.len(), 5);
}
#[test]
fn registry_payload_requires_explicit_tool_mode_and_custom_tools_capability() {
let executable = RuntimeModelRegistryMetadata::from_payload(json!({
"model": {
"toolMode": "code_mode_only",
"runtimeFeatures": ["streaming", "custom_tools"]
}
}));
assert_eq!(
executable.tool_mode(),
tool_runtime::code_mode::RuntimeToolMode::CodeModeOnly
);
assert!(executable.supports_custom_tools());
let unknown_mode = RuntimeModelRegistryMetadata::from_payload(json!({
"model": {
"tool_mode": "code_interpreter",
"runtime_features": ["custom_tools"]
}
}));
assert_eq!(
unknown_mode.tool_mode(),
tool_runtime::code_mode::RuntimeToolMode::Direct
);
assert!(unknown_mode.supports_custom_tools());
let missing_capability = RuntimeModelRegistryMetadata::from_payload(json!({
"model": {
"tool_mode": "code_mode",
"runtime_features": ["tool_calling"]
}
}));
assert_eq!(
missing_capability.tool_mode(),
tool_runtime::code_mode::RuntimeToolMode::CodeMode
);
assert!(!missing_capability.supports_custom_tools());
}
#[tokio::test]
async fn custom_provider_declared_model_does_not_infer_reasoning_from_name() {
let db = test_db();
@@ -400,6 +524,11 @@ mod tests {
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
metadata.tool_mode(),
tool_runtime::code_mode::RuntimeToolMode::Direct
);
assert!(!metadata.supports_custom_tools());
let encoded = metadata.payload().to_string();
assert!(!encoded.contains("must-not-persist"));
assert!(!encoded.contains("example.test"));
@@ -6,14 +6,15 @@ use agent_protocol::ModelId;
use agent_runtime::turn_executor::TurnProviderConfiguration;
use app_server_protocol::{
AuthKind, CapabilitySnapshot, ModelRefSource, ModelTaskKind, ModelTaskRequest, ModelTaskSource,
ProtocolKind, ResolvedModelRoute,
ProtocolKind, ResolvedModelRoute, RouteFailure, RouteFailureCategory,
};
use lime_agent::{
route_protocol_from_session_provider_config, supports_direct_route,
ModelRouteProviderConfiguration, SessionProviderConfig,
};
use lime_core::database::dao::api_key_provider::ProviderWithKeys;
use model_provider::runtime_provider::RuntimeProviderAuth;
use model_provider::provider_capabilities::ProviderCapabilities;
use model_provider::runtime_provider::{RuntimeProviderAuth, RuntimeProviderProtocol};
use model_provider::{ModelProviderProtocol, ModelRoute};
use serde_json::{json, Value};
@@ -70,7 +71,7 @@ pub(super) fn resolved_route_from_runtime(
credential_ref: Option<&str>,
direct_provider_config: Option<&SessionProviderConfig>,
) -> ResolvedModelRoute {
crate::model_route_assembly::resolved_route_from_task_with_credential(
let mut resolved_route = crate::model_route_assembly::resolved_route_from_task_with_credential(
task_request,
ModelRouteSelection {
provider_id: &selection.provider,
@@ -82,7 +83,76 @@ pub(super) fn resolved_route_from_runtime(
provider,
credential_ref,
direct_provider_config.map(direct_route_config),
)
);
intersect_resolved_provider_capabilities(
task_request,
selection,
provider,
direct_provider_config,
&mut resolved_route,
);
resolved_route
}
fn intersect_resolved_provider_capabilities(
task_request: &ModelTaskRequest,
selection: &RuntimeModelSelection,
provider: Option<&ProviderWithKeys>,
direct_provider_config: Option<&SessionProviderConfig>,
resolved_route: &mut ResolvedModelRoute,
) {
let provider_name = direct_provider_config
.map(|config| config.provider_name.clone())
.or_else(|| {
provider.map(|provider| provider.provider.effective_provider_type().to_string())
})
.unwrap_or_else(|| selection.provider.clone());
let provider_capabilities =
RuntimeProviderProtocol::from_route_protocol(&resolved_route.protocol)
.map(|protocol| {
ProviderCapabilities::from_resolved_route(
&provider_name,
protocol,
resolved_route.endpoint.base_url.as_deref(),
)
})
.unwrap_or(ProviderCapabilities::NONE);
if provider_capabilities.custom_tools {
return;
}
let original_len = resolved_route.capability_snapshot.runtime_features.len();
resolved_route
.capability_snapshot
.runtime_features
.retain(|feature| normalize_capability(feature) != "custom_tools");
if resolved_route.capability_snapshot.runtime_features.len() == original_len {
return;
}
if resolved_route.failure.is_some() {
return;
}
let Some(capability_gap) =
runtime_core::route_capability_gap(task_request, &resolved_route.capability_snapshot)
else {
return;
};
resolved_route.decision.capability_gap = Some(capability_gap.clone());
resolved_route.failure = Some(RouteFailure {
category: RouteFailureCategory::CapabilityGap,
reason_code: "capability_gap".to_string(),
message: Some(format!("model capability gap: {capability_gap}")),
provider_id: Some(selection.provider.clone()),
model_id: Some(selection.model.clone()),
capability_gap: Some(capability_gap),
retryable: false,
});
}
fn normalize_capability(value: &str) -> String {
value.trim().to_ascii_lowercase().replace(['-', ' '], "_")
}
pub(super) fn model_route_from_runtime(
@@ -287,8 +357,126 @@ mod tests {
use super::*;
use crate::runtime_backend::tests::request_for_test;
use app_server_protocol::RouteFailureCategory;
use chrono::Utc;
use lime_core::database::dao::api_key_provider::{
ApiKeyProvider, ApiProviderType, ProviderGroup,
};
use serde_json::json;
fn configured_provider(
id: &str,
provider_type: ApiProviderType,
api_host: &str,
) -> ProviderWithKeys {
let now = Utc::now();
ProviderWithKeys {
provider: ApiKeyProvider {
id: id.to_string(),
name: id.to_string(),
provider_type,
api_host: api_host.to_string(),
is_system: false,
group: ProviderGroup::Custom,
enabled: true,
sort_order: 0,
api_version: None,
project: None,
location: None,
region: None,
models: Vec::new(),
prompt_cache_mode: None,
created_at: now,
updated_at: now,
},
api_keys: Vec::new(),
}
}
fn resolved_custom_tool_route(
provider_name: &str,
base_url: &str,
protocol: ProtocolKind,
require_custom_tools: bool,
) -> ResolvedModelRoute {
let selection = RuntimeModelSelection {
provider: "openai".to_string(),
model: "gpt-5.2".to_string(),
source: "runtime_options",
reasoning_effort: None,
};
let mut capabilities = vec!["tools".to_string(), "streaming".to_string()];
if require_custom_tools {
capabilities.push("custom_tools".to_string());
}
let task_request = build_model_task_request(ModelTaskRequestInput {
task_kind: ModelTaskKind::Chat,
source: ModelTaskSource::AgentTurn,
provider_id: Some(selection.provider.clone()),
model_id: Some(selection.model.clone()),
model_ref_source: ModelRefSource::RuntimeOptions,
modality_contract_key: Some("chat".to_string()),
routing_slot: Some("coding".to_string()),
task_families: vec!["chat".to_string()],
input_modalities: vec!["text".to_string()],
output_modalities: vec!["text".to_string()],
runtime_features: vec!["streaming".to_string()],
capabilities,
session_id: None,
thread_id: None,
turn_id: None,
content_id: None,
trace_id: None,
});
let routing_payload = json!({
"providerReadiness": {
"ready": true,
"status": "ready"
},
"serviceModelSlot": "coding",
"modelRegistry": {
"source": "canonical",
"status": "matched",
"reasonCode": "matched_canonical_model",
"modelCapabilities": {
"provenance": "canonical",
"capabilities": {
"tools": true,
"streaming": true
},
"taskFamilies": ["chat"],
"inputModalities": ["text"],
"outputModalities": ["text"],
"runtimeFeatures": ["custom_tools", "streaming", "tool_calling"]
}
}
});
let direct_config = SessionProviderConfig {
provider_name: provider_name.to_string(),
provider_selector: Some(provider_name.to_string()),
model_name: selection.model.clone(),
api_key: Some("test-key".to_string()),
base_url: Some(base_url.to_string()),
api_version: None,
credential_uuid: None,
reasoning_effort: None,
service_tier: None,
route_protocol: Some(protocol),
toolshim: false,
toolshim_model: None,
model_capabilities: None,
supports_websockets: false,
};
resolved_route_from_runtime(
&task_request,
&selection,
&routing_payload,
None,
None,
Some(&direct_config),
)
}
#[test]
fn chat_task_request_adds_vision_requirement_for_image_attachments() {
let mut request = request_for_test("看图", None, None);
@@ -464,6 +652,136 @@ mod tests {
);
}
#[test]
fn custom_tools_route_requires_official_openai_responses_host() {
let route = resolved_custom_tool_route(
"openai",
"https://api.openai.com/v1",
ProtocolKind::OpenaiResponses,
true,
);
assert!(route.failure.is_none());
assert!(route
.capability_snapshot
.runtime_features
.contains(&"custom_tools".to_string()));
}
#[test]
fn custom_tools_route_uses_configured_provider_effective_type() {
let selection = RuntimeModelSelection {
provider: "stored-openai-responses".to_string(),
model: "gpt-5.2".to_string(),
source: "runtime_options",
reasoning_effort: None,
};
let task_request = build_model_task_request(ModelTaskRequestInput {
task_kind: ModelTaskKind::Chat,
source: ModelTaskSource::AgentTurn,
provider_id: Some(selection.provider.clone()),
model_id: Some(selection.model.clone()),
model_ref_source: ModelRefSource::RuntimeOptions,
modality_contract_key: Some("chat".to_string()),
routing_slot: Some("coding".to_string()),
task_families: Vec::new(),
input_modalities: Vec::new(),
output_modalities: Vec::new(),
runtime_features: Vec::new(),
capabilities: vec!["custom_tools".to_string()],
session_id: None,
thread_id: None,
turn_id: None,
content_id: None,
trace_id: None,
});
let routing_payload = json!({
"providerReadiness": { "ready": true, "status": "ready" },
"modelRegistry": {
"source": "canonical",
"status": "matched",
"modelCapabilities": {
"provenance": "canonical",
"runtimeFeatures": ["custom_tools"]
}
}
});
let provider = configured_provider(
"stored-openai-responses",
ApiProviderType::OpenaiResponse,
"https://api.openai.com/v1",
);
let route = resolved_route_from_runtime(
&task_request,
&selection,
&routing_payload,
Some(&provider),
None,
None,
);
assert_eq!(route.protocol, ProtocolKind::OpenaiResponses);
assert!(route.failure.is_none());
assert!(route
.capability_snapshot
.runtime_features
.contains(&"custom_tools".to_string()));
}
#[test]
fn custom_tools_route_fails_closed_for_unsupported_provider_routes() {
for (provider_name, base_url, protocol) in [
(
"openai",
"https://gateway.example.com/v1",
ProtocolKind::OpenaiResponses,
),
(
"openai",
"https://api.openai.com/v1",
ProtocolKind::OpenaiChat,
),
(
"azure-openai",
"https://resource.openai.azure.com",
ProtocolKind::OpenaiResponses,
),
(
"ollama",
"http://127.0.0.1:11434",
ProtocolKind::OpenaiResponses,
),
] {
let route = resolved_custom_tool_route(provider_name, base_url, protocol, true);
let failure = route.failure.expect("custom tools capability gap");
assert_eq!(failure.category, RouteFailureCategory::CapabilityGap);
assert_eq!(failure.reason_code, "capability_gap");
assert_eq!(
failure.capability_gap.as_deref(),
Some("capability:custom_tools")
);
assert!(!failure.retryable);
}
}
#[test]
fn normal_chat_does_not_require_custom_tools() {
let route = resolved_custom_tool_route(
"openai",
"https://gateway.example.com/v1",
ProtocolKind::OpenaiResponses,
false,
);
assert!(route.failure.is_none());
assert!(!route
.capability_snapshot
.runtime_features
.contains(&"custom_tools".to_string()));
}
#[test]
fn model_route_from_runtime_projects_lime_provider_route() {
let selection = RuntimeModelSelection {
@@ -20,6 +20,8 @@ pub(super) struct ChatModelRouteResolution {
pub(super) decision_payload: Value,
pub(super) fallback_payload: Option<Value>,
pub(super) not_possible_payload: Option<Value>,
pub(super) tool_mode: tool_runtime::code_mode::RuntimeToolMode,
pub(super) supports_custom_tools: bool,
}
impl ChatModelRouteResolution {
@@ -164,6 +166,8 @@ pub(super) async fn assemble_chat_model_route(
&model_task_request,
&resolved_route,
);
let tool_mode = model_registry.tool_mode();
let supports_custom_tools = model_registry.supports_custom_tools();
Ok(ChatModelRouteResolution {
selection,
model_task_request,
@@ -171,6 +175,8 @@ pub(super) async fn assemble_chat_model_route(
decision_payload: evidence.decision_payload,
fallback_payload: evidence.fallback_payload,
not_possible_payload: evidence.not_possible_payload,
tool_mode,
supports_custom_tools,
})
}
@@ -97,6 +97,9 @@ impl AppServerRuntimeFactory {
RuntimeCore::with_backend(Arc::new(RuntimeBackend::with_execution_process_server(
execution_process.clone(),
)))
.with_code_mode_factory(
agent_runtime::code_mode::RuntimeCodeModeServiceFactory::production(),
)
.with_execution_process_server(execution_process)
}
@@ -105,6 +108,9 @@ impl AppServerRuntimeFactory {
RuntimeCore::with_backend(Arc::new(
RuntimeBackend::with_db_and_execution_process_server(db, execution_process.clone()),
))
.with_code_mode_factory(
agent_runtime::code_mode::RuntimeCodeModeServiceFactory::production(),
)
.with_execution_process_server(execution_process)
}
@@ -118,6 +124,9 @@ impl AppServerRuntimeFactory {
)),
capability_source,
)
.with_code_mode_factory(
agent_runtime::code_mode::RuntimeCodeModeServiceFactory::production(),
)
.with_execution_process_server(execution_process)
}
@@ -133,6 +142,9 @@ impl AppServerRuntimeFactory {
)),
capability_source,
)
.with_code_mode_factory(
agent_runtime::code_mode::RuntimeCodeModeServiceFactory::production(),
)
.with_execution_process_server(execution_process)
}
@@ -186,6 +186,7 @@ pub enum ModelModality {
pub enum ModelRuntimeFeature {
Streaming,
ToolCalling,
CustomTools,
JsonSchema,
Reasoning,
PromptCache,
@@ -442,6 +443,9 @@ pub struct EnhancedModelMetadata {
/// 运行时特性
#[serde(default)]
pub runtime_features: Vec<ModelRuntimeFeature>,
/// 模型显式选择的工具模式;runtime 不得按模型名推断。
#[serde(default)]
pub tool_mode: Option<String>,
/// 模型显式声明支持的 Multi-Agent runtime;缺失时不得推断。
#[serde(default)]
pub multi_agent_version: Option<ModelMultiAgentVersion>,
@@ -502,6 +506,7 @@ impl EnhancedModelMetadata {
input_modalities: vec![],
output_modalities: vec![],
runtime_features: vec![],
tool_mode: None,
multi_agent_version: None,
deployment_source: ModelDeploymentSource::UserCloud,
management_plane: ModelManagementPlane::LocalSettings,
@@ -855,6 +860,7 @@ impl ModelsDevModel {
input_modalities: vec![],
output_modalities: vec![],
runtime_features: vec![],
tool_mode: None,
multi_agent_version: None,
deployment_source: ModelDeploymentSource::UserCloud,
management_plane: ModelManagementPlane::LocalSettings,
+3 -1
View File
@@ -1,6 +1,8 @@
use crate::types::{McpServerConfig, DEFAULT_MCP_SERVER_ENVIRONMENT_ID};
use std::collections::HashMap;
use std::ffi::{OsStr, OsString};
#[cfg(unix)]
use std::ffi::OsStr;
use std::ffi::OsString;
#[cfg(unix)]
use std::path::Path;
@@ -2270,6 +2270,12 @@
"output_modalities": [
"text"
],
"runtime_features": [
"custom_tools",
"streaming",
"tool_calling",
"json_schema"
],
"supports_tools": true,
"pricing": {
"prompt": 1.75e-6,
@@ -75,6 +75,10 @@ pub struct CanonicalModel {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_features: Vec<String>,
/// Model-selected runtime tool surface. Unknown values are ignored by consumers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_mode: Option<String>,
/// Whether the model supports tool calling
#[serde(default)]
pub supports_tools: bool,
@@ -330,6 +334,7 @@ mod tests {
input_modalities: vec!["text".to_string(), "image".to_string(), "file".to_string()],
output_modalities: vec!["text".to_string()],
runtime_features: Vec::new(),
tool_mode: None,
supports_tools: true,
supports_reasoning: false,
supports_prompt_cache: false,
@@ -381,6 +386,7 @@ mod tests {
input_modalities: vec!["text".to_string()],
output_modalities: vec!["image".to_string()],
runtime_features: vec!["images_api".to_string()],
tool_mode: None,
supports_tools: false,
supports_reasoning: false,
supports_prompt_cache: true,
@@ -115,6 +115,7 @@ mod tests {
input_modalities: vec!["text".to_string()],
output_modalities: vec!["text".to_string()],
runtime_features: Vec::new(),
tool_mode: None,
supports_tools: false,
supports_reasoning: false,
supports_prompt_cache: false,
@@ -19,8 +19,8 @@ use futures::future::BoxFuture;
use futures::Stream;
use reqwest::{Client, Response, StatusCode};
pub use runtime_core::{
CanonicalLlmEvent, FailureClassification, FinishReason, GenerationOptions, ModelRerouteReason,
ModelVerification, ProviderMetadata, ToolResultValue, Usage,
CanonicalLlmEvent, FailureClassification, FinishReason, FreeformToolFormat, GenerationOptions,
ModelRerouteReason, ModelVerification, ProviderMetadata, ToolResultValue, Usage,
};
use runtime_core::{CanonicalRequest, CanonicalRole, CanonicalToolDefinition, ContentPart};
use serde_json::Value;
@@ -37,6 +37,8 @@ use tokio_tungstenite::tungstenite::Error as WebSocketError;
#[cfg(test)]
mod azure_responses_tests;
#[cfg(test)]
mod code_mode_tests;
mod gemini;
#[cfg(test)]
mod gemini_tests;
@@ -46,6 +48,8 @@ mod hosted_web_search_tests;
mod lowering;
#[cfg(test)]
mod ollama_responses_tests;
#[cfg(test)]
mod request_capture_tests;
mod stream;
#[cfg(test)]
mod stream_tests;
@@ -239,12 +243,25 @@ impl CurrentProviderRequest {
canonical.tools = self
.tools
.iter()
.map(|tool| CanonicalToolDefinition {
name: tool.name.clone(),
description: tool.description.clone(),
input_schema: tool.input_schema.clone(),
output_schema: None,
metadata: Default::default(),
.map(|tool| match tool {
CurrentProviderTool::Function {
name,
description,
input_schema,
} => CanonicalToolDefinition::function(
name.clone(),
description.clone(),
input_schema.clone(),
),
CurrentProviderTool::Custom {
name,
description,
format,
} => CanonicalToolDefinition::custom(
name.clone(),
description.clone(),
format.clone(),
),
})
.collect();
canonical.generation = self.generation.clone();
@@ -340,6 +357,13 @@ fn canonical_content(
provider_executed: None,
metadata: call.provider_metadata.clone(),
}),
CurrentProviderContent::CustomToolCall(call) => Ok(ContentPart::CustomToolCall {
id: call.id.clone(),
name: call.name.clone(),
input: call.input.clone(),
namespace: call.namespace.clone(),
metadata: call.provider_metadata.clone(),
}),
CurrentProviderContent::ToolResult(result) => Ok(ContentPart::ToolResult {
id: result.call_id.clone(),
name: result.name.clone(),
@@ -357,6 +381,22 @@ fn canonical_content(
provider_executed: Some(false),
metadata: Default::default(),
}),
CurrentProviderContent::CustomToolResult(result) => Ok(ContentPart::CustomToolResult {
id: result.call_id.clone(),
name: result.name.clone(),
result: if result.success {
ToolResultValue::text(result.output.clone())
} else {
ToolResultValue::Error {
value: serde_json::json!({
"output": result.output,
"error": result.error,
}),
}
},
error: result.error.clone(),
metadata: Default::default(),
}),
CurrentProviderContent::RawResponseItem(item) => {
Ok(ContentPart::RawResponseItem { item: item.clone() })
}
@@ -422,7 +462,9 @@ pub enum CurrentProviderContent {
detail: Option<ImageDetail>,
},
ToolCall(CurrentProviderToolCall),
CustomToolCall(CurrentProviderCustomToolCall),
ToolResult(CurrentProviderToolResult),
CustomToolResult(CurrentProviderToolResult),
RawResponseItem(Value),
}
@@ -435,6 +477,27 @@ pub struct CurrentProviderToolCall {
pub provider_metadata: ProviderMetadata,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CurrentProviderCustomToolCall {
pub id: String,
pub name: String,
pub input: String,
pub namespace: Option<String>,
pub provider_metadata: ProviderMetadata,
}
impl CurrentProviderCustomToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, input: impl Into<String>) -> Self {
Self {
id: id.into(),
name: name.into(),
input: input.into(),
namespace: None,
provider_metadata: ProviderMetadata::new(),
}
}
}
impl CurrentProviderToolCall {
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: Value) -> Self {
let raw_arguments = serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".to_string());
@@ -447,29 +510,16 @@ impl CurrentProviderToolCall {
}
}
fn try_from_raw(
id: String,
name: String,
raw_arguments: String,
) -> Result<Self, CurrentProviderError> {
let name = name.trim();
if name.is_empty() {
return Err(CurrentProviderError::new(
"Provider tool call omitted tool name",
));
}
let arguments = serde_json::from_str(&raw_arguments).map_err(|error| {
CurrentProviderError::new(format!(
"Provider returned invalid JSON arguments for tool {name}: {error}"
))
})?;
Ok(Self {
pub fn from_raw(id: String, name: String, raw_arguments: String) -> Self {
let arguments = serde_json::from_str(&raw_arguments)
.unwrap_or_else(|_| Value::String(raw_arguments.clone()));
Self {
id,
name: name.to_string(),
name: name.trim().to_string(),
arguments,
raw_arguments,
provider_metadata: ProviderMetadata::new(),
})
}
}
pub fn with_provider_metadata(mut self, provider_metadata: ProviderMetadata) -> Self {
@@ -488,10 +538,49 @@ pub struct CurrentProviderToolResult {
}
#[derive(Clone, Debug, PartialEq)]
pub struct CurrentProviderTool {
pub name: String,
pub description: String,
pub input_schema: Value,
pub enum CurrentProviderTool {
Function {
name: String,
description: String,
input_schema: Value,
},
Custom {
name: String,
description: String,
format: FreeformToolFormat,
},
}
impl CurrentProviderTool {
pub fn name(&self) -> &str {
match self {
Self::Function { name, .. } | Self::Custom { name, .. } => name,
}
}
pub fn function(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
) -> Self {
Self::Function {
name: name.into(),
description: description.into(),
input_schema,
}
}
pub fn custom(
name: impl Into<String>,
description: impl Into<String>,
format: FreeformToolFormat,
) -> Self {
Self::Custom {
name: name.into(),
description: description.into(),
format,
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
@@ -667,27 +756,25 @@ impl CurrentProviderClient {
request.model_request_policy.as_ref(),
);
let payload = match protocol {
ModelProviderProtocol::Responses => Ok(responses_request(
ModelProviderProtocol::Responses => responses_request(
&self.config,
&canonical_request,
&wire_shape,
&media_payloads,
)),
ModelProviderProtocol::AnthropicMessages => Ok(anthropic_request(
&self.config,
&canonical_request,
&media_payloads,
)),
),
ModelProviderProtocol::AnthropicMessages => {
anthropic_request(&self.config, &canonical_request, &media_payloads)
}
ModelProviderProtocol::GeminiGenerateContent => {
gemini::request(&canonical_request, &media_payloads)
}
ModelProviderProtocol::ChatCompletions | ModelProviderProtocol::Custom(_) => {
Ok(chat_completions_request(
chat_completions_request(
&self.config,
&canonical_request,
&wire_shape,
&media_payloads,
))
)
}
}?;
if matches!(protocol, ModelProviderProtocol::Responses)
@@ -1604,7 +1691,8 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&Default::default(),
);
)
.expect("Responses lowering");
assert_eq!(payload["input"], json!([item]));
}
@@ -1808,7 +1896,8 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&media_payloads,
);
)
.expect("Chat Completions lowering");
assert_eq!(value["messages"][0]["content"][1]["type"], "image_url");
assert_eq!(
@@ -1847,7 +1936,8 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&Default::default(),
);
)
.expect("Chat Completions lowering");
assert_eq!(value["max_tokens"], 128);
assert_eq!(value["temperature"], 0.2);
@@ -1876,12 +1966,14 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&media_payloads,
);
)
.expect("Responses lowering");
let anthropic = anthropic_request(
&config(Some(RuntimeProviderProtocol::AnthropicMessages)),
&canonical,
&media_payloads,
);
)
.expect("Anthropic lowering");
assert_eq!(
responses["input"][0]["content"][1]["image_url"],
@@ -1967,7 +2059,7 @@ mod tests {
}
#[test]
fn responses_tool_call_rejects_invalid_json_arguments() {
fn responses_tool_call_preserves_invalid_json_for_runtime_repair() {
let item = json!({
"type": "function_call",
"call_id": "call-invalid",
@@ -1975,13 +2067,17 @@ mod tests {
"arguments": "{not-json"
});
let error = response_item_tool_call(&item).expect_err("invalid JSON must fail closed");
let call = response_item_tool_call(&item)
.expect("tool call envelope")
.expect("tool call");
assert!(error.message.contains("invalid JSON arguments"));
assert_eq!(call.name, "apply_patch");
assert_eq!(call.arguments, json!("{not-json"));
assert_eq!(call.raw_arguments, "{not-json");
}
#[test]
fn responses_tool_call_rejects_blank_tool_name() {
fn responses_tool_call_preserves_blank_tool_name_for_runtime_repair() {
let item = json!({
"type": "function_call",
"call_id": "call-blank-name",
@@ -1989,9 +2085,12 @@ mod tests {
"arguments": "{}"
});
let error = response_item_tool_call(&item).expect_err("blank tool name must fail closed");
let call = response_item_tool_call(&item)
.expect("tool call envelope")
.expect("tool call");
assert_eq!(error.message, "Provider tool call omitted tool name");
assert_eq!(call.name, "");
assert_eq!(call.arguments, json!({}));
}
#[test]
@@ -2124,7 +2223,7 @@ mod tests {
}
#[tokio::test]
async fn openai_tool_stream_rejects_arguments_without_name() {
async fn openai_tool_stream_preserves_arguments_without_name() {
let body = concat!(
"data: {\"id\":\"chatcmpl-missing-name\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.5\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call-1\",\"type\":\"function\",\"function\":{\"arguments\":\"{}\"}}]},\"finish_reason\":null}]}\n\n",
"data: {\"id\":\"chatcmpl-missing-name\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5.5\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n"
@@ -2144,15 +2243,22 @@ mod tests {
.await
.expect("SSE response");
let error = openai_chat_sse(response)
let events = openai_chat_sse(response)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.expect_err("non-empty incomplete tool call must fail closed");
.expect("incomplete tool call must reach runtime repair");
server.await.expect("fixture server");
assert_eq!(error.message, "Provider tool call omitted tool name");
assert!(events.iter().any(|event| matches!(
event,
CanonicalLlmEvent::ToolCall {
name,
raw_arguments: Some(arguments),
..
} if name.is_empty() && arguments == "{}"
)));
}
#[tokio::test]
@@ -2252,7 +2358,7 @@ mod tests {
}
#[tokio::test]
async fn responses_tool_stream_rejects_terminal_arguments_without_name() {
async fn responses_tool_stream_preserves_terminal_arguments_without_name() {
let body = concat!(
"data: {\"type\":\"response.function_call_arguments.delta\",\"call_id\":\"call-1\",\"delta\":\"{}\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-1\",\"output\":[]}}\n\n"
@@ -2272,15 +2378,22 @@ mod tests {
.await
.expect("SSE response");
let error = responses_sse(response, true)
let events = responses_sse(response, true)
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.expect_err("terminal incomplete tool call must fail closed");
.expect("incomplete tool call must reach runtime repair");
server.await.expect("fixture server");
assert_eq!(error.message, "Provider tool call omitted tool name");
assert!(events.iter().any(|event| matches!(
event,
CanonicalLlmEvent::ToolCall {
name,
raw_arguments: Some(arguments),
..
} if name.is_empty() && arguments == "{}"
)));
}
#[tokio::test]
@@ -140,15 +140,15 @@ fn request_with_tool_history() -> CurrentProviderRequest {
},
)]),
])
.with_tools(vec![CurrentProviderTool {
name: "read_file".to_string(),
description: "Read a file".to_string(),
input_schema: json!({
.with_tools(vec![CurrentProviderTool::function(
"read_file",
"Read a file",
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
}])
)])
}
#[tokio::test]
@@ -0,0 +1,36 @@
use super::lowering::responses_message;
use runtime_core::{
CanonicalMessage, CanonicalRole, ContentPart, ProviderMetadata, ToolResultValue,
};
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn custom_tool_error_keeps_formatted_runtime_output() {
let message = CanonicalMessage {
id: None,
role: CanonicalRole::Tool,
content: vec![ContentPart::CustomToolResult {
id: "custom-call-failed".to_string(),
name: "exec".to_string(),
result: ToolResultValue::Error {
value: json!({
"output": "Script failed\nOutput:\npartial\nScript error:\nboom",
"error": "boom",
}),
},
error: Some("boom".to_string()),
metadata: ProviderMetadata::new(),
}],
metadata: ProviderMetadata::new(),
};
assert_eq!(
responses_message(&message, &BTreeMap::new()),
vec![json!({
"type": "custom_tool_call_output",
"call_id": "custom-call-failed",
"output": "Script failed\nOutput:\npartial\nScript error:\nboom",
})]
);
}
@@ -4,8 +4,8 @@ use async_stream::try_stream;
use futures::{Stream, StreamExt};
use reqwest::Response;
use runtime_core::{
CanonicalLlmEvent as LlmEvent, CanonicalRequest, CanonicalRole, ContentPart,
FailureClassification, FinishReason, ToolResultValue, Usage,
CanonicalLlmEvent as LlmEvent, CanonicalRequest, CanonicalRole, CanonicalToolDefinition,
ContentPart, FailureClassification, FinishReason, ToolResultValue, Usage,
};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
@@ -81,16 +81,27 @@ pub(super) fn request(
.tools
.iter()
.map(|tool| {
let CanonicalToolDefinition::Function {
name,
description,
input_schema,
..
} = tool
else {
return Err(CurrentProviderError::invalid_request(
"custom tools require a Responses provider route",
));
};
let mut declaration = Map::from_iter([
("name".to_string(), json!(tool.name)),
("description".to_string(), json!(tool.description)),
("name".to_string(), json!(name)),
("description".to_string(), json!(description)),
]);
if let Some(parameters) = gemini_tool_schema(&tool.input_schema) {
if let Some(parameters) = gemini_tool_schema(input_schema) {
declaration.insert("parameters".to_string(), parameters);
}
Value::Object(declaration)
Ok(Value::Object(declaration))
})
.collect::<Vec<_>>();
.collect::<Result<Vec<_>, _>>()?;
object.insert(
"tools".to_string(),
json!([{ "functionDeclarations": declarations }]),
@@ -551,7 +562,7 @@ fn reduce_event(
events.push(LlmEvent::ToolInputDelta {
id: id.clone(),
name: name.to_string(),
text: arguments,
text: arguments.clone(),
});
events.push(LlmEvent::ToolInputEnd {
id: id.clone(),
@@ -561,6 +572,7 @@ fn reduce_event(
id,
name: name.to_string(),
input,
raw_arguments: Some(arguments),
provider_executed: None,
provider_metadata: part
.get("thoughtSignature")
@@ -142,10 +142,10 @@ fn request_with_history() -> CurrentProviderRequest {
)]),
])
.with_system_prompt(Some("Be concise.".to_string()))
.with_tools(vec![CurrentProviderTool {
name: "lookup".to_string(),
description: "Lookup data".to_string(),
input_schema: json!({
.with_tools(vec![CurrentProviderTool::function(
"lookup",
"Lookup data",
json!({
"type": "object",
"properties": {
"query": { "type": ["string", "null"] },
@@ -154,7 +154,7 @@ fn request_with_history() -> CurrentProviderRequest {
"required": ["query", "missing"],
"additionalProperties": false
}),
}])
)])
.with_generation(GenerationOptions {
max_tokens: Some(256),
temperature: Some(0.2),
@@ -26,15 +26,15 @@ fn config(provider_name: &str, base_url: Option<&str>) -> RuntimeProviderConfig
}
fn request(tool_name: &str) -> CurrentProviderRequest {
CurrentProviderRequest::new(Vec::new()).with_tools(vec![CurrentProviderTool {
name: tool_name.to_string(),
description: "Search the web".to_string(),
input_schema: json!({
CurrentProviderRequest::new(Vec::new()).with_tools(vec![CurrentProviderTool::function(
tool_name,
"Search the web",
json!({
"type": "object",
"properties": { "query": { "type": "string" } },
"required": ["query"],
}),
}])
)])
}
fn lowered_tools(config: &RuntimeProviderConfig, tool_name: &str) -> serde_json::Value {
@@ -46,7 +46,8 @@ fn lowered_tools(config: &RuntimeProviderConfig, tool_name: &str) -> serde_json:
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
)["tools"]
)
.expect("Responses lowering")["tools"]
.clone()
}
@@ -6,7 +6,9 @@ use crate::provider_capabilities::ProviderCapabilities;
use crate::provider_stream::RuntimeReplyProviderRequestWireShape;
use crate::runtime_provider::RuntimeProviderConfig;
use agent_protocol::ImageDetail;
use runtime_core::{CanonicalRequest, CanonicalRole, ContentPart, ToolResultValue};
use runtime_core::{
CanonicalRequest, CanonicalRole, CanonicalToolDefinition, ContentPart, ToolResultValue,
};
use serde_json::{json, Map, Value};
use std::collections::BTreeMap;
@@ -15,7 +17,8 @@ pub(super) fn chat_completions_request(
request: &CanonicalRequest,
wire_shape: &RuntimeReplyProviderRequestWireShape,
media_payloads: &BTreeMap<String, String>,
) -> Value {
) -> Result<Value, super::CurrentProviderError> {
ensure_no_custom_history(request, "Chat Completions")?;
let mut messages = Vec::new();
let system = text_from_parts(&request.system);
if !system.is_empty() {
@@ -39,7 +42,13 @@ pub(super) fn chat_completions_request(
if !request.tools.is_empty() {
object.insert(
"tools".to_string(),
Value::Array(request.tools.iter().map(chat_tool).collect()),
Value::Array(
request
.tools
.iter()
.map(chat_tool)
.collect::<Result<Vec<_>, _>>()?,
),
);
if let Some(parallel_tool_calls) = wire_shape.parallel_tool_calls {
object.insert(
@@ -61,7 +70,7 @@ pub(super) fn chat_completions_request(
json!({ "enable_thinking": enable_thinking }),
);
}
Value::Object(object)
Ok(Value::Object(object))
}
pub(super) fn responses_request(
@@ -69,7 +78,14 @@ pub(super) fn responses_request(
request: &CanonicalRequest,
wire_shape: &RuntimeReplyProviderRequestWireShape,
media_payloads: &BTreeMap<String, String>,
) -> Value {
) -> Result<Value, super::CurrentProviderError> {
if contains_custom_history(request)
&& !ProviderCapabilities::from_runtime_config(config).custom_tools
{
return Err(super::CurrentProviderError::invalid_request(
"resolved Responses route does not support custom tool call history",
));
}
let mut input = Vec::new();
for message in &request.messages {
input.extend(responses_message(message, media_payloads));
@@ -89,7 +105,7 @@ pub(super) fn responses_request(
.tools
.iter()
.map(|tool| responses_tool(config, tool))
.collect::<Vec<_>>();
.collect::<Result<Vec<_>, _>>()?;
let mut input_prefix = Vec::new();
if tools_in_input {
input_prefix.push(json!({
@@ -135,7 +151,7 @@ pub(super) fn responses_request(
Value::Object(client_metadata),
);
}
Value::Object(object)
Ok(Value::Object(object))
}
fn responses_client_metadata(request: &CanonicalRequest) -> Option<Map<String, Value>> {
@@ -183,7 +199,8 @@ pub(super) fn anthropic_request(
config: &RuntimeProviderConfig,
request: &CanonicalRequest,
media_payloads: &BTreeMap<String, String>,
) -> Value {
) -> Result<Value, super::CurrentProviderError> {
ensure_no_custom_history(request, "Anthropic Messages")?;
let messages = request
.messages
.iter()
@@ -206,19 +223,13 @@ pub(super) fn anthropic_request(
request
.tools
.iter()
.map(|tool| {
json!({
"name": tool.name,
"description": tool.description,
"input_schema": tool.input_schema,
})
})
.collect(),
.map(anthropic_tool)
.collect::<Result<Vec<_>, _>>()?,
),
);
}
apply_generation_options(&mut object, request, "max_tokens", true);
Value::Object(object)
Ok(Value::Object(object))
}
fn apply_generation_options(
@@ -369,6 +380,29 @@ fn chat_message(
}
}
fn ensure_no_custom_history(
request: &CanonicalRequest,
protocol: &str,
) -> Result<(), super::CurrentProviderError> {
if contains_custom_history(request) {
return Err(super::CurrentProviderError::invalid_request(format!(
"{protocol} cannot lower custom tool call history"
)));
}
Ok(())
}
fn contains_custom_history(request: &CanonicalRequest) -> bool {
request.messages.iter().any(|message| {
message.content.iter().any(|part| {
matches!(
part,
ContentPart::CustomToolCall { .. } | ContentPart::CustomToolResult { .. }
)
})
})
}
fn wire_role(role: CanonicalRole) -> &'static str {
match role {
CanonicalRole::System => "system",
@@ -410,39 +444,101 @@ fn chat_content(content: &[ContentPart], media_payloads: &BTreeMap<String, Strin
)
}
fn chat_tool(tool: &runtime_core::CanonicalToolDefinition) -> Value {
json!({
fn chat_tool(tool: &CanonicalToolDefinition) -> Result<Value, super::CurrentProviderError> {
let CanonicalToolDefinition::Function {
name,
description,
input_schema,
..
} = tool
else {
return Err(super::CurrentProviderError::invalid_request(
"custom tools require a Responses provider route",
));
};
Ok(json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
"name": name,
"description": description,
"parameters": input_schema,
"strict": false,
}
})
}))
}
fn responses_tool(
config: &RuntimeProviderConfig,
tool: &runtime_core::CanonicalToolDefinition,
) -> Value {
tool: &CanonicalToolDefinition,
) -> Result<Value, super::CurrentProviderError> {
let CanonicalToolDefinition::Function {
name,
description,
input_schema,
..
} = tool
else {
let CanonicalToolDefinition::Custom {
name,
description,
format,
..
} = tool
else {
unreachable!();
};
if !ProviderCapabilities::from_runtime_config(config).custom_tools {
return Err(super::CurrentProviderError::invalid_request(
"resolved Responses route does not support custom tools",
));
}
return Ok(json!({
"type": "custom",
"name": name,
"description": description,
"format": {
"type": format.r#type,
"syntax": format.syntax,
"definition": format.definition,
},
}));
};
let capabilities = ProviderCapabilities::from_runtime_config(config);
if capabilities.web_search && is_web_search_tool_name(&tool.name) {
return json!({
if capabilities.web_search && is_web_search_tool_name(name) {
return Ok(json!({
"type": "web_search",
"external_web_access": true,
});
}));
}
if capabilities.image_generation && is_image_generation_tool_name(&tool.name) {
return json!({ "type": "image_generation" });
if capabilities.image_generation && is_image_generation_tool_name(name) {
return Ok(json!({ "type": "image_generation" }));
}
json!({
Ok(json!({
"type": "function",
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
"name": name,
"description": description,
"parameters": input_schema,
"strict": false,
})
}))
}
fn anthropic_tool(tool: &CanonicalToolDefinition) -> Result<Value, super::CurrentProviderError> {
let CanonicalToolDefinition::Function {
name,
description,
input_schema,
..
} = tool
else {
return Err(super::CurrentProviderError::invalid_request(
"custom tools require a Responses provider route",
));
};
Ok(json!({
"name": name,
"description": description,
"input_schema": input_schema,
}))
}
fn is_web_search_tool_name(name: &str) -> bool {
@@ -470,7 +566,7 @@ fn strip_responses_lite_image_details(value: &mut Value) {
}
}
fn responses_message(
pub(super) fn responses_message(
message: &runtime_core::CanonicalMessage,
media_payloads: &BTreeMap<String, String>,
) -> Vec<Value> {
@@ -486,6 +582,13 @@ fn responses_message(
"call_id": id,
"output": tool_result_text(result, error.as_deref()),
})),
ContentPart::CustomToolResult {
id, result, error, ..
} => Some(json!({
"type": "custom_tool_call_output",
"call_id": id,
"output": custom_tool_result_text(result, error.as_deref()),
})),
_ => None,
})
.collect(),
@@ -509,6 +612,24 @@ fn responses_message(
"name": name,
"arguments": input.to_string(),
})),
ContentPart::CustomToolCall {
id,
name,
input,
namespace,
..
} => {
let mut item = json!({
"type": "custom_tool_call",
"call_id": id,
"name": name,
"input": input,
});
if let Some(namespace) = namespace {
item["namespace"] = json!(namespace);
}
items.push(item);
}
ContentPart::RawResponseItem { item } => items.push(item.clone()),
_ => {}
}
@@ -589,6 +710,7 @@ fn anthropic_message(
"tool_use_id": id,
"content": tool_result_text(result, error.as_deref()),
})),
ContentPart::CustomToolCall { .. } | ContentPart::CustomToolResult { .. } => None,
ContentPart::RawResponseItem { .. } => None,
})
.collect::<Vec<_>>();
@@ -661,6 +783,17 @@ fn tool_result_text(result: &ToolResultValue, error: Option<&str>) -> String {
}
}
fn custom_tool_result_text(result: &ToolResultValue, error: Option<&str>) -> String {
if let ToolResultValue::Error { value } = result {
if let Some(output) = value.get("output").and_then(Value::as_str) {
if !output.is_empty() {
return output.to_string();
}
}
}
tool_result_text(result, error)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -779,7 +912,8 @@ mod tests {
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Chat Completions lowering");
assert_eq!(chat["reasoning_effort"], "high");
let responses = responses_request(
@@ -787,23 +921,22 @@ mod tests {
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Responses lowering");
assert_eq!(responses["reasoning"], json!({ "effort": "high" }));
}
fn request_with_tool() -> CanonicalRequest {
let mut request = CanonicalRequest::text("gpt-5-codex", "hello");
request.tools = vec![runtime_core::CanonicalToolDefinition {
name: "read_file".to_string(),
description: "Read a file".to_string(),
input_schema: json!({
request.tools = vec![runtime_core::CanonicalToolDefinition::function(
"read_file",
"Read a file",
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
output_schema: None,
metadata: Default::default(),
}];
)];
request
}
@@ -821,7 +954,8 @@ mod tests {
..Default::default()
};
let responses =
responses_request(&config(None), &request, &wire_shape, &BTreeMap::new());
responses_request(&config(None), &request, &wire_shape, &BTreeMap::new())
.expect("Responses lowering");
assert_eq!(
responses
.get("parallel_tool_calls")
@@ -847,7 +981,8 @@ mod tests {
&request,
&wire_shape,
&BTreeMap::new(),
);
)
.expect("Responses lowering");
assert_eq!(
responses["reasoning"],
@@ -874,7 +1009,8 @@ mod tests {
..Default::default()
};
let responses = responses_request(&config(None), &request, &wire_shape, &media_payloads());
let responses = responses_request(&config(None), &request, &wire_shape, &media_payloads())
.expect("Responses lowering");
assert_eq!(
responses,
@@ -921,6 +1057,87 @@ mod tests {
);
}
#[test]
fn custom_responses_tools_preserve_freeform_history_and_fail_closed_elsewhere() {
let mut request = CanonicalRequest::text("gpt-5-codex", "inspect the workspace");
request.tools = vec![CanonicalToolDefinition::custom(
"run_code",
"Run a bounded CodeMode program",
runtime_core::FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "program := statement*".to_string(),
},
)];
request.messages.extend([
runtime_core::CanonicalMessage {
id: None,
role: CanonicalRole::Assistant,
content: vec![ContentPart::CustomToolCall {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
input: "return 42;".to_string(),
namespace: Some("codemode".to_string()),
metadata: BTreeMap::new(),
}],
metadata: BTreeMap::new(),
},
runtime_core::CanonicalMessage {
id: None,
role: CanonicalRole::Tool,
content: vec![ContentPart::CustomToolResult {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
result: ToolResultValue::text("42"),
error: None,
metadata: BTreeMap::new(),
}],
metadata: BTreeMap::new(),
},
]);
let mut official = config(None);
official.provider_name = "openai".to_string();
official.provider_selector = Some("openai".to_string());
official.base_url = Some("https://api.openai.com/v1".to_string());
official.protocol = Some(crate::runtime_provider::RuntimeProviderProtocol::Responses);
let responses = responses_request(
&official,
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
)
.expect("official Responses custom lowering");
assert_eq!(responses["tools"][0]["type"], "custom");
assert_eq!(responses["tools"][0]["format"]["syntax"], "lark");
assert_eq!(responses["input"][1]["type"], "custom_tool_call");
assert_eq!(responses["input"][1]["namespace"], "codemode");
assert_eq!(responses["input"][2]["type"], "custom_tool_call_output");
let mut gateway = official.clone();
gateway.base_url = Some("https://gateway.example.com/v1".to_string());
let error = responses_request(
&gateway,
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
)
.expect_err("gateway custom tools must fail closed");
assert!(error.message.contains("custom tool"));
let error = chat_completions_request(
&gateway,
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
)
.expect_err("Chat Completions custom tools must fail closed");
assert!(error.message.contains("custom tool"));
let error = anthropic_request(&gateway, &request, &BTreeMap::new())
.expect_err("Anthropic custom tools must fail closed");
assert!(error.message.contains("custom tool"));
}
#[test]
fn responses_only_emits_codex_turn_identity_and_protects_reserved_metadata() {
let metadata = super::super::CurrentProviderRequestMetadata::new(
@@ -954,7 +1171,8 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Responses lowering");
let client_metadata = responses["client_metadata"]
.as_object()
.expect("Responses client_metadata");
@@ -981,8 +1199,10 @@ mod tests {
&canonical,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
let anthropic = anthropic_request(&config(None), &canonical, &BTreeMap::new());
)
.expect("Chat Completions lowering");
let anthropic = anthropic_request(&config(None), &canonical, &BTreeMap::new())
.expect("Anthropic lowering");
assert!(chat.get("client_metadata").is_none());
assert!(anthropic.get("client_metadata").is_none());
}
@@ -995,7 +1215,8 @@ mod tests {
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Chat Completions lowering");
assert!(value.get("reasoning_effort").is_none());
}
@@ -1011,13 +1232,15 @@ mod tests {
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Chat Completions lowering");
let responses = responses_request(
&config,
&request,
&RuntimeReplyProviderRequestWireShape::default(),
&BTreeMap::new(),
);
)
.expect("Responses lowering");
assert_eq!(chat["service_tier"], "priority");
assert_eq!(responses["service_tier"], "priority");
@@ -131,15 +131,15 @@ fn request_with_tool_history() -> CurrentProviderRequest {
)]),
])
.with_system_prompt(Some("Follow repository rules".to_string()))
.with_tools(vec![CurrentProviderTool {
name: "read_file".to_string(),
description: "Read a file".to_string(),
input_schema: json!({
.with_tools(vec![CurrentProviderTool::function(
"read_file",
"Read a file",
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
}])
)])
}
#[tokio::test]
@@ -0,0 +1,455 @@
use super::*;
use futures::StreamExt;
use serde_json::{json, Value};
use std::net::SocketAddr;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::oneshot,
task::JoinHandle,
};
#[derive(Debug)]
struct HttpCapture {
path: String,
headers: String,
body: Value,
}
fn provider_config(
base_url: String,
provider_name: &str,
model_name: &str,
protocol: RuntimeProviderProtocol,
) -> RuntimeProviderConfig {
RuntimeProviderConfig {
provider_name: provider_name.to_string(),
provider_selector: Some(provider_name.to_string()),
model_name: model_name.to_string(),
api_key: Some("capture-key".to_string()),
auth: RuntimeProviderAuth::ApiKey,
base_url: Some(base_url),
api_version: None,
credential_uuid: format!("credential-{provider_name}"),
reasoning_effort: None,
service_tier: None,
protocol: Some(protocol),
supports_websockets: false,
toolshim: false,
toolshim_model: None,
}
}
fn request_with_multimodal_tool_history() -> CurrentProviderRequest {
CurrentProviderRequest::new(vec![
CurrentProviderMessage::user(vec![
CurrentProviderContent::Text("inspect this image".to_string()),
CurrentProviderContent::Image {
uri: "sidecar://image-1".to_string(),
media_type: "image/png".to_string(),
provider_data: Some("data:image/png;base64,abc".to_string()),
detail: Some(ImageDetail::High),
},
]),
CurrentProviderMessage::assistant(vec![CurrentProviderContent::ToolCall(
CurrentProviderToolCall::new("call-1", "read_file", json!({ "path": "README.md" })),
)]),
CurrentProviderMessage::tool(vec![CurrentProviderContent::ToolResult(
CurrentProviderToolResult {
call_id: "call-1".to_string(),
name: "read_file".to_string(),
success: true,
output: "Lime".to_string(),
error: None,
},
)]),
])
.with_system_prompt(Some("Follow repository rules".to_string()))
.with_tools(vec![CurrentProviderTool::function(
"read_file",
"Read a file",
json!({
"type": "object",
"properties": { "path": { "type": "string" } },
"required": ["path"]
}),
)])
.with_generation(GenerationOptions {
max_tokens: Some(128),
temperature: Some(0.2),
top_p: Some(0.8),
top_k: Some(16),
})
}
fn request_with_custom_tool_history() -> CurrentProviderRequest {
let mut call = CurrentProviderCustomToolCall::new(
"custom-call-1",
"run_code",
"const answer = await exec({cmd: \"pwd\"}); return answer.stdout;",
);
call.namespace = Some("codemode".to_string());
CurrentProviderRequest::new(vec![
CurrentProviderMessage::user(vec![CurrentProviderContent::Text(
"inspect the workspace".to_string(),
)]),
CurrentProviderMessage::assistant(vec![CurrentProviderContent::CustomToolCall(call)]),
CurrentProviderMessage::tool(vec![CurrentProviderContent::CustomToolResult(
CurrentProviderToolResult {
call_id: "custom-call-1".to_string(),
name: "run_code".to_string(),
success: true,
output: "{\"stdout\":\"/workspace\"}".to_string(),
error: None,
},
)]),
])
.with_system_prompt(Some("Follow repository rules".to_string()))
.with_tools(vec![CurrentProviderTool::custom(
"run_code",
"Run a bounded CodeMode program",
FreeformToolFormat {
r#type: "grammar".to_string(),
syntax: "lark".to_string(),
definition: "program := statement*".to_string(),
},
)])
}
async fn capture_provider_request(
provider_name: &str,
model_name: &str,
protocol: RuntimeProviderProtocol,
response_body: &'static str,
) -> (HttpCapture, Vec<CanonicalLlmEvent>) {
capture_provider_request_with(
request_with_multimodal_tool_history(),
provider_name,
model_name,
protocol,
response_body,
false,
)
.await
}
async fn capture_provider_request_with(
request: CurrentProviderRequest,
provider_name: &str,
model_name: &str,
protocol: RuntimeProviderProtocol,
response_body: &'static str,
official_openai_loopback: bool,
) -> (HttpCapture, Vec<CanonicalLlmEvent>) {
let (base_url, capture, server) = spawn_http_capture_fixture(response_body).await;
let local_address = base_url
.strip_prefix("http://")
.expect("capture fixture uses HTTP")
.parse::<SocketAddr>()
.expect("capture fixture address");
let provider_base_url = if official_openai_loopback {
format!("http://api.openai.com:{}/v1", local_address.port())
} else {
base_url
};
let mut client_builder = Client::builder().no_proxy();
if official_openai_loopback {
client_builder = client_builder.resolve("api.openai.com", local_address);
}
let client = CurrentProviderClient::with_client(
provider_config(provider_base_url, provider_name, model_name, protocol),
client_builder.build().expect("capture fixture HTTP client"),
);
let events = client
.stream(request)
.await
.expect("open provider capture stream")
.collect::<Vec<_>>()
.await
.into_iter()
.collect::<Result<Vec<_>, _>>()
.expect("collect provider capture events");
let capture = capture.await.expect("capture provider request");
server.await.expect("join provider capture fixture");
(capture, events)
}
#[tokio::test]
async fn openai_chat_capture_proves_native_request_and_terminal_stream() {
let response_body = concat!(
"data: {\"id\":\"chatcmpl-capture\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"gpt-5\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"done\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":2,\"completion_tokens\":1,\"total_tokens\":3}}\n\n",
"data: [DONE]\n\n"
);
let (capture, events) = capture_provider_request(
"openai",
"gpt-5",
RuntimeProviderProtocol::ChatCompletions,
response_body,
)
.await;
assert_eq!(capture.path, "/v1/chat/completions");
let headers = capture.headers.to_ascii_lowercase();
assert!(headers.contains("\r\nauthorization: bearer capture-key\r\n"));
assert!(!headers.contains("\r\nx-api-key:"));
assert!(!headers.contains("\r\nanthropic-version:"));
assert_eq!(capture.body["model"], "gpt-5");
assert_eq!(capture.body["messages"][0]["role"], "system");
assert_eq!(
capture.body["messages"][1]["content"][1]["type"],
"image_url"
);
assert_eq!(
capture.body["messages"][2]["tool_calls"][0]["function"]["name"],
"read_file"
);
assert_eq!(capture.body["messages"][3]["tool_call_id"], "call-1");
assert_eq!(capture.body["tools"][0]["function"]["name"], "read_file");
assert_eq!(capture.body["max_tokens"], 128);
assert_eq!(capture.body["stream_options"]["include_usage"], true);
assert!(terminal_matches(&events, "chatcmpl-capture", Some(3)));
}
#[tokio::test]
async fn openai_responses_http_capture_proves_native_request_and_terminal_stream() {
let response_body = concat!(
"data: {\"type\":\"response.output_text.delta\",\"item_id\":\"message-1\",\"delta\":\"done\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-capture\",\"output\":[],\"usage\":{\"input_tokens\":2,\"output_tokens\":1,\"total_tokens\":3}}}\n\n"
);
let (capture, events) = capture_provider_request(
"openai",
"gpt-5",
RuntimeProviderProtocol::Responses,
response_body,
)
.await;
assert_eq!(capture.path, "/v1/responses");
let headers = capture.headers.to_ascii_lowercase();
assert!(headers.contains("\r\nauthorization: bearer capture-key\r\n"));
assert!(!headers.contains("\r\nx-api-key:"));
assert_eq!(capture.body["model"], "gpt-5");
assert_eq!(capture.body["instructions"], "Follow repository rules");
assert_eq!(
capture.body["input"][0]["content"][1]["type"],
"input_image"
);
assert_eq!(capture.body["input"][0]["content"][1]["detail"], "high");
assert_eq!(capture.body["input"][1]["type"], "function_call");
assert_eq!(capture.body["input"][2]["type"], "function_call_output");
assert_eq!(capture.body["tools"][0]["name"], "read_file");
assert_eq!(capture.body["max_output_tokens"], 128);
assert_eq!(capture.body["store"], false);
assert!(terminal_matches(&events, "resp-capture", Some(3)));
}
#[tokio::test]
async fn official_responses_custom_tool_capture_proves_native_wire_and_typed_event() {
let response_body = concat!(
"data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"custom_tool_call\",\"call_id\":\"custom-call-2\",\"name\":\"run_code\",\"namespace\":\"codemode\"}}\n\n",
"data: {\"type\":\"response.custom_tool_call_input.delta\",\"call_id\":\"custom-call-2\",\"name\":\"run_code\",\"namespace\":\"codemode\",\"delta\":\"return 42;\"}\n\n",
"data: {\"type\":\"response.custom_tool_call_input.done\",\"call_id\":\"custom-call-2\",\"name\":\"run_code\",\"namespace\":\"codemode\",\"input\":\"return 42;\"}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-custom-capture\",\"output\":[],\"usage\":{\"input_tokens\":3,\"output_tokens\":2,\"total_tokens\":5}}}\n\n"
);
let (capture, events) = capture_provider_request_with(
request_with_custom_tool_history(),
"openai",
"gpt-5",
RuntimeProviderProtocol::Responses,
response_body,
true,
)
.await;
assert_eq!(capture.path, "/v1/responses");
assert_eq!(
capture.body["tools"][0],
json!({
"type": "custom",
"name": "run_code",
"description": "Run a bounded CodeMode program",
"format": {
"type": "grammar",
"syntax": "lark",
"definition": "program := statement*"
}
})
);
assert_eq!(
capture.body["input"][1],
json!({
"type": "custom_tool_call",
"call_id": "custom-call-1",
"name": "run_code",
"input": "const answer = await exec({cmd: \"pwd\"}); return answer.stdout;",
"namespace": "codemode"
})
);
assert_eq!(
capture.body["input"][2],
json!({
"type": "custom_tool_call_output",
"call_id": "custom-call-1",
"output": "{\"stdout\":\"/workspace\"}"
})
);
assert!(events.iter().any(|event| matches!(
event,
CanonicalLlmEvent::CustomToolCall {
id,
name,
input,
namespace: Some(namespace),
..
} if id == "custom-call-2"
&& name == "run_code"
&& input == "return 42;"
&& namespace == "codemode"
)));
assert!(terminal_matches(&events, "resp-custom-capture", Some(5)));
}
#[tokio::test]
async fn anthropic_capture_proves_native_request_and_terminal_stream() {
let response_body = concat!(
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg-capture\",\"type\":\"message\",\"role\":\"assistant\",\"model\":\"claude-sonnet-4\",\"usage\":{\"input_tokens\":2,\"output_tokens\":0}}}\n\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"done\"}}\n\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":2,\"output_tokens\":1}}\n\n",
"data: {\"type\":\"message_stop\"}\n\n"
);
let (capture, events) = capture_provider_request(
"anthropic",
"claude-sonnet-4",
RuntimeProviderProtocol::AnthropicMessages,
response_body,
)
.await;
assert_eq!(capture.path, "/v1/messages");
let headers = capture.headers.to_ascii_lowercase();
assert!(headers.contains("\r\nx-api-key: capture-key\r\n"));
assert!(headers.contains("\r\nanthropic-version: 2023-06-01\r\n"));
assert!(!headers.contains("\r\nauthorization:"));
assert_eq!(capture.body["model"], "claude-sonnet-4");
assert_eq!(capture.body["system"], "Follow repository rules");
assert_eq!(capture.body["messages"][0]["content"][1]["type"], "image");
assert_eq!(
capture.body["messages"][1]["content"][0]["type"],
"tool_use"
);
assert_eq!(
capture.body["messages"][2]["content"][0]["type"],
"tool_result"
);
assert_eq!(capture.body["tools"][0]["name"], "read_file");
assert_eq!(capture.body["max_tokens"], 128);
assert_eq!(capture.body["top_k"], 16);
assert!(terminal_matches(&events, "msg-capture", None));
assert!(events.iter().any(|event| matches!(
event,
CanonicalLlmEvent::Finish {
usage: Some(usage),
..
} if usage.input_tokens == Some(2) && usage.output_tokens == Some(1)
)));
}
fn terminal_matches(
events: &[CanonicalLlmEvent],
response_id: &str,
total_tokens: Option<u64>,
) -> bool {
events.iter().any(|event| {
matches!(
event,
CanonicalLlmEvent::Finish {
usage: Some(usage),
response_id: Some(actual_response_id),
..
} if actual_response_id == response_id
&& total_tokens.map_or(true, |total| usage.total_tokens == Some(total))
)
})
}
async fn spawn_http_capture_fixture(
response_body: &'static str,
) -> (String, oneshot::Receiver<HttpCapture>, JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind provider capture fixture");
let address = listener.local_addr().expect("provider fixture address");
let (capture_tx, capture_rx) = oneshot::channel();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.expect("accept provider request");
let request = read_http_request(&mut stream).await;
let header_end = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.expect("provider request headers")
+ 4;
let headers = String::from_utf8_lossy(&request[..header_end]).into_owned();
let path = headers
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or_default()
.to_string();
let body = serde_json::from_slice(&request[header_end..]).expect("provider request body");
let _ = capture_tx.send(HttpCapture {
path,
headers,
body,
});
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}",
response_body.len()
);
stream
.write_all(response.as_bytes())
.await
.expect("write provider response");
stream.shutdown().await.expect("close provider response");
});
(format!("http://{address}"), capture_rx, server)
}
async fn read_http_request(stream: &mut tokio::net::TcpStream) -> Vec<u8> {
let mut request = Vec::new();
let mut buffer = [0_u8; 2048];
let mut expected_length = None;
loop {
let read = stream
.read(&mut buffer)
.await
.expect("read provider request");
if read == 0 {
break;
}
request.extend_from_slice(&buffer[..read]);
let Some(header_end) = request
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
else {
continue;
};
let content_length = *expected_length.get_or_insert_with(|| {
String::from_utf8_lossy(&request[..header_end])
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().ok())
.flatten()
})
.expect("provider request content length")
});
if request.len() >= header_end + content_length {
request.truncate(header_end + content_length);
break;
}
}
request
}
@@ -1,4 +1,4 @@
use super::{CurrentProviderError, CurrentProviderToolCall};
use super::{CurrentProviderCustomToolCall, CurrentProviderError, CurrentProviderToolCall};
use agent_protocol::{anthropic, openai};
use async_stream::try_stream;
use futures::{Stream, StreamExt};
@@ -31,29 +31,17 @@ impl ToolCallAccumulator {
self.id.clone().unwrap_or_else(|| fallback.to_string())
}
fn into_call(
&mut self,
fallback: &str,
) -> Result<Option<CurrentProviderToolCall>, CurrentProviderError> {
fn into_call(&mut self, fallback: &str) -> Option<CurrentProviderToolCall> {
if self.emitted {
return Ok(None);
return None;
}
let name = self
.name
.clone()
.ok_or_else(|| CurrentProviderError::new("Provider tool call omitted tool name"))?;
if name.trim().is_empty() {
return Err(CurrentProviderError::new(
"Provider tool call omitted tool name",
));
}
let call = CurrentProviderToolCall::try_from_raw(
let call = CurrentProviderToolCall::from_raw(
self.call_id(fallback),
name,
self.name.clone().unwrap_or_default(),
self.arguments.clone(),
)?;
);
self.emitted = true;
Ok(Some(call))
Some(call)
}
fn begin_input_if_ready(&mut self, fallback: &str) -> Option<(String, String, String)> {
@@ -73,6 +61,49 @@ impl ToolCallAccumulator {
}
}
#[derive(Debug, Default)]
struct CustomToolCallAccumulator {
id: Option<String>,
name: Option<String>,
namespace: Option<String>,
input: String,
emitted: bool,
started: bool,
}
impl CustomToolCallAccumulator {
fn call_id(&self, fallback: &str) -> String {
self.id.clone().unwrap_or_else(|| fallback.to_string())
}
fn into_call(&mut self, fallback: &str) -> Option<CurrentProviderCustomToolCall> {
if self.emitted {
return None;
}
let call = CurrentProviderCustomToolCall {
id: self.call_id(fallback),
name: self.name.clone().unwrap_or_default(),
input: self.input.clone(),
namespace: self.namespace.clone(),
provider_metadata: Default::default(),
};
self.emitted = true;
Some(call)
}
fn begin_input_if_ready(&mut self, fallback: &str) -> Option<(String, String, String)> {
if self.started || self.input.is_empty() {
return None;
}
let name = self.name.as_deref()?.trim();
if name.is_empty() {
return None;
}
self.started = true;
Some((self.call_id(fallback), name.to_string(), self.input.clone()))
}
}
#[derive(Debug, Default)]
struct OpenAiStreamState {
response_id: Option<String>,
@@ -222,7 +253,7 @@ fn take_openai_calls(state: &mut OpenAiStreamState) -> Result<Vec<LlmEvent>, Cur
text: arguments,
});
}
let Some(tool_call) = call.into_call(&format!("call_{index}"))? else {
let Some(tool_call) = call.into_call(&format!("call_{index}")) else {
continue;
};
state.emitted_tool_call = true;
@@ -234,6 +265,7 @@ fn take_openai_calls(state: &mut OpenAiStreamState) -> Result<Vec<LlmEvent>, Cur
id: tool_call.id,
name: tool_call.name,
input: tool_call.arguments,
raw_arguments: Some(tool_call.raw_arguments),
provider_executed: None,
provider_metadata: Default::default(),
});
@@ -297,6 +329,7 @@ fn openai_usage(usage: openai::StreamUsage) -> Usage {
struct ResponsesStreamState {
response_id: Option<String>,
calls: HashMap<String, ToolCallAccumulator>,
custom_calls: HashMap<String, CustomToolCallAccumulator>,
emitted_calls: HashSet<String>,
hosted_tools: HostedToolState,
emitted_tool_call: bool,
@@ -423,6 +456,7 @@ impl ResponsesEventReducer {
if let Some(item) = payload.get("item") {
observe_active_responses_reasoning_item(item, &mut self.state);
absorb_responses_call(item, &mut self.state);
absorb_responses_custom_call(item, &mut self.state);
events.extend(hosted_tool_events(
item,
&mut self.state.hosted_tools,
@@ -476,10 +510,60 @@ impl ResponsesEventReducer {
}
events.extend(take_responses_call(&mut self.state, &key)?);
}
"response.custom_tool_call_input.delta" => {
let key = response_call_key(payload);
let call = self.state.custom_calls.entry(key.clone()).or_default();
if let Some(call_id) = payload.get("call_id").and_then(Value::as_str) {
call.id = Some(call_id.to_string());
}
if let Some(name) = payload.get("name").and_then(Value::as_str) {
call.name = Some(name.to_string());
}
if let Some(namespace) = payload.get("namespace").and_then(Value::as_str) {
call.namespace = Some(namespace.to_string());
}
if let Some(delta) = payload.get("delta").and_then(Value::as_str) {
call.input.push_str(delta);
if let Some((id, name, input)) = call.begin_input_if_ready(&key) {
events.push(LlmEvent::ToolInputStart {
id: id.clone(),
name: name.clone(),
});
events.push(LlmEvent::ToolInputDelta {
id,
name,
text: input,
});
} else if call.started {
if let Some(name) = call.name.clone() {
events.push(LlmEvent::ToolInputDelta {
id: call.call_id(&key),
name,
text: delta.to_string(),
});
}
}
}
}
"response.custom_tool_call_input.done" => {
let key = response_call_key(payload);
let call = self.state.custom_calls.entry(key.clone()).or_default();
if let Some(call_id) = payload.get("call_id").and_then(Value::as_str) {
call.id = Some(call_id.to_string());
}
if let Some(name) = payload.get("name").and_then(Value::as_str) {
call.name = Some(name.to_string());
}
if let Some(input) = payload.get("input").and_then(Value::as_str) {
call.input = input.to_string();
}
events.extend(take_responses_custom_call(&mut self.state, &key)?);
}
"response.output_item.done" => {
if let Some(item) = payload.get("item") {
clear_active_responses_reasoning_item(item, &mut self.state);
absorb_responses_call(item, &mut self.state);
absorb_responses_custom_call(item, &mut self.state);
events.extend(hosted_tool_events(
item,
&mut self.state.hosted_tools,
@@ -489,6 +573,10 @@ impl ResponsesEventReducer {
let key = response_call_key(item);
events.extend(take_responses_call(&mut self.state, &key)?);
}
if item.get("type").and_then(Value::as_str) == Some("custom_tool_call") {
let key = response_call_key(item);
events.extend(take_responses_custom_call(&mut self.state, &key)?);
}
}
}
"response.completed" => {
@@ -511,6 +599,7 @@ impl ResponsesEventReducer {
.flatten()
{
absorb_responses_call(item, &mut self.state);
absorb_responses_custom_call(item, &mut self.state);
events.extend(hosted_tool_events(
item,
&mut self.state.hosted_tools,
@@ -518,6 +607,7 @@ impl ResponsesEventReducer {
)?);
}
events.extend(take_responses_calls(&mut self.state)?);
events.extend(take_responses_custom_calls(&mut self.state)?);
events.extend(
self.state
.text_ids
@@ -691,6 +781,55 @@ fn take_responses_calls(
Ok(events)
}
fn take_responses_custom_calls(
state: &mut ResponsesStreamState,
) -> Result<Vec<LlmEvent>, CurrentProviderError> {
let keys = state.custom_calls.keys().cloned().collect::<Vec<_>>();
let mut events = Vec::new();
for key in keys {
events.extend(take_responses_custom_call(state, &key)?);
}
Ok(events)
}
fn take_responses_custom_call(
state: &mut ResponsesStreamState,
key: &str,
) -> Result<Vec<LlmEvent>, CurrentProviderError> {
let call = state
.custom_calls
.get_mut(key)
.expect("response custom tool call must be accumulated before emission");
let mut events = Vec::new();
if let Some((id, name, input)) = call.begin_input_if_ready(key) {
events.push(LlmEvent::ToolInputStart {
id: id.clone(),
name: name.clone(),
});
events.push(LlmEvent::ToolInputDelta {
id,
name,
text: input,
});
}
let Some(call) = call.into_call(key) else {
return Ok(events);
};
state.emitted_tool_call = true;
events.push(LlmEvent::ToolInputEnd {
id: call.id.clone(),
name: call.name.clone(),
});
events.push(LlmEvent::CustomToolCall {
id: call.id,
name: call.name,
input: call.input,
namespace: call.namespace,
provider_metadata: call.provider_metadata,
});
Ok(events)
}
fn take_responses_call(
state: &mut ResponsesStreamState,
key: &str,
@@ -711,7 +850,7 @@ fn take_responses_call(
text: arguments,
});
}
let Some(call) = call.into_call(key)? else {
let Some(call) = call.into_call(key) else {
return Ok(events);
};
if state.emitted_calls.insert(call.id.clone()) {
@@ -724,6 +863,7 @@ fn take_responses_call(
id: call.id,
name: call.name,
input: call.arguments,
raw_arguments: Some(call.raw_arguments),
provider_executed: None,
provider_metadata: Default::default(),
});
@@ -817,6 +957,32 @@ fn absorb_responses_call(item: &Value, state: &mut ResponsesStreamState) {
}
}
fn absorb_responses_custom_call(item: &Value, state: &mut ResponsesStreamState) {
if item.get("type").and_then(Value::as_str) != Some("custom_tool_call") {
return;
}
let key = response_call_key(item);
let call = state.custom_calls.entry(key).or_default();
call.id = item
.get("call_id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or(call.id.clone());
call.name = item
.get("name")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or(call.name.clone());
call.namespace = item
.get("namespace")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or(call.namespace.clone());
if let Some(input) = item.get("input").and_then(Value::as_str) {
call.input = input.to_string();
}
}
#[cfg(test)]
pub(super) fn response_item_tool_call(
item: &Value,
@@ -839,7 +1005,7 @@ pub(super) fn response_item_tool_call(
.and_then(Value::as_str)
.unwrap_or("{}")
.to_string();
CurrentProviderToolCall::try_from_raw(id, name, arguments).map(Some)
Ok(Some(CurrentProviderToolCall::from_raw(id, name, arguments)))
}
fn responses_usage(value: &Value) -> Usage {
@@ -951,13 +1117,11 @@ pub(super) fn anthropic_sse(
if let Some(call) = state
.calls
.get_mut(&index)
.map(|call| call.into_call(&format!("tool_{index}")))
.transpose()?
.flatten()
.and_then(|call| call.into_call(&format!("tool_{index}")))
{
state.emitted_tool_call = true;
yield LlmEvent::ToolInputEnd { id: call.id.clone(), name: call.name.clone() };
yield LlmEvent::ToolCall { id: call.id, name: call.name, input: call.arguments, provider_executed: None, provider_metadata: Default::default() };
yield LlmEvent::ToolCall { id: call.id, name: call.name, input: call.arguments, raw_arguments: Some(call.raw_arguments), provider_executed: None, provider_metadata: Default::default() };
}
}
anthropic::AnthropicStreamEvent::MessageDelta { delta, usage } => {
@@ -1178,15 +1342,16 @@ mod tests {
}
#[test]
fn openai_tool_call_with_blank_name_fails_closed() {
let error = CurrentProviderToolCall::try_from_raw(
fn openai_tool_call_with_blank_name_preserves_runtime_repair_input() {
let call = CurrentProviderToolCall::from_raw(
"call-blank-name".to_string(),
" ".to_string(),
"{}".to_string(),
)
.expect_err("blank tool name must fail closed");
);
assert_eq!(error.message, "Provider tool call omitted tool name");
assert_eq!(call.name, "");
assert_eq!(call.arguments, json!({}));
assert_eq!(call.raw_arguments, "{}");
}
#[test]
@@ -1208,25 +1373,75 @@ mod tests {
"{\"query\":\"latest Rust release\"}".to_string(),
))
);
let call = call
.into_call("fallback")
.expect("complete tool call")
.expect("tool call");
let call = call.into_call("fallback").expect("tool call");
assert_eq!(call.name, "WebSearch");
assert_eq!(call.arguments["query"], "latest Rust release");
}
#[test]
fn tool_call_accumulator_rejects_terminal_arguments_without_name() {
fn tool_call_accumulator_preserves_terminal_arguments_without_name() {
let mut call = ToolCallAccumulator {
arguments: "{}".to_string(),
..Default::default()
};
let error = call
let call = call
.into_call("call-1")
.expect_err("incomplete tool call must fail closed");
.expect("incomplete tool call must reach runtime repair");
assert_eq!(error.message, "Provider tool call omitted tool name");
assert_eq!(call.id, "call-1");
assert_eq!(call.name, "");
assert_eq!(call.arguments, json!({}));
}
#[test]
fn completed_custom_tool_call_emits_complete_input_lifecycle() {
let events = ResponsesEventReducer::new(None, false)
.push(&json!({
"type": "response.completed",
"response": {
"id": "response-1",
"output": [{
"type": "custom_tool_call",
"call_id": "custom-call-1",
"name": "run_code",
"namespace": "codemode",
"input": "return 42;"
}]
}
}))
.expect("completed custom tool call")
.events;
assert_eq!(
events,
vec![
LlmEvent::ToolInputStart {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
},
LlmEvent::ToolInputDelta {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
text: "return 42;".to_string(),
},
LlmEvent::ToolInputEnd {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
},
LlmEvent::CustomToolCall {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
input: "return 42;".to_string(),
namespace: Some("codemode".to_string()),
provider_metadata: Default::default(),
},
LlmEvent::Finish {
reason: FinishReason::ToolCall,
usage: None,
response_id: Some("response-1".to_string()),
},
]
);
}
}
@@ -113,6 +113,7 @@ fn lifecycle_events(
id: id.clone(),
name: name.to_string(),
input,
raw_arguments: None,
provider_executed: Some(true),
provider_metadata: BTreeMap::from([("raw_response_item".to_string(), item.clone())]),
});
@@ -196,6 +196,7 @@ async fn responses_projects_hosted_web_search_without_local_tool_finish_reason()
input,
provider_executed,
provider_metadata,
..
} => Some((id, name, input, provider_executed, provider_metadata)),
_ => None,
})
@@ -241,6 +242,7 @@ async fn responses_projects_hosted_image_generation_exactly_once_and_finishes_st
input,
provider_executed,
provider_metadata,
..
} => Some((id, name, input, provider_executed, provider_metadata)),
_ => None,
})
@@ -97,6 +97,12 @@ pub(crate) fn canonical_generation_prompt(
ContentPart::Reasoning { .. } => return Err(unsupported(protocol, "reasoning")),
ContentPart::ToolCall { .. } => return Err(unsupported(protocol, "tool_call")),
ContentPart::ToolResult { .. } => return Err(unsupported(protocol, "tool_result")),
ContentPart::CustomToolCall { .. } => {
return Err(unsupported(protocol, "custom_tool_call"));
}
ContentPart::CustomToolResult { .. } => {
return Err(unsupported(protocol, "custom_tool_result"));
}
ContentPart::RawResponseItem { .. } => {
return Err(unsupported(protocol, "raw_response_item"));
}
@@ -3,6 +3,7 @@ use crate::runtime_provider::{RuntimeProviderConfig, RuntimeProviderProtocol};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderCapabilities {
pub namespace_tools: bool,
pub custom_tools: bool,
pub image_generation: bool,
pub web_search: bool,
}
@@ -10,6 +11,7 @@ pub struct ProviderCapabilities {
impl ProviderCapabilities {
pub const NONE: Self = Self {
namespace_tools: false,
custom_tools: false,
image_generation: false,
web_search: false,
};
@@ -20,16 +22,10 @@ impl ProviderCapabilities {
pub fn from_provider_route(provider_type: &str, base_url: Option<&str>) -> Option<Self> {
let protocol = RuntimeProviderProtocol::from_provider_type(provider_type)?;
Some(Self::for_resolved_route(provider_type, protocol, base_url))
Some(Self::from_resolved_route(provider_type, protocol, base_url))
}
pub fn from_runtime_config(config: &RuntimeProviderConfig) -> Self {
config.protocol.map_or(Self::NONE, |protocol| {
Self::for_resolved_route(&config.provider_name, protocol, config.base_url.as_deref())
})
}
fn for_resolved_route(
pub fn from_resolved_route(
provider_name: &str,
protocol: RuntimeProviderProtocol,
base_url: Option<&str>,
@@ -44,10 +40,17 @@ impl ProviderCapabilities {
&& is_official_openai_host(base_url);
Self {
namespace_tools: false,
custom_tools: hosted_tools,
image_generation: hosted_tools,
web_search: hosted_tools,
}
}
pub fn from_runtime_config(config: &RuntimeProviderConfig) -> Self {
config.protocol.map_or(Self::NONE, |protocol| {
Self::from_resolved_route(&config.provider_name, protocol, config.base_url.as_deref())
})
}
}
fn normalize_provider(value: &str) -> String {
@@ -102,6 +105,7 @@ mod tests {
fn hosted_tools_require_official_responses_route_and_host() {
let expected = Some(ProviderCapabilities {
namespace_tools: false,
custom_tools: true,
image_generation: true,
web_search: true,
});
@@ -137,6 +141,38 @@ mod tests {
),
Some(ProviderCapabilities::NONE)
);
assert_eq!(
ProviderCapabilities::from_resolved_route(
"openai",
RuntimeProviderProtocol::Responses,
Some("https://api.openai.com/v1"),
),
expected.expect("official Responses capability")
);
assert_eq!(
ProviderCapabilities::from_resolved_route(
"openai",
RuntimeProviderProtocol::ChatCompletions,
Some("https://api.openai.com/v1"),
),
ProviderCapabilities::NONE
);
assert_eq!(
ProviderCapabilities::from_resolved_route(
"gateway",
RuntimeProviderProtocol::Responses,
Some("https://api.openai.com/v1"),
),
ProviderCapabilities::NONE
);
assert_eq!(
ProviderCapabilities::from_resolved_route(
"azure-openai",
RuntimeProviderProtocol::AzureResponses,
Some("https://resource.openai.azure.com"),
),
ProviderCapabilities::NONE
);
}
#[test]
+1 -1
View File
@@ -11,7 +11,7 @@ pub use context_fragments::{
ContextFragmentSource, ContextSidecarReference,
};
pub use llm_protocol::canonical::{
ContentPart, FailureClassification, FinishReason, GenerationOptions,
ContentPart, FailureClassification, FinishReason, FreeformToolFormat, GenerationOptions,
LlmEvent as CanonicalLlmEvent, Message as CanonicalMessage, ModelRerouteReason,
ModelVerification, ProviderMetadata, Request as CanonicalRequest, Role as CanonicalRole,
ToolDefinition as CanonicalToolDefinition, ToolResultValue, Usage,
@@ -81,6 +81,15 @@ pub enum ContentPart {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
CustomToolCall {
id: String,
name: String,
input: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
namespace: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
ToolResult {
id: String,
name: String,
@@ -92,6 +101,15 @@ pub enum ContentPart {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
CustomToolResult {
id: String,
name: String,
result: ToolResultValue,
#[serde(default, skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
/// A validated Responses API item that must retain its exact provider wire shape.
RawResponseItem { item: Value },
}
@@ -183,16 +201,69 @@ impl Message {
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FreeformToolFormat {
pub r#type: String,
pub syntax: String,
pub definition: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
#[serde(default)]
pub description: String,
pub input_schema: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_schema: Option<Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub metadata: ProviderMetadata,
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ToolDefinition {
Function {
name: String,
#[serde(default)]
description: String,
input_schema: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_schema: Option<Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
Custom {
name: String,
#[serde(default)]
description: String,
format: FreeformToolFormat,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
metadata: ProviderMetadata,
},
}
impl ToolDefinition {
pub fn function(
name: impl Into<String>,
description: impl Into<String>,
input_schema: Value,
) -> Self {
Self::Function {
name: name.into(),
description: description.into(),
input_schema,
output_schema: None,
metadata: ProviderMetadata::new(),
}
}
pub fn custom(
name: impl Into<String>,
description: impl Into<String>,
format: FreeformToolFormat,
) -> Self {
Self::Custom {
name: name.into(),
description: description.into(),
format,
metadata: ProviderMetadata::new(),
}
}
pub fn name(&self) -> &str {
match self {
Self::Function { name, .. } | Self::Custom { name, .. } => name,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
@@ -363,10 +434,21 @@ pub enum LlmEvent {
name: String,
input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
raw_arguments: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provider_executed: Option<bool>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
provider_metadata: ProviderMetadata,
},
CustomToolCall {
id: String,
name: String,
input: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
namespace: Option<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
provider_metadata: ProviderMetadata,
},
ToolResult {
id: String,
name: String,
@@ -10,6 +10,7 @@ pub fn routing_decision_payload(
readiness: &ProviderReadiness,
model_registry_payload: &Value,
) -> Value {
let required_capabilities = required_capabilities(routing, selection);
let selected_provider = selection.provider.clone();
let selected_model = selection.model.clone();
let requested_provider = routing
@@ -41,12 +42,12 @@ pub fn routing_decision_payload(
"requested_model": requested_model,
"fallbackChain": routing.fallback_chain,
"fallback_chain": routing.fallback_chain,
"requiredCapabilities": REQUIRED_CODING_CAPABILITIES,
"required_capabilities": REQUIRED_CODING_CAPABILITIES,
"requiredCapabilities": &required_capabilities,
"required_capabilities": &required_capabilities,
"modelRegistry": model_registry_payload,
"model_registry": model_registry_payload,
});
let model_slot = model_slot_payload(routing, selection);
let model_slot = model_slot_payload(routing, selection, &required_capabilities);
json!({
"backend": "runtime",
@@ -79,8 +80,8 @@ pub fn routing_decision_payload(
"requested_model": requested_model,
"fallbackChain": routing.fallback_chain,
"fallback_chain": routing.fallback_chain,
"requiredCapabilities": REQUIRED_CODING_CAPABILITIES,
"required_capabilities": REQUIRED_CODING_CAPABILITIES,
"requiredCapabilities": &required_capabilities,
"required_capabilities": &required_capabilities,
})
}
@@ -201,7 +202,11 @@ pub fn routing_not_possible_payload_with_attempts(
payload
}
fn model_slot_payload(routing: &ModelRoutingDecision, selection: &RuntimeModelSelection) -> Value {
fn model_slot_payload(
routing: &ModelRoutingDecision,
selection: &RuntimeModelSelection,
required_capabilities: &[String],
) -> Value {
json!({
"serviceModelSlot": routing.service_model_slot,
"service_model_slot": routing.service_model_slot,
@@ -220,11 +225,43 @@ fn model_slot_payload(routing: &ModelRoutingDecision, selection: &RuntimeModelSe
.iter()
.map(profile_slot_payload)
.collect::<Vec<_>>(),
"requiredCapabilities": REQUIRED_CODING_CAPABILITIES,
"required_capabilities": REQUIRED_CODING_CAPABILITIES,
"requiredCapabilities": required_capabilities,
"required_capabilities": required_capabilities,
})
}
fn required_capabilities(
routing: &ModelRoutingDecision,
selection: &RuntimeModelSelection,
) -> Vec<String> {
let mut required = REQUIRED_CODING_CAPABILITIES
.iter()
.map(|capability| (*capability).to_string())
.collect::<Vec<_>>();
let selected_slot = routing.profile_slots.iter().find(|slot| {
slot.slot == routing.service_model_slot
&& slot.provider.as_deref() == Some(selection.provider.as_str())
&& slot.model.as_deref() == Some(selection.model.as_str())
});
for capability in selected_slot
.into_iter()
.flat_map(|slot| slot.capability_tags.iter())
.filter_map(|capability| normalize_capability(capability))
{
if !required.iter().any(|existing| existing == &capability) {
required.push(capability);
}
}
required
}
fn normalize_capability(value: &str) -> Option<String> {
let normalized = value.trim().to_ascii_lowercase().replace(['-', ' '], "_");
(!normalized.is_empty()).then_some(normalized)
}
fn profile_slot_payload(slot: &super::ProfileModelSlot) -> Value {
json!({
"slot": slot.slot,
@@ -174,6 +174,48 @@ fn routing_payload_keeps_review_fast_local_as_diagnostics_only() {
);
}
#[test]
fn routing_payload_requires_only_selected_profile_slot_capabilities() {
let metadata = json!({
"harness": {
"modelSlots": {
"coding": {
"provider": "openai",
"model": "gpt-5.2",
"capabilityTags": ["custom-tools", "tools"]
},
"review": {
"provider": "review-provider",
"model": "review-model",
"capabilityTags": ["review_only"]
}
}
}
});
let selection = selection("openai", "gpt-5.2");
let routing = resolve_model_routing_for_candidate(&[&metadata], &selection);
let payload = routing_decision_payload(
&selection,
&routing,
&ProviderReadiness::direct_request_ready(),
&json!({}),
);
assert_eq!(
payload["requiredCapabilities"],
json!(["coding", "tools", "streaming", "custom_tools"])
);
assert_eq!(
payload["modelSlot"]["requiredCapabilities"],
payload["requiredCapabilities"]
);
assert!(!payload["requiredCapabilities"]
.as_array()
.expect("required capabilities")
.iter()
.any(|capability| capability == "review_only"));
}
#[test]
fn ready_routing_falls_back_from_unready_coding_slot_to_base_slot() {
let metadata = json!({
@@ -181,11 +223,13 @@ fn ready_routing_falls_back_from_unready_coding_slot_to_base_slot() {
"coding_model_slots": {
"coding": {
"provider": "custom-coding",
"model": "missing-key-coder"
"model": "missing-key-coder",
"capabilityTags": ["custom_tools"]
},
"base": {
"provider": "openai",
"model": "gpt-4.1-mini"
"model": "gpt-4.1-mini",
"capabilityTags": ["tools"]
}
}
}
@@ -231,6 +275,16 @@ fn ready_routing_falls_back_from_unready_coding_slot_to_base_slot() {
"openai/gpt-4.1-mini".to_string()
]
);
let payload = routing_decision_payload(
&resolution.selection,
&resolution.routing,
&resolution.readiness,
&json!({}),
);
assert_eq!(
payload["requiredCapabilities"],
json!(["coding", "tools", "streaming"])
);
}
#[test]
@@ -198,6 +198,9 @@ fn capability_satisfied(required: &str, snapshot: &CapabilitySnapshot) -> bool {
.any(|feature| feature == "tool_calling")
}
"function_calling" => snapshot.capabilities.function_calling,
"custom_tools" => normalized_values(&snapshot.runtime_features)
.iter()
.any(|feature| feature == "custom_tools"),
"streaming" => {
snapshot.capabilities.streaming
|| normalized_values(&snapshot.runtime_features)
@@ -439,6 +442,41 @@ mod tests {
);
}
#[test]
fn custom_tool_capability_requires_explicit_runtime_feature() {
let request = build_model_task_request(ModelTaskRequestInput {
task_kind: ModelTaskKind::Chat,
source: ModelTaskSource::AgentTurn,
provider_id: Some("openai".to_string()),
model_id: Some("gpt-5.2".to_string()),
model_ref_source: ModelRefSource::ProfileSlot,
modality_contract_key: None,
routing_slot: Some("coding".to_string()),
task_families: Vec::new(),
input_modalities: Vec::new(),
output_modalities: Vec::new(),
runtime_features: Vec::new(),
capabilities: vec!["custom_tools".to_string()],
session_id: None,
thread_id: None,
turn_id: None,
content_id: None,
trace_id: None,
});
let declared = capability_snapshot_from_model_capabilities(&json!({
"runtimeFeatures": ["custom_tools"]
}));
let missing = capability_snapshot_from_model_capabilities(&json!({
"runtimeFeatures": ["tool_calling"]
}));
assert_eq!(route_capability_gap(&request, &declared), None);
assert_eq!(
route_capability_gap(&request, &missing).as_deref(),
Some("capability:custom_tools")
);
}
#[test]
fn image_generation_capability_uses_declared_task_and_output_modality() {
let request = build_model_task_request(ModelTaskRequestInput {
@@ -193,6 +193,11 @@ async fn collect_provider_output(
} => output
.tool_calls
.push(CollectedToolCall { id, name, input }),
CanonicalLlmEvent::CustomToolCall { .. } => {
return Err(CurrentProviderError::invalid_request(
"legacy provider call endpoint cannot expose custom tool calls",
));
}
CanonicalLlmEvent::Usage { usage } => output.usage = usage,
CanonicalLlmEvent::Finish {
reason,
@@ -515,11 +520,13 @@ fn openai_request_to_current(
.into_iter()
.flat_map(|tools| tools.iter())
.filter_map(|tool| match tool {
agent_protocol::openai::Tool::Function { function } => Some(CurrentProviderTool {
name: function.name.clone(),
description: function.description.clone().unwrap_or_default(),
input_schema: function.parameters.clone().unwrap_or_else(|| json!({})),
}),
agent_protocol::openai::Tool::Function { function } => {
Some(CurrentProviderTool::function(
function.name.clone(),
function.description.clone().unwrap_or_default(),
function.parameters.clone().unwrap_or_else(|| json!({})),
))
}
agent_protocol::openai::Tool::WebSearch
| agent_protocol::openai::Tool::WebSearch20250305 => None,
})
@@ -653,10 +660,12 @@ fn anthropic_request_to_current(
.as_ref()
.into_iter()
.flat_map(|tools| tools.iter())
.map(|tool| CurrentProviderTool {
name: tool.name.clone(),
description: tool.description.clone().unwrap_or_default(),
input_schema: tool.input_schema.clone().unwrap_or_else(|| json!({})),
.map(|tool| {
CurrentProviderTool::function(
tool.name.clone(),
tool.description.clone().unwrap_or_default(),
tool.input_schema.clone().unwrap_or_else(|| json!({})),
)
})
.collect();
Ok(CurrentProviderRequest::new(messages)
@@ -982,6 +991,29 @@ mod tests {
}
}
#[tokio::test]
async fn legacy_provider_output_rejects_custom_tool_calls() {
let error = match collect_provider_output(Box::pin(stream::iter(vec![Ok(
CanonicalLlmEvent::CustomToolCall {
id: "custom-call-1".to_string(),
name: "run_code".to_string(),
input: "return 42;".to_string(),
namespace: Some("codemode".to_string()),
provider_metadata: Default::default(),
},
)])))
.await
{
Ok(_) => panic!("legacy provider output must reject custom calls"),
Err(error) => error,
};
assert_eq!(
error.classification,
Some(FailureClassification::InvalidRequest)
);
assert!(error.message.contains("cannot expose custom tool calls"));
}
#[tokio::test]
async fn anthropic_tool_call_does_not_restart_after_input_end() {
let events = vec![
@@ -1002,6 +1034,7 @@ mod tests {
id: "call-1".to_string(),
name: "lookup".to_string(),
input: json!({}),
raw_arguments: None,
provider_executed: None,
provider_metadata: Default::default(),
}),
@@ -131,6 +131,10 @@ impl OpenAiStreamEncoder {
"data: [DONE]\n\n".to_string(),
]
}
CanonicalLlmEvent::CustomToolCall { .. } => self.error(
"legacy provider call endpoint cannot expose custom tool calls",
Some(FailureClassification::InvalidRequest),
),
CanonicalLlmEvent::TextStart { .. }
| CanonicalLlmEvent::TextEnd { .. }
| CanonicalLlmEvent::ReasoningStart { .. }
@@ -296,6 +300,10 @@ impl AnthropicStreamEncoder {
self.usage = usage.clone();
Vec::new()
}
CanonicalLlmEvent::CustomToolCall { .. } => self.error(
"legacy provider call endpoint cannot expose custom tool calls",
Some(FailureClassification::InvalidRequest),
),
CanonicalLlmEvent::StepFinish { reason, usage, .. } => {
self.finish_reason = *reason;
if let Some(usage) = usage {
@@ -76,6 +76,15 @@ fn normalize_identifier(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn normalize_model_tool_mode(value: Option<&str>) -> Option<String> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
Some("direct") => Some("direct".to_string()),
Some("code_mode") => Some("code_mode".to_string()),
Some("code_mode_only") => Some("code_mode_only".to_string()),
_ => None,
}
}
fn build_search_text(parts: &[Option<String>]) -> String {
parts
.iter()
@@ -269,6 +278,7 @@ fn parse_runtime_feature(value: &str) -> Option<ModelRuntimeFeature> {
"tool_calling" | "tools" | "function_calling" | "functions" => {
Some(ModelRuntimeFeature::ToolCalling)
}
"custom_tools" => Some(ModelRuntimeFeature::CustomTools),
"json_schema" | "json_mode" | "structured_output" => Some(ModelRuntimeFeature::JsonSchema),
"reasoning" | "thinking" => Some(ModelRuntimeFeature::Reasoning),
"prompt_cache" | "prompt_caching" => Some(ModelRuntimeFeature::PromptCache),
@@ -365,6 +375,9 @@ fn merge_api_capability_signals(
"tools" | "tool_calling" | "function_calling" | "functions" => {
push_unique(runtime_features, ModelRuntimeFeature::ToolCalling);
}
"custom_tools" => {
push_unique(runtime_features, ModelRuntimeFeature::CustomTools);
}
"json_mode" | "json_schema" | "structured_output" => {
push_unique(runtime_features, ModelRuntimeFeature::JsonSchema);
}
@@ -3136,6 +3149,7 @@ impl ModelRegistryService {
output_modalities: None,
modalities: None,
runtime_features: None,
tool_mode: None,
vision_supported: None,
capabilities: None,
supported_parameters: None,
@@ -3235,6 +3249,7 @@ impl ModelRegistryService {
output_modalities: None,
modalities: None,
runtime_features: None,
tool_mode: None,
vision_supported: None,
capabilities: None,
supported_parameters: None,
@@ -3277,6 +3292,7 @@ impl ModelRegistryService {
output_modalities: None,
modalities: None,
runtime_features: None,
tool_mode: None,
vision_supported: None,
capabilities: None,
supported_parameters: None,
@@ -3426,6 +3442,11 @@ impl ModelRegistryService {
canonical_model: canonical_model.as_ref(),
});
let tool_mode = normalize_model_tool_mode(model.tool_mode.as_deref().or_else(|| {
canonical_model
.as_ref()
.and_then(|model| model.tool_mode.as_deref())
}));
EnhancedModelMetadata {
id: model.id.clone(),
display_name,
@@ -3444,6 +3465,7 @@ impl ModelRegistryService {
input_modalities: taxonomy.input_modalities,
output_modalities: taxonomy.output_modalities,
runtime_features: taxonomy.runtime_features,
tool_mode,
multi_agent_version: None,
deployment_source: taxonomy.deployment_source,
management_plane: taxonomy.management_plane,
@@ -3487,6 +3509,7 @@ impl ModelRegistryService {
output_modalities: None,
modalities: None,
runtime_features: None,
tool_mode: None,
vision_supported: None,
capabilities: None,
supported_parameters: None,
@@ -3676,6 +3699,8 @@ struct ApiModelResponse {
modalities: Option<ApiModelModalitiesResponse>,
#[serde(default, alias = "runtimeFeatures")]
runtime_features: Option<serde_json::Value>,
#[serde(default, alias = "toolMode")]
tool_mode: Option<String>,
#[serde(
default,
alias = "visionSupported",
@@ -3857,9 +3882,10 @@ pub struct FetchModelsResult {
mod tests {
use super::{
infer_model_capabilities, infer_model_taxonomy, infer_runtime_features,
infer_vision_capability, ModelFetchErrorKind, ModelFetchProtocol, ModelFetchSource,
ModelRegistryService, ModelTaxonomyInput, LIME_TENANT_HEADER,
PROVIDER_MODELS_CACHE_TAXONOMY_VERSION, PROVIDER_MODELS_CACHE_TTL_SECONDS,
infer_vision_capability, normalize_model_tool_mode, ModelFetchErrorKind,
ModelFetchProtocol, ModelFetchSource, ModelRegistryService, ModelTaxonomyInput,
LIME_TENANT_HEADER, PROVIDER_MODELS_CACHE_TAXONOMY_VERSION,
PROVIDER_MODELS_CACHE_TTL_SECONDS,
};
use lime_core::database::dao::api_key_provider::ApiProviderType;
use lime_core::database::dao::route_state::RouteStateDao;
@@ -4099,6 +4125,76 @@ mod tests {
}
}
#[test]
fn test_canonical_custom_tools_declaration_is_exact_to_gpt_5_2() {
let (service, _db) = setup_cache_service();
let response = ModelRegistryService::parse_openai_models_response(
r#"{"data":[{"id":"gpt-5.2"},{"id":"gpt-5.2-pro"}]}"#,
)
.expect("parse OpenAI models response");
let mut models = response
.into_iter()
.map(|model| service.convert_api_model(model, "openai", 0));
let gpt_5_2 = models.next().expect("gpt-5.2");
let gpt_5_2_pro = models.next().expect("gpt-5.2-pro");
assert_eq!(
gpt_5_2.capability_provenance,
ModelCapabilityProvenance::Canonical
);
assert!(gpt_5_2
.runtime_features
.contains(&ModelRuntimeFeature::CustomTools));
assert!(gpt_5_2
.runtime_features
.contains(&ModelRuntimeFeature::Streaming));
assert!(gpt_5_2
.runtime_features
.contains(&ModelRuntimeFeature::ToolCalling));
assert!(!gpt_5_2_pro
.runtime_features
.contains(&ModelRuntimeFeature::CustomTools));
}
#[test]
fn test_model_tool_mode_normalization_fails_closed() {
assert_eq!(
normalize_model_tool_mode(Some("direct")),
Some("direct".into())
);
assert_eq!(
normalize_model_tool_mode(Some(" code_mode ")),
Some("code_mode".into())
);
assert_eq!(
normalize_model_tool_mode(Some("code_mode_only")),
Some("code_mode_only".into())
);
assert_eq!(normalize_model_tool_mode(Some("code_interpreter")), None);
assert_eq!(normalize_model_tool_mode(None), None);
}
#[test]
fn test_provider_model_tool_mode_ingestion_is_explicit() {
let (service, _db) = setup_cache_service();
let response = ModelRegistryService::parse_openai_models_response(
r#"{"data":[
{"id":"fixture-code-mode","toolMode":"code_mode"},
{"id":"fixture-unknown-mode","tool_mode":"code_interpreter"}
]}"#,
)
.expect("parse provider model tool modes");
let mut models = response
.into_iter()
.map(|model| service.convert_api_model(model, "fixture-provider", 0));
assert_eq!(
models.next().expect("code mode model").tool_mode.as_deref(),
Some("code_mode")
);
assert_eq!(models.next().expect("unknown mode model").tool_mode, None);
}
#[test]
fn test_non_official_agnes_endpoint_does_not_gain_canonical_capabilities() {
let (service, _db) = setup_cache_service();
+9 -1
View File
@@ -10,18 +10,24 @@ name = "tool_runtime"
path = "src/lib.rs"
doctest = false
[[bin]]
name = "code-mode-host"
path = "src/bin/code-mode-host.rs"
[dependencies]
agent-protocol.workspace = true
app-server-protocol.workspace = true
async-trait.workspace = true
base64.workspace = true
chrono.workspace = true
deno_core_icudata.workspace = true
dirs.workspace = true
document-preview.workspace = true
encoding_rs = "0.8"
futures.workspace = true
glob.workspace = true
hex.workspace = true
jsonschema.workspace = true
lime-core.workspace = true
lime-skills.workspace = true
patch-apply.workspace = true
@@ -35,11 +41,13 @@ serde_json.workspace = true
sha2.workspace = true
tiktoken-rs.workspace = true
tokio.workspace = true
tokio-util.workspace = true
tokio-util = { workspace = true, features = ["rt"] }
toml_edit.workspace = true
tracing.workspace = true
url.workspace = true
urlencoding.workspace = true
uuid.workspace = true
v8.workspace = true
which = "8.0.0"
[dev-dependencies]
@@ -0,0 +1,7 @@
#[tokio::main]
async fn main() {
if let Err(error) = tool_runtime::code_mode::run_code_mode_host_stdio().await {
eprintln!("code-mode-host failed: {error}");
std::process::exit(1);
}
}
@@ -0,0 +1,714 @@
use crate::tool_definition::RuntimeToolDefinition;
use crate::turn_snapshot::{RuntimeToolIdentity, RuntimeToolSnapshot};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashSet;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
mod process;
mod v8;
pub use process::{default_code_mode_host_path, ProcessCodeModeSessionProvider};
#[doc(hidden)]
pub async fn run_code_mode_host_stdio() -> Result<(), String> {
process::run_stdio().await
}
pub const CODE_MODE_EXEC_TOOL_NAME: &str = "exec";
pub const CODE_MODE_WAIT_TOOL_NAME: &str = "wait";
pub const DEFAULT_CODE_MODE_EXEC_YIELD_TIME_MS: u64 = 10_000;
pub const DEFAULT_CODE_MODE_WAIT_YIELD_TIME_MS: u64 = 10_000;
pub const DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS: usize = 10_000;
pub const CODE_MODE_EXEC_PRAGMA_PREFIX: &str = "// @exec:";
const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
pub const CODE_MODE_EXEC_FREEFORM_GRAMMAR: &str = r#"
start: pragma_source | plain_source
pragma_source: PRAGMA_LINE NEWLINE SOURCE
plain_source: SOURCE
PRAGMA_LINE: /[ \t]*\/\/ @exec:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
"#;
pub fn code_mode_exec_tool_description(tools: &[RuntimeCodeModeTool]) -> String {
let mut description = format!(
"Run JavaScript code to orchestrate tool calls. Each cell evaluates as an async module in a fresh sandbox-enabled V8 isolate; cells in the same thread share values written with store/load. There is no Node.js, file system, network, or console access. The input is raw JavaScript source, not JSON. Long-running cells yield after {} ms and return a cell ID. Nested tools are asynchronous and must be awaited.\n\nGlobal helpers: text(value), image(value, detail?), audio(value), generatedImage(result), store(key, value), load(key), notify(value), setTimeout(callback, delayMs?), clearTimeout(id?), yield_control(), and exit().",
DEFAULT_CODE_MODE_EXEC_YIELD_TIME_MS
);
if !tools.is_empty() {
description.push_str("\n\nAvailable nested tools:\n");
for tool in tools {
description.push_str(&format!(
"- `{}`: {}\n",
tool.global_name, tool.definition.description
));
}
}
description
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeCodeModeParsedExecSource {
pub code: String,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct RuntimeCodeModeExecPragma {
#[serde(default)]
yield_time_ms: Option<u64>,
#[serde(default)]
max_output_tokens: Option<usize>,
}
pub fn parse_code_mode_exec_source(input: &str) -> Result<RuntimeCodeModeParsedExecSource, String> {
if input.trim().is_empty() {
return Err("exec expects non-empty raw JavaScript source text".to_string());
}
let mut parsed = RuntimeCodeModeParsedExecSource {
code: input.to_string(),
yield_time_ms: None,
max_output_tokens: None,
};
let mut lines = input.splitn(2, '\n');
let first_line = lines.next().unwrap_or_default();
let rest = lines.next().unwrap_or_default();
let Some(directive) = first_line
.trim_start()
.strip_prefix(CODE_MODE_EXEC_PRAGMA_PREFIX)
else {
return Ok(parsed);
};
if rest.trim().is_empty() {
return Err("exec pragma must be followed by JavaScript source".to_string());
}
let pragma: RuntimeCodeModeExecPragma = serde_json::from_str(directive.trim()).map_err(|error| {
format!(
"exec pragma must be a JSON object containing only `yield_time_ms` and `max_output_tokens`: {error}"
)
})?;
if pragma
.yield_time_ms
.is_some_and(|value| value > MAX_JS_SAFE_INTEGER)
{
return Err("exec pragma `yield_time_ms` must be a JavaScript safe integer".to_string());
}
if pragma.max_output_tokens.is_some_and(|value| {
u64::try_from(value)
.map(|value| value > MAX_JS_SAFE_INTEGER)
.unwrap_or(true)
}) {
return Err(
"exec pragma `max_output_tokens` must be a JavaScript safe integer".to_string(),
);
}
parsed.code = rest.to_string();
parsed.yield_time_ms = pragma.yield_time_ms;
parsed.max_output_tokens = pragma.max_output_tokens;
Ok(parsed)
}
pub fn code_mode_wait_tool_definition() -> RuntimeToolDefinition {
RuntimeToolDefinition::new(
CODE_MODE_WAIT_TOOL_NAME,
"Waits on a yielded exec cell and returns new output or completion.",
serde_json::json!({
"type": "object",
"required": ["cell_id"],
"additionalProperties": false,
"properties": {
"cell_id": {
"type": "string",
"description": "Identifier of the running exec cell."
},
"yield_time_ms": {
"type": "number",
"description": "Wait before yielding more output. Defaults to 10000 ms."
},
"max_tokens": {
"type": "number",
"description": "Output token budget for this wait call. Defaults to 10000 tokens."
},
"terminate": {
"type": "boolean",
"description": "True stops the running exec cell; false or omitted waits for output."
}
}
}),
)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RuntimeCodeModeCellId(String);
impl RuntimeCodeModeCellId {
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for RuntimeCodeModeCellId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
#[derive(Clone)]
pub struct RuntimeCodeModeExecuteRequest {
pub tool_call_id: String,
pub source: String,
pub enabled_tools: Vec<RuntimeCodeModeTool>,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<usize>,
pub cancellation_token: Option<CancellationToken>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCodeModeWaitRequest {
pub cell_id: RuntimeCodeModeCellId,
pub yield_time_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeCodeModeResponse {
Yielded {
cell_id: RuntimeCodeModeCellId,
output: String,
},
Terminated {
cell_id: RuntimeCodeModeCellId,
output: String,
},
Result {
cell_id: RuntimeCodeModeCellId,
output: String,
error_text: Option<String>,
},
}
impl RuntimeCodeModeResponse {
pub fn cell_id(&self) -> &RuntimeCodeModeCellId {
match self {
Self::Yielded { cell_id, .. }
| Self::Terminated { cell_id, .. }
| Self::Result { cell_id, .. } => cell_id,
}
}
pub fn is_terminal(&self) -> bool {
!matches!(self, Self::Yielded { .. })
}
pub fn into_tool_result(self) -> RuntimeCodeModeToolResult {
self.into_tool_result_with_max_tokens(DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS)
}
pub fn into_tool_result_with_max_tokens(
self,
max_output_tokens: usize,
) -> RuntimeCodeModeToolResult {
let mut result = match self {
Self::Yielded { cell_id, output } => RuntimeCodeModeToolResult {
output: format_code_mode_output(
&format!("Script running with cell ID {cell_id}"),
&output,
None,
),
cell_id,
success: true,
error: None,
},
Self::Terminated { cell_id, output } => RuntimeCodeModeToolResult {
output: format_code_mode_output("Script terminated", &output, None),
cell_id,
success: true,
error: None,
},
Self::Result {
cell_id,
output,
error_text,
} => {
let success = error_text.is_none();
let status = if success {
"Script completed"
} else {
"Script failed"
};
RuntimeCodeModeToolResult {
cell_id,
success,
output: format_code_mode_output(status, &output, error_text.as_deref()),
error: error_text,
}
}
};
result.output = crate::tool_io::format_tool_output_for_model(
&result.output,
crate::tool_io::ToolOutputTruncationPolicy::Tokens(max_output_tokens.max(1)),
);
result
}
}
fn format_code_mode_output(status: &str, output: &str, error_text: Option<&str>) -> String {
let mut formatted = format!("{status}\nOutput:\n{output}");
if let Some(error_text) = error_text {
formatted.push_str("\nScript error:\n");
formatted.push_str(error_text);
}
formatted
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeCodeModeToolResult {
pub cell_id: RuntimeCodeModeCellId,
pub success: bool,
pub output: String,
pub error: Option<String>,
}
impl RuntimeCodeModeToolResult {
pub fn failure(cell_id: RuntimeCodeModeCellId, error: impl Into<String>) -> Self {
let error = error.into();
Self {
cell_id,
success: false,
output: format_code_mode_output("Script failed", "", Some(&error)),
error: Some(error),
}
}
}
pub type RuntimeCodeModeFuture<'a, T> =
Pin<Box<dyn Future<Output = Result<T, String>> + Send + 'a>>;
pub struct RuntimeCodeModeStartedCell {
pub cell_id: RuntimeCodeModeCellId,
initial_response: RuntimeCodeModeFuture<'static, RuntimeCodeModeResponse>,
}
impl RuntimeCodeModeStartedCell {
pub fn new(
cell_id: RuntimeCodeModeCellId,
initial_response: RuntimeCodeModeFuture<'static, RuntimeCodeModeResponse>,
) -> Self {
Self {
cell_id,
initial_response,
}
}
pub fn from_result_receiver(
cell_id: RuntimeCodeModeCellId,
initial_response: oneshot::Receiver<Result<RuntimeCodeModeResponse, String>>,
) -> Self {
Self::new(
cell_id,
Box::pin(async move {
initial_response
.await
.map_err(|_| "code mode runtime ended unexpectedly".to_string())?
}),
)
}
pub async fn initial_response(self) -> Result<RuntimeCodeModeResponse, String> {
self.initial_response.await
}
}
impl fmt::Debug for RuntimeCodeModeStartedCell {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("RuntimeCodeModeStartedCell")
.field("cell_id", &self.cell_id)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RuntimeCodeModeWaitOutcome {
LiveCell(RuntimeCodeModeResponse),
MissingCell(RuntimeCodeModeResponse),
}
impl RuntimeCodeModeWaitOutcome {
pub fn into_response(self) -> RuntimeCodeModeResponse {
match self {
Self::LiveCell(response) | Self::MissingCell(response) => response,
}
}
}
pub trait RuntimeCodeModeSession: Send + Sync {
fn execute<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell>;
/// Binds the nested-tool delegate for one cell without changing the session-wide host delegate.
/// Providers that do not support nested dispatch keep the default execute-only behavior.
fn execute_with_delegate<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
_delegate: Option<Arc<dyn RuntimeCodeModeSessionDelegate>>,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
self.execute(request)
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome>;
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome>;
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()>;
}
#[derive(Clone)]
pub struct RuntimeCodeModeSessionHandle(Arc<dyn RuntimeCodeModeSession>);
impl RuntimeCodeModeSessionHandle {
pub fn new(session: Arc<dyn RuntimeCodeModeSession>) -> Self {
Self(session)
}
pub async fn execute(
&self,
request: RuntimeCodeModeExecuteRequest,
) -> Result<RuntimeCodeModeStartedCell, String> {
self.0.execute(request).await
}
pub async fn execute_with_delegate(
&self,
request: RuntimeCodeModeExecuteRequest,
delegate: Option<Arc<dyn RuntimeCodeModeSessionDelegate>>,
) -> Result<RuntimeCodeModeStartedCell, String> {
self.0.execute_with_delegate(request, delegate).await
}
pub async fn wait(
&self,
request: RuntimeCodeModeWaitRequest,
) -> Result<RuntimeCodeModeWaitOutcome, String> {
self.0.wait(request).await
}
pub async fn terminate(
&self,
cell_id: RuntimeCodeModeCellId,
) -> Result<RuntimeCodeModeWaitOutcome, String> {
self.0.terminate(cell_id).await
}
pub async fn shutdown(&self) -> Result<(), String> {
self.0.shutdown().await
}
}
impl fmt::Debug for RuntimeCodeModeSessionHandle {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RuntimeCodeModeSessionHandle(<runtime>)")
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RuntimeCodeModeNestedToolCall {
pub cell_id: RuntimeCodeModeCellId,
pub runtime_tool_call_id: String,
pub tool_name: String,
pub input: Option<Value>,
}
pub trait RuntimeCodeModeSessionDelegate: Send + Sync {
fn invoke_tool<'a>(
&'a self,
invocation: RuntimeCodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, Value>;
fn notify<'a>(
&'a self,
tool_call_id: String,
cell_id: RuntimeCodeModeCellId,
text: String,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()>;
fn cell_closed(&self, cell_id: &RuntimeCodeModeCellId);
}
pub struct NoopRuntimeCodeModeSessionDelegate;
impl RuntimeCodeModeSessionDelegate for NoopRuntimeCodeModeSessionDelegate {
fn invoke_tool<'a>(
&'a self,
_invocation: RuntimeCodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, Value> {
Box::pin(async move {
cancellation_token.cancelled().await;
Err("code mode nested tools are unavailable".to_string())
})
}
fn notify<'a>(
&'a self,
_tool_call_id: String,
_cell_id: RuntimeCodeModeCellId,
_text: String,
_cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async { Ok(()) })
}
fn cell_closed(&self, _cell_id: &RuntimeCodeModeCellId) {}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCodeModeSessionLimits {
pub max_yield_time_ms: Option<u64>,
pub max_heap_size_bytes: Option<usize>,
}
pub type RuntimeCodeModeSessionProviderFuture<'a> =
RuntimeCodeModeFuture<'a, RuntimeCodeModeSessionHandle>;
pub trait RuntimeCodeModeSessionProvider: Send + Sync {
fn availability(&self) -> Result<(), String> {
Ok(())
}
fn create_session<'a>(
&'a self,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionProviderFuture<'a>;
fn create_session_with_limits<'a>(
&'a self,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
limits: RuntimeCodeModeSessionLimits,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
if limits == RuntimeCodeModeSessionLimits::default() {
self.create_session(delegate)
} else {
Box::pin(async {
Err("code mode session provider does not support resource limits".to_string())
})
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeToolMode {
#[default]
Direct,
CodeMode,
CodeModeOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RuntimeToolModeResolution {
pub requested: RuntimeToolMode,
pub effective: RuntimeToolMode,
pub used_direct_fallback: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeToolModeResolutionError {
CodeModeUnavailable { requested: RuntimeToolMode },
ReservedToolNameCollision { tool_name: String },
}
impl fmt::Display for RuntimeToolModeResolutionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CodeModeUnavailable { requested } => {
write!(
formatter,
"{requested:?} requested but code mode is unavailable"
)
}
Self::ReservedToolNameCollision { tool_name } => {
write!(
formatter,
"tool '{tool_name}' collides with a code mode reserved name"
)
}
}
}
}
impl std::error::Error for RuntimeToolModeResolutionError {}
pub fn resolve_runtime_tool_mode(
requested: RuntimeToolMode,
code_mode_available: bool,
disable_direct_fallback: bool,
) -> Result<RuntimeToolModeResolution, RuntimeToolModeResolutionError> {
let (effective, used_direct_fallback) = match requested {
RuntimeToolMode::Direct => (RuntimeToolMode::Direct, false),
RuntimeToolMode::CodeMode => {
if code_mode_available {
(RuntimeToolMode::CodeMode, false)
} else if disable_direct_fallback {
return Err(RuntimeToolModeResolutionError::CodeModeUnavailable { requested });
} else {
(RuntimeToolMode::Direct, true)
}
}
RuntimeToolMode::CodeModeOnly => {
if !code_mode_available {
return Err(RuntimeToolModeResolutionError::CodeModeUnavailable { requested });
}
(RuntimeToolMode::CodeModeOnly, false)
}
};
Ok(RuntimeToolModeResolution {
requested,
effective,
used_direct_fallback,
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeCodeModeTool {
pub identity: RuntimeToolIdentity,
pub definition: RuntimeToolDefinition,
pub code_name: String,
pub global_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeCodeModeToolPlan {
pub resolution: RuntimeToolModeResolution,
pub model_visible_tools: Vec<RuntimeToolSnapshot>,
pub searchable_tools: Vec<RuntimeToolSnapshot>,
pub nested_tools: Vec<RuntimeCodeModeTool>,
pub shadowed_nested_tools: Vec<RuntimeCodeModeTool>,
}
pub fn plan_runtime_code_mode_tools(
tools: &[RuntimeToolSnapshot],
requested: RuntimeToolMode,
code_mode_available: bool,
disable_direct_fallback: bool,
) -> Result<RuntimeCodeModeToolPlan, RuntimeToolModeResolutionError> {
let resolution =
resolve_runtime_tool_mode(requested, code_mode_available, disable_direct_fallback)?;
let mut model_visible_tools = Vec::new();
let mut searchable_tools = Vec::new();
let mut nested_tools = Vec::new();
let mut shadowed_nested_tools = Vec::new();
let mut nested_global_names = HashSet::new();
for tool in tools {
let exposure = tool.exposure;
let code_name = code_mode_name_for_identity(&tool.identity);
if resolution.effective != RuntimeToolMode::Direct
&& exposure != crate::tool_definition::RuntimeToolExposure::Hidden
&& matches!(
code_name.as_str(),
CODE_MODE_EXEC_TOOL_NAME | CODE_MODE_WAIT_TOOL_NAME
)
{
return Err(RuntimeToolModeResolutionError::ReservedToolNameCollision {
tool_name: code_name,
});
}
if exposure.is_deferred() {
searchable_tools.push(tool.clone());
}
let model_visible = match resolution.effective {
RuntimeToolMode::Direct | RuntimeToolMode::CodeMode => exposure.is_direct(),
RuntimeToolMode::CodeModeOnly => {
exposure.is_direct() && !exposure.is_available_in_code_mode()
}
};
if model_visible {
let mut tool = tool.clone();
tool.model_visible = true;
model_visible_tools.push(tool);
}
if resolution.effective == RuntimeToolMode::Direct || !exposure.is_available_in_code_mode()
{
continue;
}
let global_name = normalize_code_mode_identifier(&code_name);
let nested = RuntimeCodeModeTool {
identity: tool.identity.clone(),
definition: tool.definition.clone(),
code_name,
global_name: global_name.clone(),
};
if nested_global_names.insert(global_name) {
nested_tools.push(nested);
} else {
shadowed_nested_tools.push(nested);
}
}
Ok(RuntimeCodeModeToolPlan {
resolution,
model_visible_tools,
searchable_tools,
nested_tools,
shadowed_nested_tools,
})
}
pub fn code_mode_name_for_identity(identity: &RuntimeToolIdentity) -> String {
let Some(namespace) = identity.namespace.as_deref() else {
return identity.name.clone();
};
if namespace.ends_with('_') || identity.name.starts_with('_') {
format!("{namespace}{}", identity.name)
} else {
format!("{namespace}__{}", identity.name)
}
}
pub fn normalize_code_mode_identifier(tool_name: &str) -> String {
let mut identifier = String::new();
for (index, character) in tool_name.chars().enumerate() {
let valid = if index == 0 {
character == '_' || character == '$' || character.is_ascii_alphabetic()
} else {
character == '_' || character == '$' || character.is_ascii_alphanumeric()
};
identifier.push(if valid { character } else { '_' });
}
if identifier.is_empty() {
"_".to_string()
} else {
identifier
}
}
#[cfg(test)]
#[path = "code_mode/tests.rs"]
mod tests;
@@ -0,0 +1,535 @@
use super::client_state::{ConnectionState, PendingRequest};
use super::default_code_mode_host_path;
use super::protocol::{
ClientHello, ClientToHost, FramedReader, FramedWriter, HostHello, HostRequest, HostResponse,
HostToClient, WireExecuteRequest, PROTOCOL_VERSION, SESSION_LIMITS_CAPABILITY,
};
use crate::code_mode::{
RuntimeCodeModeCellId, RuntimeCodeModeExecuteRequest, RuntimeCodeModeFuture,
RuntimeCodeModeSession, RuntimeCodeModeSessionDelegate, RuntimeCodeModeSessionHandle,
RuntimeCodeModeSessionLimits, RuntimeCodeModeSessionProvider,
RuntimeCodeModeSessionProviderFuture, RuntimeCodeModeStartedCell, RuntimeCodeModeWaitOutcome,
RuntimeCodeModeWaitRequest,
};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::sync::{mpsc, oneshot, Mutex as AsyncMutex, Semaphore};
use tokio_util::sync::CancellationToken;
const HOST_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const OUTGOING_QUEUE_CAPACITY: usize = 256;
pub struct ProcessCodeModeSessionProvider {
host_path: PathBuf,
connection: AsyncMutex<Option<Arc<ProcessConnection>>>,
connect_permit: Semaphore,
next_session_id: AtomicU64,
}
impl ProcessCodeModeSessionProvider {
pub fn with_host_path(host_path: PathBuf) -> Self {
Self {
host_path,
connection: AsyncMutex::new(None),
connect_permit: Semaphore::new(1),
next_session_id: AtomicU64::new(1),
}
}
async fn connection(&self) -> Result<Arc<ProcessConnection>, String> {
if let Some(connection) = self.live_connection().await {
return Ok(connection);
}
let _permit = self
.connect_permit
.acquire()
.await
.map_err(|_| "code mode host connection coordinator closed".to_string())?;
if let Some(connection) = self.live_connection().await {
return Ok(connection);
}
let connection = Arc::new(ProcessConnection::spawn(&self.host_path).await?);
self.connection
.lock()
.await
.replace(Arc::clone(&connection));
Ok(connection)
}
async fn live_connection(&self) -> Option<Arc<ProcessConnection>> {
self.connection
.lock()
.await
.as_ref()
.filter(|connection| connection.is_alive())
.cloned()
}
}
impl Default for ProcessCodeModeSessionProvider {
fn default() -> Self {
Self::with_host_path(default_code_mode_host_path())
}
}
impl RuntimeCodeModeSessionProvider for ProcessCodeModeSessionProvider {
fn availability(&self) -> Result<(), String> {
if self.host_path.is_file() {
Ok(())
} else {
Err(format!(
"code mode host executable was not found: {}",
self.host_path.display()
))
}
}
fn create_session<'a>(
&'a self,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
self.create_session_with_limits(delegate, RuntimeCodeModeSessionLimits::default())
}
fn create_session_with_limits<'a>(
&'a self,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
limits: RuntimeCodeModeSessionLimits,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
Box::pin(async move {
self.availability()?;
let connection = self.connection().await?;
let session_id = format!(
"session-{}",
self.next_session_id.fetch_add(1, Ordering::Relaxed)
);
connection
.open_session(session_id.clone(), Arc::clone(&delegate), limits)
.await?;
Ok(RuntimeCodeModeSessionHandle::new(Arc::new(
ProcessCodeModeSession {
connection,
session_id,
closed: AtomicBool::new(false),
},
)))
})
}
}
struct ProcessCodeModeSession {
connection: Arc<ProcessConnection>,
session_id: String,
closed: AtomicBool,
}
impl RuntimeCodeModeSession for ProcessCodeModeSession {
fn execute<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
Box::pin(async move {
if self.closed.load(Ordering::Acquire) {
return Err("code mode session is closed".to_string());
}
self.connection
.execute(self.session_id.clone(), request)
.await
})
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
if self.closed.load(Ordering::Acquire) {
return Err("code mode session is closed".to_string());
}
self.connection.wait(self.session_id.clone(), request).await
})
}
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
if self.closed.load(Ordering::Acquire) {
return Err("code mode session is closed".to_string());
}
self.connection
.terminate(self.session_id.clone(), cell_id)
.await
})
}
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()> {
Box::pin(async move {
if self.closed.swap(true, Ordering::AcqRel) {
return Ok(());
}
self.connection
.shutdown_session(self.session_id.clone())
.await
})
}
}
impl Drop for ProcessCodeModeSession {
fn drop(&mut self) {
if self.closed.swap(true, Ordering::AcqRel) {
return;
}
let connection = Arc::clone(&self.connection);
let session_id = self.session_id.clone();
if tokio::runtime::Handle::try_current().is_ok() {
tokio::spawn(async move {
let _ = connection.shutdown_session(session_id).await;
});
}
}
}
struct ProcessConnection {
outgoing: mpsc::Sender<ClientToHost>,
state: Arc<ConnectionState>,
next_request_id: AtomicU64,
cancellation: CancellationToken,
}
impl ProcessConnection {
async fn spawn(host_path: &Path) -> Result<Self, String> {
let mut child = Command::new(host_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|error| {
format!(
"failed to spawn code mode host {}: {error}",
host_path.display()
)
})?;
let stdin = child
.stdin
.take()
.ok_or_else(|| "code mode host stdin was unavailable".to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "code mode host stdout was unavailable".to_string())?;
let stderr = child.stderr.take();
let cancellation = CancellationToken::new();
let state = Arc::new(ConnectionState::new(cancellation.clone()));
let mut reader = FramedReader::new(stdout);
let mut writer = FramedWriter::new(stdin);
writer
.write(&ClientToHost::ClientHello(ClientHello {
supported_versions: vec![PROTOCOL_VERSION],
required_capabilities: vec![SESSION_LIMITS_CAPABILITY.to_string()],
optional_capabilities: Vec::new(),
}))
.await
.map_err(|error| format!("failed to write code mode host handshake: {error}"))?;
let hello = tokio::time::timeout(HOST_HANDSHAKE_TIMEOUT, reader.read::<HostToClient>())
.await
.map_err(|_| "code mode host handshake timed out".to_string())?
.map_err(|error| format!("failed to read code mode host handshake: {error}"))?
.ok_or_else(|| "code mode host exited during handshake".to_string())?;
let HostToClient::HostHello(HostHello {
selected_version,
capabilities,
host_pid,
}) = hello
else {
return Err(match hello {
HostToClient::HandshakeRejected { reason } => {
format!("code mode host rejected handshake: {reason}")
}
_ => "code mode host returned an invalid handshake response".to_string(),
});
};
if selected_version != PROTOCOL_VERSION {
return Err(format!(
"code mode host selected unsupported protocol version {selected_version}"
));
}
if !capabilities
.iter()
.any(|capability| capability == SESSION_LIMITS_CAPABILITY)
{
return Err(format!(
"code mode host omitted required capability {SESSION_LIMITS_CAPABILITY}"
));
}
tracing::debug!(host_pid, "connected to standalone code mode host");
let (outgoing, outgoing_rx) = mpsc::channel(OUTGOING_QUEUE_CAPACITY);
spawn_writer(writer, outgoing_rx, Arc::clone(&state));
spawn_reader(reader, outgoing.clone(), Arc::clone(&state));
spawn_child_supervisor(child, Arc::clone(&state));
if let Some(stderr) = stderr {
spawn_stderr_reader(stderr);
}
Ok(Self {
outgoing,
state,
next_request_id: AtomicU64::new(1),
cancellation,
})
}
fn is_alive(&self) -> bool {
self.state.alive.load(Ordering::Acquire)
}
async fn open_session(
&self,
session_id: String,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
limits: RuntimeCodeModeSessionLimits,
) -> Result<(), String> {
let response = self
.request(HostRequest::OpenSession {
session_id: session_id.clone(),
cell_execution_limits: Some(limits),
})
.await?;
match response {
HostResponse::SessionReady {
session_id: ready_id,
} if ready_id == session_id => {
self.state.insert_session(session_id, delegate);
Ok(())
}
other => Err(format!(
"unexpected code mode open-session response: {other:?}"
)),
}
}
async fn execute(
&self,
session_id: String,
request: RuntimeCodeModeExecuteRequest,
) -> Result<RuntimeCodeModeStartedCell, String> {
let cancellation_token = request.cancellation_token.clone();
let wire_request = WireExecuteRequest::try_from(request)?;
let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (started_tx, started_rx) = oneshot::channel();
let (initial_tx, initial_rx) = oneshot::channel();
self.state.register_pending(
id,
PendingRequest::Execute {
started: started_tx,
initial: initial_tx,
},
)?;
if let Err(error) = self
.send(ClientToHost::Request {
id,
request: HostRequest::Execute {
session_id,
request: wire_request,
},
})
.await
{
self.state.remove_pending(id);
return Err(error);
}
if let Some(token) = cancellation_token {
let outgoing = self.outgoing.clone();
let state = Arc::clone(&self.state);
let finished = CancellationToken::new();
state.register_caller_cancellation_watcher(id, finished.clone());
tokio::spawn(async move {
tokio::select! {
() = token.cancelled() => {
if state.has_pending(id) {
let _ = outgoing.send(ClientToHost::CancelRequest { id }).await;
}
}
() = finished.cancelled() => {}
}
});
}
let started = started_rx
.await
.map_err(|_| self.state.failure_message())??;
let HostResponse::ExecutionStarted { cell_id } = started else {
return Err(format!(
"unexpected code mode execute response: {started:?}"
));
};
Ok(RuntimeCodeModeStartedCell::from_result_receiver(
cell_id, initial_rx,
))
}
async fn wait(
&self,
session_id: String,
request: RuntimeCodeModeWaitRequest,
) -> Result<RuntimeCodeModeWaitOutcome, String> {
match self
.request(HostRequest::Wait {
session_id,
request,
})
.await?
{
HostResponse::WaitCompleted { outcome } => Ok(outcome),
other => Err(format!("unexpected code mode wait response: {other:?}")),
}
}
async fn terminate(
&self,
session_id: String,
cell_id: RuntimeCodeModeCellId,
) -> Result<RuntimeCodeModeWaitOutcome, String> {
match self
.request(HostRequest::Terminate {
session_id,
cell_id,
})
.await?
{
HostResponse::WaitCompleted { outcome } => Ok(outcome),
other => Err(format!(
"unexpected code mode terminate response: {other:?}"
)),
}
}
async fn shutdown_session(&self, session_id: String) -> Result<(), String> {
let response = self
.request(HostRequest::ShutdownSession {
session_id: session_id.clone(),
})
.await;
self.state.remove_session(&session_id);
match response? {
HostResponse::SessionClosed {
session_id: closed_id,
} if closed_id == session_id => Ok(()),
other => Err(format!("unexpected code mode shutdown response: {other:?}")),
}
}
async fn request(&self, request: HostRequest) -> Result<HostResponse, String> {
let id = self.next_request_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
self.state
.register_pending(id, PendingRequest::Standard(sender))?;
if let Err(error) = self.send(ClientToHost::Request { id, request }).await {
self.state.remove_pending(id);
return Err(error);
}
receiver.await.map_err(|_| self.state.failure_message())?
}
async fn send(&self, message: ClientToHost) -> Result<(), String> {
if !self.is_alive() {
return Err(self.state.failure_message());
}
self.outgoing
.send(message)
.await
.map_err(|_| self.state.failure_message())
}
}
impl Drop for ProcessConnection {
fn drop(&mut self) {
self.cancellation.cancel();
}
}
fn spawn_writer<W>(
mut writer: FramedWriter<W>,
mut outgoing: mpsc::Receiver<ClientToHost>,
state: Arc<ConnectionState>,
) where
W: tokio::io::AsyncWrite + Send + Unpin + 'static,
{
tokio::spawn(async move {
while let Some(message) = outgoing.recv().await {
if let Err(error) = writer.write(&message).await {
state.fail(format!("code mode host writer failed: {error}"));
return;
}
}
state.fail("code mode host writer stopped".to_string());
});
}
fn spawn_reader<R>(
mut reader: FramedReader<R>,
outgoing: mpsc::Sender<ClientToHost>,
state: Arc<ConnectionState>,
) where
R: tokio::io::AsyncRead + Send + Unpin + 'static,
{
tokio::spawn(async move {
loop {
match reader.read::<HostToClient>().await {
Ok(Some(message)) => {
if let Err(error) = state.handle_host_message(message, &outgoing) {
state.fail(error);
return;
}
}
Ok(None) => {
state.fail("code mode host closed stdout".to_string());
return;
}
Err(error) => {
state.fail(format!("code mode host reader failed: {error}"));
return;
}
}
}
});
}
fn spawn_child_supervisor(mut child: Child, state: Arc<ConnectionState>) {
tokio::spawn(async move {
let result = tokio::select! {
status = child.wait() => status.map_err(|error| error.to_string()),
() = state.cancellation.cancelled() => {
let _ = child.kill().await;
child.wait().await.map_err(|error| error.to_string())
}
};
let message = match result {
Ok(status) => format!("code mode host exited with {status}"),
Err(error) => format!("failed to wait for code mode host: {error}"),
};
state.fail(message);
});
}
fn spawn_stderr_reader<R>(stderr: R)
where
R: tokio::io::AsyncRead + Send + Unpin + 'static,
{
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::warn!(target: "code_mode_host", "{line}");
}
});
}
@@ -0,0 +1,314 @@
use super::protocol::{
ClientToHost, DelegateRequest, DelegateResponse, HostResponse, HostToClient, WireResult,
MAX_IN_FLIGHT_REQUESTS, MAX_PENDING_DELEGATE_CALLS,
};
use crate::code_mode::{RuntimeCodeModeResponse, RuntimeCodeModeSessionDelegate};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use tokio::sync::{mpsc, oneshot};
use tokio_util::sync::CancellationToken;
pub(super) struct ConnectionState {
pub(super) alive: AtomicBool,
failure: Mutex<Option<String>>,
pending: Mutex<HashMap<u64, PendingRequest>>,
sessions: Mutex<HashMap<String, Arc<dyn RuntimeCodeModeSessionDelegate>>>,
delegate_cancellations: Mutex<HashMap<u64, CancellationToken>>,
caller_cancellation_watchers: Mutex<HashMap<u64, CancellationToken>>,
pub(super) cancellation: CancellationToken,
}
pub(super) enum PendingRequest {
Standard(oneshot::Sender<Result<HostResponse, String>>),
Execute {
started: oneshot::Sender<Result<HostResponse, String>>,
initial: oneshot::Sender<Result<RuntimeCodeModeResponse, String>>,
},
ExecuteStarted(oneshot::Sender<Result<RuntimeCodeModeResponse, String>>),
}
impl ConnectionState {
pub(super) fn new(cancellation: CancellationToken) -> Self {
Self {
alive: AtomicBool::new(true),
failure: Mutex::new(None),
pending: Mutex::new(HashMap::new()),
sessions: Mutex::new(HashMap::new()),
delegate_cancellations: Mutex::new(HashMap::new()),
caller_cancellation_watchers: Mutex::new(HashMap::new()),
cancellation,
}
}
pub(super) fn insert_session(
&self,
session_id: String,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) {
self.sessions
.lock()
.expect("code mode session delegates poisoned")
.insert(session_id, delegate);
}
pub(super) fn remove_session(&self, session_id: &str) {
self.sessions
.lock()
.expect("code mode session delegates poisoned")
.remove(session_id);
}
pub(super) fn register_pending(&self, id: u64, request: PendingRequest) -> Result<(), String> {
let mut pending = self
.pending
.lock()
.expect("code mode pending requests poisoned");
if pending.len() >= MAX_IN_FLIGHT_REQUESTS {
return Err(format!(
"code mode host request limit {MAX_IN_FLIGHT_REQUESTS} exceeded"
));
}
if pending.insert(id, request).is_some() {
return Err(format!("duplicate code mode host request id {id}"));
}
Ok(())
}
pub(super) fn remove_pending(&self, id: u64) -> Option<PendingRequest> {
self.pending
.lock()
.expect("code mode pending requests poisoned")
.remove(&id)
}
pub(super) fn has_pending(&self, id: u64) -> bool {
self.pending
.lock()
.expect("code mode pending requests poisoned")
.contains_key(&id)
}
pub(super) fn register_caller_cancellation_watcher(&self, id: u64, token: CancellationToken) {
self.caller_cancellation_watchers
.lock()
.expect("code mode caller cancellation watchers poisoned")
.insert(id, token);
}
pub(super) fn failure_message(&self) -> String {
self.failure
.lock()
.expect("code mode connection failure poisoned")
.clone()
.unwrap_or_else(|| "code mode host connection closed".to_string())
}
pub(super) fn fail(&self, reason: impl Into<String>) {
if !self.alive.swap(false, Ordering::AcqRel) {
return;
}
let reason = reason.into();
self.failure
.lock()
.expect("code mode connection failure poisoned")
.get_or_insert_with(|| reason.clone());
let pending = std::mem::take(
&mut *self
.pending
.lock()
.expect("code mode pending requests poisoned"),
);
for request in pending.into_values() {
match request {
PendingRequest::Standard(sender) => {
let _ = sender.send(Err(reason.clone()));
}
PendingRequest::Execute { started, initial } => {
let _ = started.send(Err(reason.clone()));
let _ = initial.send(Err(reason.clone()));
}
PendingRequest::ExecuteStarted(initial) => {
let _ = initial.send(Err(reason.clone()));
}
}
}
for token in self
.delegate_cancellations
.lock()
.expect("code mode delegate cancellations poisoned")
.drain()
.map(|(_, token)| token)
{
token.cancel();
}
for token in self
.caller_cancellation_watchers
.lock()
.expect("code mode caller cancellation watchers poisoned")
.drain()
.map(|(_, token)| token)
{
token.cancel();
}
self.cancellation.cancel();
}
pub(super) fn handle_host_message(
self: &Arc<Self>,
message: HostToClient,
outgoing: &mpsc::Sender<ClientToHost>,
) -> Result<(), String> {
match message {
HostToClient::Response { id, result } => self.complete_response(id, result),
HostToClient::InitialResponse { id, result } => {
let Some(PendingRequest::ExecuteStarted(initial)) = self.remove_pending(id) else {
return Err(format!("unexpected code mode initial response id {id}"));
};
self.finish_caller_cancellation_watcher(id);
let _ = initial.send(result.into_result());
Ok(())
}
HostToClient::DelegateRequest {
id,
session_id,
request,
} => self.spawn_delegate_request(id, session_id, request, outgoing.clone()),
HostToClient::CancelDelegateRequest { id } => {
if let Some(token) = self
.delegate_cancellations
.lock()
.expect("code mode delegate cancellations poisoned")
.remove(&id)
{
token.cancel();
}
Ok(())
}
HostToClient::CellClosed {
session_id,
cell_id,
} => {
if let Some(delegate) = self
.sessions
.lock()
.expect("code mode session delegates poisoned")
.get(&session_id)
.cloned()
{
delegate.cell_closed(&cell_id);
}
Ok(())
}
HostToClient::HostHello(_) | HostToClient::HandshakeRejected { .. } => {
Err("code mode host sent a handshake message after initialization".to_string())
}
}
}
fn complete_response(&self, id: u64, result: WireResult<HostResponse>) -> Result<(), String> {
let mut pending = self
.pending
.lock()
.expect("code mode pending requests poisoned");
match pending.remove(&id) {
Some(PendingRequest::Standard(sender)) => {
let _ = sender.send(result.into_result());
Ok(())
}
Some(PendingRequest::Execute { started, initial }) => {
match result.into_result() {
Ok(response) => {
pending.insert(id, PendingRequest::ExecuteStarted(initial));
let _ = started.send(Ok(response));
}
Err(message) => {
self.finish_caller_cancellation_watcher(id);
let _ = started.send(Err(message.clone()));
let _ = initial.send(Err(message));
}
}
Ok(())
}
Some(PendingRequest::ExecuteStarted(initial)) => {
pending.insert(id, PendingRequest::ExecuteStarted(initial));
Err(format!("duplicate code mode execute response id {id}"))
}
None => Err(format!("unexpected code mode response id {id}")),
}
}
fn finish_caller_cancellation_watcher(&self, id: u64) {
if let Some(token) = self
.caller_cancellation_watchers
.lock()
.expect("code mode caller cancellation watchers poisoned")
.remove(&id)
{
token.cancel();
}
}
fn spawn_delegate_request(
self: &Arc<Self>,
id: u64,
session_id: String,
request: DelegateRequest,
outgoing: mpsc::Sender<ClientToHost>,
) -> Result<(), String> {
let delegate = self
.sessions
.lock()
.expect("code mode session delegates poisoned")
.get(&session_id)
.cloned()
.ok_or_else(|| format!("delegate request referenced unknown session {session_id}"))?;
let cancellation_token = CancellationToken::new();
{
let mut cancellations = self
.delegate_cancellations
.lock()
.expect("code mode delegate cancellations poisoned");
if cancellations.len() >= MAX_PENDING_DELEGATE_CALLS {
return Err(format!(
"code mode delegate request limit {MAX_PENDING_DELEGATE_CALLS} exceeded"
));
}
if cancellations
.insert(id, cancellation_token.clone())
.is_some()
{
return Err(format!("duplicate code mode delegate request id {id}"));
}
}
let state = Arc::clone(self);
tokio::spawn(async move {
let result = match request {
DelegateRequest::InvokeTool { invocation } => delegate
.invoke_tool(invocation, cancellation_token)
.await
.map(|result| DelegateResponse::ToolResult { result }),
DelegateRequest::Notify {
tool_call_id,
cell_id,
text,
} => delegate
.notify(tool_call_id, cell_id, text, cancellation_token)
.await
.map(|()| DelegateResponse::NotificationDelivered),
};
state
.delegate_cancellations
.lock()
.expect("code mode delegate cancellations poisoned")
.remove(&id);
let _ = outgoing
.send(ClientToHost::DelegateResponse {
id,
result: WireResult::from_result(result),
})
.await;
});
Ok(())
}
}
@@ -0,0 +1,569 @@
use super::protocol::{
ClientHello, ClientToHost, DelegateRequest, DelegateResponse, FramedReader, FramedWriter,
HostHello, HostRequest, HostResponse, HostToClient, WireResult, MAX_IN_FLIGHT_REQUESTS,
MAX_PENDING_DELEGATE_CALLS, PROTOCOL_VERSION, SESSION_LIMITS_CAPABILITY,
};
use crate::code_mode::v8::V8CodeModeSessionProvider;
use crate::code_mode::{
RuntimeCodeModeCellId, RuntimeCodeModeFuture, RuntimeCodeModeNestedToolCall,
RuntimeCodeModeSessionDelegate, RuntimeCodeModeSessionHandle, RuntimeCodeModeSessionProvider,
RuntimeCodeModeWaitOutcome,
};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use tokio::io::{stdin, stdout, AsyncRead, AsyncWrite};
use tokio::sync::{mpsc, oneshot, Semaphore};
use tokio_util::sync::CancellationToken;
const OUTGOING_QUEUE_CAPACITY: usize = 256;
pub async fn run_stdio() -> Result<(), String> {
run_connection(stdin(), stdout()).await
}
async fn run_connection<R, W>(reader: R, writer: W) -> Result<(), String>
where
R: AsyncRead + Send + Unpin + 'static,
W: AsyncWrite + Send + Unpin + 'static,
{
let mut reader = FramedReader::new(reader);
let mut writer = FramedWriter::new(writer);
let hello = reader
.read::<ClientToHost>()
.await
.map_err(|error| format!("failed to read code mode client handshake: {error}"))?
.ok_or_else(|| "code mode client closed before handshake".to_string())?;
let ClientToHost::ClientHello(hello) = hello else {
writer
.write(&HostToClient::HandshakeRejected {
reason: "the first message must be connection/hello".to_string(),
})
.await
.map_err(|error| format!("failed to reject code mode handshake: {error}"))?;
return Err("the first code mode client message was not a handshake".to_string());
};
if let Err(reason) = validate_hello(&hello) {
writer
.write(&HostToClient::HandshakeRejected {
reason: reason.clone(),
})
.await
.map_err(|error| format!("failed to reject code mode handshake: {error}"))?;
return Err(reason);
}
V8CodeModeSessionProvider.availability()?;
writer
.write(&HostToClient::HostHello(HostHello {
selected_version: PROTOCOL_VERSION,
capabilities: vec![SESSION_LIMITS_CAPABILITY.to_string()],
host_pid: std::process::id(),
}))
.await
.map_err(|error| format!("failed to write code mode host handshake: {error}"))?;
let (outgoing, mut outgoing_rx) = mpsc::channel(OUTGOING_QUEUE_CAPACITY);
let state = Arc::new(HostState::new(outgoing.clone()));
let writer_task = tokio::spawn(async move {
while let Some(message) = outgoing_rx.recv().await {
writer
.write(&message)
.await
.map_err(|error| format!("code mode host writer failed: {error}"))?;
}
Ok::<(), String>(())
});
let read_result = loop {
match reader.read::<ClientToHost>().await {
Ok(Some(message)) => {
if let Err(error) = state.handle_message(message) {
break Err(error);
}
}
Ok(None) => break Ok(()),
Err(error) => break Err(format!("code mode host reader failed: {error}")),
}
};
state.shutdown_all().await;
writer_task.abort();
let _ = writer_task.await;
read_result
}
fn validate_hello(hello: &ClientHello) -> Result<(), String> {
if !hello.supported_versions.contains(&PROTOCOL_VERSION) {
return Err(format!(
"client does not support code mode protocol version {PROTOCOL_VERSION}"
));
}
let supported = [SESSION_LIMITS_CAPABILITY];
if let Some(capability) = hello
.required_capabilities
.iter()
.find(|capability| !supported.contains(&capability.as_str()))
{
return Err(format!("unsupported required capability {capability}"));
}
Ok(())
}
struct HostState {
outgoing: mpsc::Sender<HostToClient>,
sessions: Mutex<HashMap<String, RuntimeCodeModeSessionHandle>>,
operations: Mutex<HashMap<u64, CancellationToken>>,
pending_delegates: Mutex<HashMap<u64, oneshot::Sender<Result<DelegateResponse, String>>>>,
cancelled_delegates: Mutex<HashSet<u64>>,
cell_lifecycle: Mutex<CellLifecycle>,
next_delegate_id: AtomicU64,
operation_permits: Arc<Semaphore>,
}
impl HostState {
fn new(outgoing: mpsc::Sender<HostToClient>) -> Self {
Self {
outgoing,
sessions: Mutex::new(HashMap::new()),
operations: Mutex::new(HashMap::new()),
pending_delegates: Mutex::new(HashMap::new()),
cancelled_delegates: Mutex::new(HashSet::new()),
cell_lifecycle: Mutex::new(CellLifecycle::default()),
next_delegate_id: AtomicU64::new(1),
operation_permits: Arc::new(Semaphore::new(MAX_IN_FLIGHT_REQUESTS)),
}
}
fn handle_message(self: &Arc<Self>, message: ClientToHost) -> Result<(), String> {
match message {
ClientToHost::Request { id, request } => self.spawn_request(id, request),
ClientToHost::CancelRequest { id } => {
if let Some(token) = self
.operations
.lock()
.expect("code mode host operations poisoned")
.get(&id)
.cloned()
{
token.cancel();
}
Ok(())
}
ClientToHost::DelegateResponse { id, result } => {
let sender = self
.pending_delegates
.lock()
.expect("code mode pending delegates poisoned")
.remove(&id);
let Some(sender) = sender else {
if self
.cancelled_delegates
.lock()
.expect("code mode cancelled delegates poisoned")
.remove(&id)
{
return Ok(());
}
return Err(format!("unexpected code mode delegate response id {id}"));
};
let _ = sender.send(result.into_result());
Ok(())
}
ClientToHost::ClientHello(_) => {
Err("code mode client sent a second handshake".to_string())
}
}
}
fn spawn_request(self: &Arc<Self>, id: u64, request: HostRequest) -> Result<(), String> {
let permit = Arc::clone(&self.operation_permits)
.try_acquire_owned()
.map_err(|_| {
format!("code mode host request limit {MAX_IN_FLIGHT_REQUESTS} exceeded")
})?;
let cancellation = CancellationToken::new();
if self
.operations
.lock()
.expect("code mode host operations poisoned")
.insert(id, cancellation.clone())
.is_some()
{
return Err(format!("duplicate code mode host request id {id}"));
}
let state = Arc::clone(self);
tokio::spawn(async move {
let _permit = permit;
match request {
HostRequest::Execute {
session_id,
request,
} => state.execute(id, session_id, request, cancellation).await,
request => {
let result = state.request(request, cancellation).await;
let _ = state
.outgoing
.send(HostToClient::Response {
id,
result: WireResult::from_result(result),
})
.await;
}
}
state
.operations
.lock()
.expect("code mode host operations poisoned")
.remove(&id);
});
Ok(())
}
async fn request(
self: &Arc<Self>,
request: HostRequest,
cancellation: CancellationToken,
) -> Result<HostResponse, String> {
match request {
HostRequest::OpenSession {
session_id,
cell_execution_limits,
} => {
if session_id.trim().is_empty() {
return Err("code mode session id must not be empty".to_string());
}
if self
.sessions
.lock()
.expect("code mode host sessions poisoned")
.contains_key(&session_id)
{
return Err(format!("code mode session {session_id} already exists"));
}
let delegate: Arc<dyn RuntimeCodeModeSessionDelegate> =
Arc::new(HostDelegate::new(session_id.clone(), Arc::clone(self)));
let session = V8CodeModeSessionProvider
.create_session_with_limits(delegate, cell_execution_limits.unwrap_or_default())
.await?;
self.sessions
.lock()
.expect("code mode host sessions poisoned")
.insert(session_id.clone(), session);
Ok(HostResponse::SessionReady { session_id })
}
HostRequest::Wait {
session_id,
request,
} => {
let session = self.session(&session_id)?;
let outcome = tokio::select! {
result = session.wait(request) => result?,
() = cancellation.cancelled() => return Err("code mode wait cancelled".to_string()),
};
Ok(HostResponse::WaitCompleted { outcome })
}
HostRequest::Terminate {
session_id,
cell_id,
} => {
let session = self.session(&session_id)?;
let outcome = session.terminate(cell_id).await?;
Ok(HostResponse::WaitCompleted { outcome })
}
HostRequest::ShutdownSession { session_id } => {
let session = self
.sessions
.lock()
.expect("code mode host sessions poisoned")
.remove(&session_id)
.ok_or_else(|| format!("code mode session {session_id} not found"))?;
session.shutdown().await?;
Ok(HostResponse::SessionClosed { session_id })
}
HostRequest::Execute { .. } => unreachable!("execute uses the two-phase response"),
}
}
async fn execute(
self: &Arc<Self>,
id: u64,
session_id: String,
request: super::protocol::WireExecuteRequest,
cancellation: CancellationToken,
) {
let result = async {
let session = self.session(&session_id)?;
let mut request = crate::code_mode::RuntimeCodeModeExecuteRequest::try_from(request)?;
request.cancellation_token = Some(cancellation.clone());
let started = tokio::select! {
result = session.execute(request) => result?,
() = cancellation.cancelled() => return Err("code mode execution cancelled".to_string()),
};
Ok::<_, String>((session, started))
}
.await;
let (session, started) = match result {
Ok(result) => result,
Err(message) => {
let _ = self
.outgoing
.send(HostToClient::Response {
id,
result: WireResult::Err { message },
})
.await;
return;
}
};
let cell_id = started.cell_id.clone();
if self
.outgoing
.send(HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::ExecutionStarted {
cell_id: cell_id.clone(),
},
},
})
.await
.is_err()
{
return;
}
let initial = tokio::select! {
result = started.initial_response() => result,
() = cancellation.cancelled() => {
session.terminate(cell_id.clone()).await.map(RuntimeCodeModeWaitOutcome::into_response)
}
};
if self
.outgoing
.send(HostToClient::InitialResponse {
id,
result: WireResult::from_result(initial),
})
.await
.is_ok()
{
self.announce_cell(session_id, cell_id);
}
}
fn announce_cell(&self, session_id: String, cell_id: RuntimeCodeModeCellId) {
let key = (session_id.clone(), cell_id.clone());
let send_closed = {
let mut lifecycle = self
.cell_lifecycle
.lock()
.expect("code mode host cell lifecycle poisoned");
if lifecycle.pending_closed.remove(&key) {
true
} else {
lifecycle.announced.insert(key);
false
}
};
if send_closed {
self.send_cell_closed(session_id, cell_id);
}
}
fn close_cell(&self, session_id: String, cell_id: RuntimeCodeModeCellId) {
let key = (session_id.clone(), cell_id.clone());
let send_closed = {
let mut lifecycle = self
.cell_lifecycle
.lock()
.expect("code mode host cell lifecycle poisoned");
if lifecycle.announced.remove(&key) {
true
} else {
lifecycle.pending_closed.insert(key);
false
}
};
if send_closed {
self.send_cell_closed(session_id, cell_id);
}
}
fn send_cell_closed(&self, session_id: String, cell_id: RuntimeCodeModeCellId) {
let outgoing = self.outgoing.clone();
tokio::spawn(async move {
let _ = outgoing
.send(HostToClient::CellClosed {
session_id,
cell_id,
})
.await;
});
}
fn session(&self, session_id: &str) -> Result<RuntimeCodeModeSessionHandle, String> {
self.sessions
.lock()
.expect("code mode host sessions poisoned")
.get(session_id)
.cloned()
.ok_or_else(|| format!("code mode session {session_id} not found"))
}
async fn delegate(
&self,
session_id: String,
request: DelegateRequest,
cancellation: CancellationToken,
) -> Result<DelegateResponse, String> {
let id = self.next_delegate_id.fetch_add(1, Ordering::Relaxed);
let (sender, receiver) = oneshot::channel();
{
let mut pending = self
.pending_delegates
.lock()
.expect("code mode pending delegates poisoned");
if pending.len() >= MAX_PENDING_DELEGATE_CALLS {
return Err(format!(
"code mode delegate request limit {MAX_PENDING_DELEGATE_CALLS} exceeded"
));
}
pending.insert(id, sender);
}
if self
.outgoing
.send(HostToClient::DelegateRequest {
id,
session_id,
request,
})
.await
.is_err()
{
self.pending_delegates
.lock()
.expect("code mode pending delegates poisoned")
.remove(&id);
return Err("code mode client writer closed".to_string());
}
tokio::select! {
result = receiver => result.map_err(|_| "code mode delegate response channel closed".to_string())?,
() = cancellation.cancelled() => {
if self.pending_delegates
.lock()
.expect("code mode pending delegates poisoned")
.remove(&id)
.is_some()
{
self.cancelled_delegates
.lock()
.expect("code mode cancelled delegates poisoned")
.insert(id);
}
let _ = self.outgoing.send(HostToClient::CancelDelegateRequest { id }).await;
Err("code mode delegate request cancelled".to_string())
}
}
}
async fn shutdown_all(&self) {
let sessions = std::mem::take(
&mut *self
.sessions
.lock()
.expect("code mode host sessions poisoned"),
);
for session in sessions.into_values() {
let _ = session.shutdown().await;
}
for token in self
.operations
.lock()
.expect("code mode host operations poisoned")
.drain()
.map(|(_, token)| token)
{
token.cancel();
}
*self
.cell_lifecycle
.lock()
.expect("code mode host cell lifecycle poisoned") = CellLifecycle::default();
}
}
#[derive(Default)]
struct CellLifecycle {
announced: HashSet<(String, RuntimeCodeModeCellId)>,
pending_closed: HashSet<(String, RuntimeCodeModeCellId)>,
}
struct HostDelegate {
session_id: String,
state: Arc<HostState>,
}
impl HostDelegate {
fn new(session_id: String, state: Arc<HostState>) -> Self {
Self { session_id, state }
}
}
impl RuntimeCodeModeSessionDelegate for HostDelegate {
fn invoke_tool<'a>(
&'a self,
invocation: RuntimeCodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, Value> {
Box::pin(async move {
match self
.state
.delegate(
self.session_id.clone(),
DelegateRequest::InvokeTool { invocation },
cancellation_token,
)
.await?
{
DelegateResponse::ToolResult { result } => Ok(result),
DelegateResponse::NotificationDelivered => Err(
"code mode client returned a notification response for a tool call".to_string(),
),
}
})
}
fn notify<'a>(
&'a self,
tool_call_id: String,
cell_id: RuntimeCodeModeCellId,
text: String,
cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async move {
match self
.state
.delegate(
self.session_id.clone(),
DelegateRequest::Notify {
tool_call_id,
cell_id,
text,
},
cancellation_token,
)
.await?
{
DelegateResponse::NotificationDelivered => Ok(()),
DelegateResponse::ToolResult { .. } => {
Err("code mode client returned a tool result for a notification".to_string())
}
}
})
}
fn cell_closed(&self, cell_id: &RuntimeCodeModeCellId) {
self.state
.close_cell(self.session_id.clone(), cell_id.clone());
}
}
@@ -0,0 +1,44 @@
mod client;
mod client_state;
mod host;
mod protocol;
use std::path::PathBuf;
pub use client::ProcessCodeModeSessionProvider;
const CODE_MODE_HOST_BINARY_NAME: &str = "code-mode-host";
const CODE_MODE_HOST_BIN_ENV: &str = "CODE_MODE_HOST_BIN";
pub fn default_code_mode_host_path() -> PathBuf {
if let Some(path) = std::env::var_os(CODE_MODE_HOST_BIN_ENV).filter(|path| !path.is_empty()) {
return PathBuf::from(path);
}
let binary_name = if cfg!(windows) {
format!("{CODE_MODE_HOST_BINARY_NAME}.exe")
} else {
CODE_MODE_HOST_BINARY_NAME.to_string()
};
std::env::current_exe()
.ok()
.and_then(|path| {
let parent = path.parent()?;
let sibling = parent.join(&binary_name);
if sibling.is_file() {
return Some(sibling);
}
if parent.file_name().is_some_and(|name| name == "deps") {
return parent.parent().map(|target| target.join(&binary_name));
}
Some(sibling)
})
.unwrap_or_else(|| PathBuf::from(binary_name))
}
pub async fn run_stdio() -> Result<(), String> {
host::run_stdio().await
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,286 @@
use super::super::{
RuntimeCodeModeCellId, RuntimeCodeModeExecuteRequest, RuntimeCodeModeNestedToolCall,
RuntimeCodeModeResponse, RuntimeCodeModeSessionLimits, RuntimeCodeModeTool,
RuntimeCodeModeWaitOutcome, RuntimeCodeModeWaitRequest,
};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;
use std::io;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
pub const PROTOCOL_VERSION: u32 = 1;
pub const MAX_FRAME_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_IN_FLIGHT_REQUESTS: usize = 1_024;
pub const MAX_PENDING_DELEGATE_CALLS: usize = 1_024;
pub const SESSION_LIMITS_CAPABILITY: &str = "session-cell-execution-resource-limits";
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ClientHello {
pub supported_versions: Vec<u32>,
pub required_capabilities: Vec<String>,
pub optional_capabilities: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct HostHello {
pub selected_version: u32,
pub capabilities: Vec<String>,
pub host_pid: u32,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
pub enum ClientToHost {
#[serde(rename = "connection/hello")]
ClientHello(ClientHello),
#[serde(rename = "operation/request")]
Request { id: u64, request: HostRequest },
#[serde(rename = "operation/cancel")]
CancelRequest { id: u64 },
#[serde(rename = "delegate/response")]
DelegateResponse {
id: u64,
result: WireResult<DelegateResponse>,
},
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
pub enum HostToClient {
#[serde(rename = "connection/ready")]
HostHello(HostHello),
#[serde(rename = "connection/rejected")]
HandshakeRejected { reason: String },
#[serde(rename = "operation/response")]
Response {
id: u64,
result: WireResult<HostResponse>,
},
#[serde(rename = "execute/initialResponse")]
InitialResponse {
id: u64,
result: WireResult<RuntimeCodeModeResponse>,
},
#[serde(rename = "delegate/request")]
DelegateRequest {
id: u64,
session_id: String,
request: DelegateRequest,
},
#[serde(rename = "delegate/cancel")]
CancelDelegateRequest { id: u64 },
#[serde(rename = "cell/closed")]
CellClosed {
session_id: String,
cell_id: RuntimeCodeModeCellId,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "method", rename_all_fields = "camelCase")]
pub enum HostRequest {
#[serde(rename = "session/open")]
OpenSession {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
cell_execution_limits: Option<RuntimeCodeModeSessionLimits>,
},
#[serde(rename = "session/execute")]
Execute {
session_id: String,
request: WireExecuteRequest,
},
#[serde(rename = "session/wait")]
Wait {
session_id: String,
request: RuntimeCodeModeWaitRequest,
},
#[serde(rename = "session/terminate")]
Terminate {
session_id: String,
cell_id: RuntimeCodeModeCellId,
},
#[serde(rename = "session/shutdown")]
ShutdownSession { session_id: String },
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
pub enum HostResponse {
#[serde(rename = "session/ready")]
SessionReady { session_id: String },
#[serde(rename = "execution/started")]
ExecutionStarted { cell_id: RuntimeCodeModeCellId },
#[serde(rename = "wait/completed")]
WaitCompleted { outcome: RuntimeCodeModeWaitOutcome },
#[serde(rename = "session/closed")]
SessionClosed { session_id: String },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
pub enum DelegateRequest {
#[serde(rename = "tool/invoke")]
InvokeTool {
invocation: RuntimeCodeModeNestedToolCall,
},
#[serde(rename = "notification/send")]
Notify {
tool_call_id: String,
cell_id: RuntimeCodeModeCellId,
text: String,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
pub enum DelegateResponse {
#[serde(rename = "tool/result")]
ToolResult { result: Value },
#[serde(rename = "notification/delivered")]
NotificationDelivered,
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, tag = "status", rename_all_fields = "camelCase")]
pub enum WireResult<T> {
#[serde(rename = "ok")]
Ok { value: T },
#[serde(rename = "error")]
Err { message: String },
}
impl<T> WireResult<T> {
pub fn from_result(result: Result<T, String>) -> Self {
match result {
Ok(value) => Self::Ok { value },
Err(message) => Self::Err { message },
}
}
pub fn into_result(self) -> Result<T, String> {
match self {
Self::Ok { value } => Ok(value),
Self::Err { message } => Err(message),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct WireExecuteRequest {
pub tool_call_id: String,
pub source: String,
pub enabled_tools: Vec<RuntimeCodeModeTool>,
pub yield_time_ms: Option<u64>,
pub max_output_tokens: Option<u64>,
}
impl TryFrom<RuntimeCodeModeExecuteRequest> for WireExecuteRequest {
type Error = String;
fn try_from(request: RuntimeCodeModeExecuteRequest) -> Result<Self, Self::Error> {
Ok(Self {
tool_call_id: request.tool_call_id,
source: request.source,
enabled_tools: request.enabled_tools,
yield_time_ms: request.yield_time_ms,
max_output_tokens: request
.max_output_tokens
.map(u64::try_from)
.transpose()
.map_err(|_| "code mode output token limit exceeds u64".to_string())?,
})
}
}
impl TryFrom<WireExecuteRequest> for RuntimeCodeModeExecuteRequest {
type Error = String;
fn try_from(request: WireExecuteRequest) -> Result<Self, Self::Error> {
Ok(Self {
tool_call_id: request.tool_call_id,
source: request.source,
enabled_tools: request.enabled_tools,
yield_time_ms: request.yield_time_ms,
max_output_tokens: request
.max_output_tokens
.map(usize::try_from)
.transpose()
.map_err(|_| "code mode output token limit exceeds usize".to_string())?,
cancellation_token: None,
})
}
}
pub struct FramedReader<R> {
reader: R,
}
impl<R: AsyncRead + Unpin> FramedReader<R> {
pub fn new(reader: R) -> Self {
Self { reader }
}
pub async fn read<T: DeserializeOwned>(&mut self) -> io::Result<Option<T>> {
let mut length_bytes = [0_u8; 4];
if self.reader.read(&mut length_bytes[..1]).await? == 0 {
return Ok(None);
}
self.reader.read_exact(&mut length_bytes[1..]).await?;
let length = u32::from_le_bytes(length_bytes) as usize;
if length > MAX_FRAME_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("code-mode IPC frame length {length} exceeds {MAX_FRAME_BYTES} bytes"),
));
}
let mut payload = vec![0_u8; length];
self.reader.read_exact(&mut payload).await?;
serde_json::from_slice(&payload).map(Some).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to decode code-mode IPC frame: {error}"),
)
})
}
}
pub struct FramedWriter<W> {
writer: W,
}
impl<W: AsyncWrite + Unpin> FramedWriter<W> {
pub fn new(writer: W) -> Self {
Self { writer }
}
pub async fn write<T: Serialize>(&mut self, message: &T) -> io::Result<()> {
let payload = serde_json::to_vec(message).map_err(|error| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to encode code-mode IPC frame: {error}"),
)
})?;
if payload.len() > MAX_FRAME_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"code-mode IPC frame length {} exceeds {MAX_FRAME_BYTES} bytes",
payload.len()
),
));
}
let length = u32::try_from(payload.len()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidData,
"code-mode IPC frame exceeds u32",
)
})?;
self.writer.write_all(&length.to_le_bytes()).await?;
self.writer.write_all(&payload).await?;
self.writer.flush().await
}
}
@@ -0,0 +1,365 @@
use super::protocol::{
ClientHello, ClientToHost, FramedReader, FramedWriter, HostHello, HostToClient,
MAX_FRAME_BYTES, PROTOCOL_VERSION, SESSION_LIMITS_CAPABILITY,
};
use super::ProcessCodeModeSessionProvider;
use crate::code_mode::{
NoopRuntimeCodeModeSessionDelegate, RuntimeCodeModeCellId, RuntimeCodeModeExecuteRequest,
RuntimeCodeModeFuture, RuntimeCodeModeNestedToolCall, RuntimeCodeModeResponse,
RuntimeCodeModeSessionDelegate, RuntimeCodeModeSessionHandle, RuntimeCodeModeSessionProvider,
RuntimeCodeModeTool, RuntimeCodeModeWaitRequest,
};
use crate::tool_definition::RuntimeToolDefinition;
use crate::turn_snapshot::RuntimeToolIdentity;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use tempfile::TempDir;
use tokio::io::{duplex, AsyncWriteExt};
use tokio::sync::Notify;
use tokio::time::{timeout, Duration};
use tokio_util::sync::CancellationToken;
#[tokio::test]
async fn length_prefixed_codec_round_trips_handshake() {
let (client, server) = duplex(16 * 1024);
let (client_read, client_write) = tokio::io::split(client);
let (server_read, server_write) = tokio::io::split(server);
let client_task = tokio::spawn(async move {
let mut writer = FramedWriter::new(client_write);
let mut reader = FramedReader::new(client_read);
writer
.write(&ClientToHost::ClientHello(ClientHello {
supported_versions: vec![PROTOCOL_VERSION],
required_capabilities: vec![SESSION_LIMITS_CAPABILITY.to_string()],
optional_capabilities: Vec::new(),
}))
.await
.expect("write client hello");
reader
.read::<HostToClient>()
.await
.expect("read host hello")
.expect("host hello frame")
});
let server_task = tokio::spawn(async move {
let mut reader = FramedReader::new(server_read);
let mut writer = FramedWriter::new(server_write);
let hello = reader
.read::<ClientToHost>()
.await
.expect("read client hello")
.expect("client hello frame");
assert!(matches!(hello, ClientToHost::ClientHello(_)));
writer
.write(&HostToClient::HostHello(HostHello {
selected_version: PROTOCOL_VERSION,
capabilities: vec![SESSION_LIMITS_CAPABILITY.to_string()],
host_pid: 42,
}))
.await
.expect("write host hello");
});
server_task.await.expect("server codec task");
assert_eq!(
client_task.await.expect("client codec task"),
HostToClient::HostHello(HostHello {
selected_version: PROTOCOL_VERSION,
capabilities: vec![SESSION_LIMITS_CAPABILITY.to_string()],
host_pid: 42,
})
);
}
#[tokio::test]
async fn length_prefixed_codec_rejects_oversized_frame_before_allocation() {
let (mut writer, reader) = duplex(16);
writer
.write_all(&u32::try_from(MAX_FRAME_BYTES + 1).unwrap().to_le_bytes())
.await
.expect("write oversized frame header");
let error = FramedReader::new(reader)
.read::<ClientToHost>()
.await
.expect_err("oversized frame must fail");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidData);
assert!(error.to_string().contains("exceeds"));
}
#[test]
fn process_provider_fails_closed_when_host_is_missing() {
let temp = TempDir::new().expect("missing host temp dir");
let host_path = temp.path().join("code-mode-host-missing");
let provider = ProcessCodeModeSessionProvider::with_host_path(host_path.clone());
assert_eq!(
provider.availability().expect_err("missing host must fail"),
format!(
"code mode host executable was not found: {}",
host_path.display()
)
);
}
#[tokio::test]
async fn process_host_executes_in_a_distinct_process_when_test_binary_is_available() {
let provider = process_provider();
let delegate: Arc<dyn RuntimeCodeModeSessionDelegate> =
Arc::new(NoopRuntimeCodeModeSessionDelegate);
let session = provider
.create_session(delegate)
.await
.expect("open process-owned session");
let started = session
.execute(RuntimeCodeModeExecuteRequest {
tool_call_id: "process-cell".to_string(),
source: "text('process-ok')".to_string(),
enabled_tools: Vec::new(),
yield_time_ms: Some(1_000),
max_output_tokens: None,
cancellation_token: None,
})
.await
.expect("start process cell");
let response = started.initial_response().await.expect("process response");
assert!(response.is_terminal());
assert!(response.into_tool_result().output.contains("process-ok"));
let missing = session
.wait(RuntimeCodeModeWaitRequest {
cell_id: crate::code_mode::RuntimeCodeModeCellId::new("missing-cell"),
yield_time_ms: 10,
})
.await
.expect("missing cell response");
assert!(missing.into_response().is_terminal());
session.shutdown().await.expect("shutdown process session");
}
#[derive(Default)]
struct RecordingDelegate {
calls: Mutex<Vec<RuntimeCodeModeNestedToolCall>>,
notifications: Mutex<Vec<String>>,
closed_cells: Mutex<Vec<RuntimeCodeModeCellId>>,
closed: Notify,
}
impl RuntimeCodeModeSessionDelegate for RecordingDelegate {
fn invoke_tool<'a>(
&'a self,
invocation: RuntimeCodeModeNestedToolCall,
_cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, Value> {
Box::pin(async move {
self.calls.lock().expect("process calls").push(invocation);
Ok(json!({ "answer": 42 }))
})
}
fn notify<'a>(
&'a self,
_tool_call_id: String,
_cell_id: RuntimeCodeModeCellId,
text: String,
_cancellation_token: CancellationToken,
) -> RuntimeCodeModeFuture<'a, ()> {
Box::pin(async move {
self.notifications
.lock()
.expect("process notifications")
.push(text);
Ok(())
})
}
fn cell_closed(&self, cell_id: &RuntimeCodeModeCellId) {
self.closed_cells
.lock()
.expect("process closed cells")
.push(cell_id.clone());
self.closed.notify_waiters();
}
}
#[tokio::test]
async fn process_host_routes_nested_tools_notifications_and_cell_close_in_order() {
let provider = process_provider();
let delegate = Arc::new(RecordingDelegate::default());
let session = process_session(&provider, delegate.clone()).await;
let mut request = process_request(
"const result = await tools.lookup({ value: 41 }); notify('working'); text(result.answer);",
);
request.enabled_tools.push(RuntimeCodeModeTool {
identity: RuntimeToolIdentity::plain("lookup"),
definition: RuntimeToolDefinition::new(
"lookup",
"Returns an answer.",
json!({ "type": "object" }),
),
code_name: "lookup".to_string(),
global_name: "lookup".to_string(),
});
let started = session
.execute(request)
.await
.expect("start nested process cell");
let cell_id = started.cell_id.clone();
let response = started
.initial_response()
.await
.expect("nested process response");
assert!(matches!(
response,
RuntimeCodeModeResponse::Result { output, error_text: None, .. } if output == "42"
));
wait_for_cell_closed(&delegate, &cell_id).await;
let calls = delegate.calls.lock().expect("process calls");
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].tool_name, "lookup");
assert_eq!(calls[0].input, Some(json!({ "value": 41 })));
assert_eq!(
delegate
.notifications
.lock()
.expect("process notifications")
.as_slice(),
["working"]
);
session
.shutdown()
.await
.expect("shutdown nested process session");
}
#[tokio::test]
async fn process_host_yields_waits_and_terminates_cells() {
let provider = process_provider();
let session = process_session(&provider, Arc::new(RecordingDelegate::default())).await;
let mut delayed = process_request(
"await new Promise(resolve => setTimeout(resolve, 25)); text('completed');",
);
delayed.yield_time_ms = Some(1);
let yielded = session
.execute(delayed)
.await
.expect("start delayed process cell")
.initial_response()
.await
.expect("yield delayed process cell");
let delayed_cell_id = yielded.cell_id().clone();
assert!(matches!(yielded, RuntimeCodeModeResponse::Yielded { .. }));
let waited = session
.wait(RuntimeCodeModeWaitRequest {
cell_id: delayed_cell_id,
yield_time_ms: 1_000,
})
.await
.expect("wait for delayed process cell")
.into_response();
assert!(matches!(
waited,
RuntimeCodeModeResponse::Result { output, error_text: None, .. } if output == "completed"
));
let mut infinite = process_request("while (true) {}");
infinite.yield_time_ms = Some(1);
let running = session
.execute(infinite)
.await
.expect("start infinite process cell");
let infinite_cell_id = running.cell_id.clone();
assert!(matches!(
running
.initial_response()
.await
.expect("yield infinite process cell"),
RuntimeCodeModeResponse::Yielded { .. }
));
assert!(matches!(
session
.terminate(infinite_cell_id)
.await
.expect("terminate process cell")
.into_response(),
RuntimeCodeModeResponse::Terminated { .. }
));
session
.shutdown()
.await
.expect("shutdown yielded process session");
}
fn process_provider() -> ProcessCodeModeSessionProvider {
ProcessCodeModeSessionProvider::with_host_path(test_host_path().expect(
"code-mode-host must be built before the standalone process test; run `cargo build -p tool-runtime --bin code-mode-host`",
))
}
async fn process_session(
provider: &ProcessCodeModeSessionProvider,
delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionHandle {
provider
.create_session(delegate)
.await
.expect("open process-owned session")
}
fn process_request(source: &str) -> RuntimeCodeModeExecuteRequest {
RuntimeCodeModeExecuteRequest {
tool_call_id: "process-call".to_string(),
source: source.to_string(),
enabled_tools: Vec::new(),
yield_time_ms: Some(1_000),
max_output_tokens: None,
cancellation_token: None,
}
}
async fn wait_for_cell_closed(delegate: &RecordingDelegate, cell_id: &RuntimeCodeModeCellId) {
timeout(Duration::from_secs(5), async {
loop {
let notified = delegate.closed.notified();
if delegate
.closed_cells
.lock()
.expect("process closed cells")
.contains(cell_id)
{
return;
}
notified.await;
}
})
.await
.expect("process host did not deliver cell_closed");
}
fn test_host_path() -> Option<PathBuf> {
std::env::var_os("CARGO_BIN_EXE_code-mode-host")
.map(PathBuf::from)
.filter(|path| path.is_file())
.or_else(|| {
let binary_name = if cfg!(windows) {
"code-mode-host.exe"
} else {
"code-mode-host"
};
std::env::current_exe()
.ok()?
.parent()?
.parent()?
.join(binary_name)
.is_file()
.then(|| {
std::env::current_exe()
.expect("current test binary")
.parent()
.expect("test deps directory")
.parent()
.expect("cargo target profile directory")
.join(binary_name)
})
})
}
@@ -0,0 +1,435 @@
use super::*;
use crate::tool_definition::RuntimeToolExposure;
use serde_json::json;
use std::sync::Mutex;
fn tool(name: &str, exposure: RuntimeToolExposure) -> RuntimeToolSnapshot {
RuntimeToolSnapshot::new(
RuntimeToolIdentity::plain(name),
RuntimeToolDefinition::new(name, format!("{name} description"), json!({})),
exposure,
false,
false,
)
}
fn tool_names(tools: &[RuntimeToolSnapshot]) -> Vec<&str> {
tools
.iter()
.map(|tool| tool.definition.name.as_str())
.collect()
}
fn nested_tool_names(tools: &[RuntimeCodeModeTool]) -> Vec<&str> {
tools.iter().map(|tool| tool.code_name.as_str()).collect()
}
#[test]
fn exec_source_parser_accepts_supported_pragma_fields() {
let parsed = parse_code_mode_exec_source(
"// @exec: {\"yield_time_ms\":250,\"max_output_tokens\":512}\ntext(42);",
)
.expect("supported pragma");
assert_eq!(parsed.code, "text(42);");
assert_eq!(parsed.yield_time_ms, Some(250));
assert_eq!(parsed.max_output_tokens, Some(512));
}
#[test]
fn exec_source_parser_fails_closed_for_invalid_pragmas() {
for source in [
"// @exec: {\"future_field\":true}\ntext(42);",
"// @exec: {\"yield_time_ms\":9007199254740992}\ntext(42);",
"// @exec: {\"max_output_tokens\":9007199254740992}\ntext(42);",
"// @exec: {\"yield_time_ms\":1}\n",
" ",
] {
assert!(
parse_code_mode_exec_source(source).is_err(),
"invalid pragma source must fail: {source:?}"
);
}
}
struct RecordingSession {
operations: Mutex<Vec<String>>,
}
impl RecordingSession {
fn new() -> Self {
Self {
operations: Mutex::new(Vec::new()),
}
}
fn operations(&self) -> Vec<String> {
self.operations.lock().expect("recorded operations").clone()
}
}
impl RuntimeCodeModeSession for RecordingSession {
fn execute<'a>(
&'a self,
request: RuntimeCodeModeExecuteRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeStartedCell> {
Box::pin(async move {
self.operations
.lock()
.expect("record execute")
.push(format!(
"execute:{}:{}",
request.tool_call_id, request.source
));
Ok(RuntimeCodeModeStartedCell::new(
RuntimeCodeModeCellId::new("cell-execute"),
Box::pin(async {
Ok(RuntimeCodeModeResponse::Result {
cell_id: RuntimeCodeModeCellId::new("cell-execute"),
output: "done".to_string(),
error_text: None,
})
}),
))
})
}
fn wait<'a>(
&'a self,
request: RuntimeCodeModeWaitRequest,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
self.operations.lock().expect("record wait").push(format!(
"wait:{}:{}",
request.cell_id, request.yield_time_ms
));
Ok(RuntimeCodeModeWaitOutcome::LiveCell(
RuntimeCodeModeResponse::Yielded {
cell_id: request.cell_id,
output: "pending".to_string(),
},
))
})
}
fn terminate<'a>(
&'a self,
cell_id: RuntimeCodeModeCellId,
) -> RuntimeCodeModeFuture<'a, RuntimeCodeModeWaitOutcome> {
Box::pin(async move {
self.operations
.lock()
.expect("record terminate")
.push(format!("terminate:{cell_id}"));
Ok(RuntimeCodeModeWaitOutcome::LiveCell(
RuntimeCodeModeResponse::Terminated {
cell_id,
output: String::new(),
},
))
})
}
fn shutdown(&self) -> RuntimeCodeModeFuture<'_, ()> {
Box::pin(async move {
self.operations
.lock()
.expect("record shutdown")
.push("shutdown".to_string());
Ok(())
})
}
}
struct RecordingProvider {
session: RuntimeCodeModeSessionHandle,
creates: Mutex<usize>,
}
impl RuntimeCodeModeSessionProvider for RecordingProvider {
fn create_session<'a>(
&'a self,
_delegate: Arc<dyn RuntimeCodeModeSessionDelegate>,
) -> RuntimeCodeModeSessionProviderFuture<'a> {
Box::pin(async move {
*self.creates.lock().expect("record create") += 1;
Ok(self.session.clone())
})
}
}
#[test]
fn public_exec_and_wait_contract_matches_codex_names_grammar_and_defaults() {
assert_eq!(CODE_MODE_EXEC_TOOL_NAME, "exec");
assert_eq!(CODE_MODE_WAIT_TOOL_NAME, "wait");
assert!(CODE_MODE_EXEC_FREEFORM_GRAMMAR.contains("start: pragma_source | plain_source"));
assert!(CODE_MODE_EXEC_FREEFORM_GRAMMAR.contains("PRAGMA_LINE:"));
assert_eq!(DEFAULT_CODE_MODE_EXEC_YIELD_TIME_MS, 10_000);
assert_eq!(DEFAULT_CODE_MODE_WAIT_YIELD_TIME_MS, 10_000);
assert_eq!(DEFAULT_CODE_MODE_MAX_OUTPUT_TOKENS, 10_000);
let wait = code_mode_wait_tool_definition();
assert_eq!(wait.name, CODE_MODE_WAIT_TOOL_NAME);
assert_eq!(wait.input_schema["required"], json!(["cell_id"]));
assert_eq!(
wait.input_schema["properties"]["terminate"]["type"],
"boolean"
);
}
#[test]
fn runtime_responses_preserve_terminal_state_and_model_visible_status() {
let yielded = RuntimeCodeModeResponse::Yielded {
cell_id: RuntimeCodeModeCellId::new("7"),
output: "partial".to_string(),
};
assert!(!yielded.is_terminal());
assert_eq!(yielded.cell_id().as_str(), "7");
assert_eq!(
yielded.into_tool_result(),
RuntimeCodeModeToolResult {
cell_id: RuntimeCodeModeCellId::new("7"),
success: true,
output: "Script running with cell ID 7\nOutput:\npartial".to_string(),
error: None,
}
);
let terminated = RuntimeCodeModeResponse::Terminated {
cell_id: RuntimeCodeModeCellId::new("8"),
output: "last output".to_string(),
};
assert!(terminated.is_terminal());
assert_eq!(
terminated.into_tool_result(),
RuntimeCodeModeToolResult {
cell_id: RuntimeCodeModeCellId::new("8"),
success: true,
output: "Script terminated\nOutput:\nlast output".to_string(),
error: None,
}
);
let failed = RuntimeCodeModeResponse::Result {
cell_id: RuntimeCodeModeCellId::new("9"),
output: "before failure".to_string(),
error_text: Some("boom".to_string()),
};
assert!(failed.is_terminal());
assert_eq!(
failed.into_tool_result(),
RuntimeCodeModeToolResult {
cell_id: RuntimeCodeModeCellId::new("9"),
success: false,
output: "Script failed\nOutput:\nbefore failure\nScript error:\nboom".to_string(),
error: Some("boom".to_string()),
}
);
}
#[tokio::test]
async fn session_handle_delegates_execute_wait_terminate_and_shutdown() {
let session = Arc::new(RecordingSession::new());
let handle = RuntimeCodeModeSessionHandle::new(session.clone());
let started = handle
.execute(RuntimeCodeModeExecuteRequest {
tool_call_id: "call-1".to_string(),
source: "return 1".to_string(),
enabled_tools: Vec::new(),
yield_time_ms: None,
max_output_tokens: None,
cancellation_token: None,
})
.await
.expect("execute starts a cell");
assert_eq!(started.cell_id.as_str(), "cell-execute");
assert!(started
.initial_response()
.await
.expect("initial response")
.is_terminal());
let waited = handle
.wait(RuntimeCodeModeWaitRequest {
cell_id: RuntimeCodeModeCellId::new("cell-execute"),
yield_time_ms: 42,
})
.await
.expect("wait delegates");
assert!(matches!(
waited,
RuntimeCodeModeWaitOutcome::LiveCell(RuntimeCodeModeResponse::Yielded { .. })
));
let terminated = handle
.terminate(RuntimeCodeModeCellId::new("cell-execute"))
.await
.expect("terminate delegates");
assert!(matches!(
terminated,
RuntimeCodeModeWaitOutcome::LiveCell(RuntimeCodeModeResponse::Terminated { .. })
));
handle.shutdown().await.expect("shutdown delegates");
assert_eq!(
session.operations(),
vec![
"execute:call-1:return 1",
"wait:cell-execute:42",
"terminate:cell-execute",
"shutdown",
]
);
}
#[tokio::test]
async fn session_provider_accepts_default_limits_and_rejects_unsupported_limits() {
let session = RuntimeCodeModeSessionHandle::new(Arc::new(RecordingSession::new()));
let provider = RecordingProvider {
session,
creates: Mutex::new(0),
};
let delegate: Arc<dyn RuntimeCodeModeSessionDelegate> =
Arc::new(NoopRuntimeCodeModeSessionDelegate);
provider
.create_session_with_limits(delegate.clone(), RuntimeCodeModeSessionLimits::default())
.await
.expect("default limits delegate to create_session");
assert_eq!(*provider.creates.lock().expect("create count"), 1);
let error = provider
.create_session_with_limits(
delegate,
RuntimeCodeModeSessionLimits {
max_yield_time_ms: Some(1_000),
max_heap_size_bytes: None,
},
)
.await
.expect_err("non-default limits require provider support");
assert_eq!(
error,
"code mode session provider does not support resource limits"
);
assert_eq!(*provider.creates.lock().expect("create count"), 1);
}
#[test]
fn exposure_plan_matches_direct_code_mode_and_code_mode_only_surfaces() {
let tools = vec![
tool("direct", RuntimeToolExposure::Direct),
tool("deferred", RuntimeToolExposure::Deferred),
tool(
"deferred_model_only",
RuntimeToolExposure::DeferredModelOnly,
),
tool("direct_model_only", RuntimeToolExposure::DirectModelOnly),
tool("nested_only", RuntimeToolExposure::CodeModeOnly),
tool("hidden", RuntimeToolExposure::Hidden),
];
let direct = plan_runtime_code_mode_tools(&tools, RuntimeToolMode::Direct, false, false)
.expect("direct mode does not require code mode");
assert_eq!(
tool_names(&direct.model_visible_tools),
vec!["direct", "direct_model_only"]
);
assert_eq!(
tool_names(&direct.searchable_tools),
vec!["deferred", "deferred_model_only"]
);
assert!(direct.nested_tools.is_empty());
let code_mode = plan_runtime_code_mode_tools(&tools, RuntimeToolMode::CodeMode, true, false)
.expect("available code mode");
assert_eq!(
tool_names(&code_mode.model_visible_tools),
vec!["direct", "direct_model_only"]
);
assert_eq!(
nested_tool_names(&code_mode.nested_tools),
vec!["direct", "deferred", "nested_only"]
);
let code_mode_only =
plan_runtime_code_mode_tools(&tools, RuntimeToolMode::CodeModeOnly, true, false)
.expect("available code-mode-only surface");
assert_eq!(
tool_names(&code_mode_only.model_visible_tools),
vec!["direct_model_only"]
);
assert_eq!(
nested_tool_names(&code_mode_only.nested_tools),
vec!["direct", "deferred", "nested_only"]
);
}
#[test]
fn unavailable_code_mode_falls_back_only_when_explicitly_allowed() {
let fallback = resolve_runtime_tool_mode(RuntimeToolMode::CodeMode, false, false)
.expect("regular code mode may use direct fallback");
assert_eq!(fallback.effective, RuntimeToolMode::Direct);
assert!(fallback.used_direct_fallback);
assert_eq!(
resolve_runtime_tool_mode(RuntimeToolMode::CodeMode, false, true),
Err(RuntimeToolModeResolutionError::CodeModeUnavailable {
requested: RuntimeToolMode::CodeMode,
})
);
assert_eq!(
resolve_runtime_tool_mode(RuntimeToolMode::CodeModeOnly, false, false),
Err(RuntimeToolModeResolutionError::CodeModeUnavailable {
requested: RuntimeToolMode::CodeModeOnly,
})
);
}
#[test]
fn nested_names_follow_codex_namespace_and_collision_rules() {
assert_eq!(
code_mode_name_for_identity(&RuntimeToolIdentity::namespaced("mcp", "lookup")),
"mcp__lookup"
);
assert_eq!(
code_mode_name_for_identity(&RuntimeToolIdentity::namespaced("mcp__", "lookup")),
"mcp__lookup"
);
assert_eq!(
normalize_code_mode_identifier("hidden-dynamic-tool"),
"hidden_dynamic_tool"
);
assert_eq!(normalize_code_mode_identifier("9 invalid"), "__invalid");
assert_eq!(normalize_code_mode_identifier(""), "_");
let tools = vec![
tool("hidden-dynamic-tool", RuntimeToolExposure::CodeModeOnly),
tool("hidden dynamic tool", RuntimeToolExposure::CodeModeOnly),
];
let plan = plan_runtime_code_mode_tools(&tools, RuntimeToolMode::CodeModeOnly, true, false)
.expect("available code mode");
assert_eq!(
nested_tool_names(&plan.nested_tools),
vec!["hidden-dynamic-tool"]
);
assert_eq!(
nested_tool_names(&plan.shadowed_nested_tools),
vec!["hidden dynamic tool"]
);
assert_eq!(
plan_runtime_code_mode_tools(
&[tool(
CODE_MODE_EXEC_TOOL_NAME,
RuntimeToolExposure::DirectModelOnly,
)],
RuntimeToolMode::CodeMode,
true,
false,
),
Err(RuntimeToolModeResolutionError::ReservedToolNameCollision {
tool_name: CODE_MODE_EXEC_TOOL_NAME.to_string(),
})
);
}
@@ -0,0 +1,124 @@
use std::panic::AssertUnwindSafe;
use std::sync::Arc;
use futures::FutureExt;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use tracing::warn;
use super::CellHost;
use super::CellToolCall;
use crate::code_mode::v8::runtime::RuntimeCommand;
use crate::code_mode::v8::TaskFailureHandler;
#[derive(Clone, Copy)]
pub(super) enum CallbackCompletion {
DrainNotifications,
Cancel,
}
pub(super) fn spawn_notification<H: CellHost>(
tasks: &mut JoinSet<()>,
host: Arc<H>,
call_id: String,
text: String,
cancellation_token: CancellationToken,
task_failure_handler: Option<TaskFailureHandler>,
) {
tasks.spawn(async move {
let callback =
AssertUnwindSafe(async move { host.notify(call_id, text, cancellation_token).await })
.catch_unwind()
.await;
match callback {
Ok(Ok(())) => {}
Ok(Err(err)) => warn!("failed to deliver code mode notification: {err}"),
Err(_) => report_task_failure(
task_failure_handler.as_ref(),
"code mode notification task panicked".to_string(),
),
}
});
}
pub(super) fn spawn_tool<H: CellHost>(
tasks: &mut JoinSet<()>,
host: Arc<H>,
invocation: CellToolCall,
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
cancellation_token: CancellationToken,
task_failure_handler: Option<TaskFailureHandler>,
) {
tasks.spawn(async move {
let id = invocation.id.clone();
let callback =
AssertUnwindSafe(async move { host.invoke_tool(invocation, cancellation_token).await })
.catch_unwind()
.await;
let (command, failure_reason) = match callback {
Ok(Ok(result)) => (RuntimeCommand::ToolResponse { id, result }, None),
Ok(Err(error_text)) => (RuntimeCommand::ToolError { id, error_text }, None),
Err(_) => {
let failure_reason = "code mode tool task panicked".to_string();
(
RuntimeCommand::ToolError {
id,
error_text: failure_reason.clone(),
},
Some(failure_reason),
)
}
};
let _ = runtime_tx.send(command);
if let Some(failure_reason) = failure_reason {
report_task_failure(task_failure_handler.as_ref(), failure_reason);
}
});
}
pub(super) async fn finish_callbacks(
cancellation_token: &CancellationToken,
notification_tasks: &mut JoinSet<()>,
tool_tasks: &mut JoinSet<()>,
completion: CallbackCompletion,
task_failure_handler: Option<&TaskFailureHandler>,
) {
if matches!(completion, CallbackCompletion::Cancel) {
cancellation_token.cancel();
}
drain_tasks(notification_tasks, "notification", task_failure_handler).await;
cancellation_token.cancel();
drain_tasks(tool_tasks, "tool", task_failure_handler).await;
}
pub(super) fn report_task_result(
task_result: Option<Result<(), tokio::task::JoinError>>,
description: &str,
task_failure_handler: Option<&TaskFailureHandler>,
) {
if let Some(Err(err)) = task_result {
if !err.is_cancelled() {
report_task_failure(
task_failure_handler,
format!("code mode {description} task failed: {err}"),
);
}
}
}
fn report_task_failure(task_failure_handler: Option<&TaskFailureHandler>, failure_reason: String) {
warn!("{failure_reason}");
if let Some(task_failure_handler) = task_failure_handler {
task_failure_handler(failure_reason);
}
}
async fn drain_tasks(
tasks: &mut JoinSet<()>,
description: &str,
task_failure_handler: Option<&TaskFailureHandler>,
) {
while let Some(result) = tasks.join_next().await {
report_task_result(Some(result), description, task_failure_handler);
}
}
@@ -0,0 +1,45 @@
use crate::code_mode::v8::protocol::ExecuteRequest;
use crate::code_mode::v8::protocol::FunctionCallOutputContentItem;
use crate::code_mode::v8::protocol::ImageDetail;
use crate::code_mode::v8::protocol::ToolDefinition;
use crate::code_mode::v8::protocol::ToolName;
use crate::code_mode::v8::session_runtime::CreateCellRequest as CellRequest;
use crate::code_mode::v8::session_runtime::ImageDetail as CellImageDetail;
use crate::code_mode::v8::session_runtime::OutputItem as CellOutputItem;
pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest {
ExecuteRequest {
tool_call_id: request.tool_call_id,
enabled_tools: request
.enabled_tools
.into_iter()
.map(|definition| ToolDefinition {
name: definition.name,
tool_name: ToolName {
name: definition.tool_name.name,
namespace: definition.tool_name.namespace,
},
description: definition.description,
})
.collect(),
source: request.source,
}
}
pub(super) fn output_item(item: FunctionCallOutputContentItem) -> CellOutputItem {
match item {
FunctionCallOutputContentItem::InputText { text } => CellOutputItem::Text { text },
FunctionCallOutputContentItem::InputImage { image_url, detail } => CellOutputItem::Image {
image_url,
detail: detail.map(|detail| match detail {
ImageDetail::Auto => CellImageDetail::Auto,
ImageDetail::Low => CellImageDetail::Low,
ImageDetail::High => CellImageDetail::High,
ImageDetail::Original => CellImageDetail::Original,
}),
},
FunctionCallOutputContentItem::InputAudio { audio_url } => {
CellOutputItem::Audio { audio_url }
}
}
}
@@ -0,0 +1,604 @@
mod callbacks;
mod conversions;
mod types;
use std::collections::HashMap;
use std::future::Future;
use std::sync::Arc;
use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::task::JoinSet;
use tokio_util::sync::CancellationToken;
use self::callbacks::finish_callbacks;
use self::callbacks::report_task_result;
use self::callbacks::spawn_notification;
use self::callbacks::spawn_tool;
use self::callbacks::CallbackCompletion;
use self::conversions::output_item;
use self::conversions::runtime_request;
use self::types::CellCommand;
pub(crate) use self::types::CellError;
pub(crate) use self::types::CellEventFuture;
pub(crate) use self::types::CellHandle;
pub(crate) use self::types::CellHost;
pub(crate) use self::types::CellState;
pub(crate) use self::types::CellToolCall;
pub(crate) use self::types::CompletionCommit;
use self::types::CompletionDelivery;
use self::types::ObservationDelivery;
use crate::code_mode::v8::runtime::spawn_runtime;
use crate::code_mode::v8::runtime::PendingRuntimeMode;
use crate::code_mode::v8::runtime::RuntimeCommand;
use crate::code_mode::v8::runtime::RuntimeControlCommand;
use crate::code_mode::v8::runtime::RuntimeEvent;
use crate::code_mode::v8::session_runtime::CellEvent;
use crate::code_mode::v8::session_runtime::CreateCellRequest as CellRequest;
use crate::code_mode::v8::session_runtime::ObserveMode;
use crate::code_mode::v8::session_runtime::OutputItem;
use crate::code_mode::v8::session_runtime::ToolName as CellToolName;
use crate::code_mode::v8::TaskFailureHandler;
pub(crate) struct CellActor;
impl CellActor {
pub(crate) fn prepare<H: CellHost>(
request: CellRequest,
stored_values: HashMap<String, JsonValue>,
host: Arc<H>,
initial_observe_mode: ObserveMode,
cell_state: Arc<CellState>,
task_failure_handler: Option<TaskFailureHandler>,
) -> Result<
(
CellHandle,
CellEventFuture,
impl Future<Output = ()> + Send + 'static,
),
String,
> {
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (command_tx, command_rx) = mpsc::unbounded_channel();
let (initial_response_tx, initial_response_rx) = oneshot::channel();
let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime(
stored_values,
runtime_request(request),
event_tx,
PendingRuntimeMode::PauseUntilResumed,
task_failure_handler.clone(),
)?;
let handle = CellHandle::new(command_tx, Arc::clone(&cell_state));
let task = run_cell(
host,
CellContext {
runtime_tx,
runtime_control_tx,
runtime_terminate_handle,
cell_state,
},
event_rx,
command_rx,
Observer {
mode: initial_observe_mode,
response_tx: initial_response_tx,
},
task_failure_handler,
);
let initial_response =
Box::pin(async move { initial_response_rx.await.unwrap_or(Err(CellError::Closed)) });
Ok((handle, initial_response, task))
}
}
struct CellContext {
runtime_tx: std::sync::mpsc::Sender<RuntimeCommand>,
runtime_control_tx: std::sync::mpsc::Sender<RuntimeControlCommand>,
runtime_terminate_handle: v8::IsolateHandle,
cell_state: Arc<CellState>,
}
struct Observer {
mode: ObserveMode,
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
}
async fn run_cell<H: CellHost>(
host: Arc<H>,
context: CellContext,
mut event_rx: mpsc::UnboundedReceiver<RuntimeEvent>,
command_rx: mpsc::UnboundedReceiver<CellCommand>,
initial_observer: Observer,
task_failure_handler: Option<TaskFailureHandler>,
) {
let CellContext {
runtime_tx,
runtime_control_tx,
runtime_terminate_handle,
cell_state,
} = context;
let cancellation_token = cell_state.cancellation_token();
let callback_cancellation_token = cancellation_token.child_token();
let mut content_items = Vec::new();
let mut pending_tool_call_ids = Vec::new();
let mut pending_frontier_ready = false;
let mut observer = Some(initial_observer);
let mut termination = false;
let mut runtime_closed = false;
let mut runtime_paused = false;
let mut runtime_failure_reported = false;
let mut yield_timer: Option<std::pin::Pin<Box<tokio::time::Sleep>>> = None;
let mut notification_tasks = JoinSet::new();
let mut tool_tasks = JoinSet::new();
let mut command_rx = Some(command_rx);
loop {
let yield_deadline_elapsed = yield_timer
.as_ref()
.is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now());
tokio::select! {
biased;
_ = cancellation_token.cancelled(), if !termination => {
termination = true;
yield_timer = None;
drop(command_rx.take());
begin_termination(
&runtime_tx,
&runtime_control_tx,
&runtime_terminate_handle,
&cancellation_token,
);
if runtime_closed {
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::Cancel,
task_failure_handler.as_ref(),
).await;
finish_termination(
&cell_state,
observer.take().map(|observer| observer.response_tx),
CellEvent::Terminated {
content_items: std::mem::take(&mut content_items),
},
);
break;
}
}
maybe_command = async {
match command_rx.as_mut() {
Some(command_rx) => command_rx.recv().await,
None => std::future::pending::<Option<CellCommand>>().await,
}
} => {
let Some(CellCommand::Observe { mode, response_tx }) = maybe_command else {
cancellation_token.cancel();
continue;
};
if response_tx.is_closed() {
continue;
}
let response_tx = match cell_state.route_observation(mode, response_tx) {
ObservationDelivery::Running(response_tx) => response_tx,
ObservationDelivery::Delivered => break,
ObservationDelivery::Buffered | ObservationDelivery::Closed => continue,
};
if observer
.as_ref()
.is_some_and(|observer| observer.response_tx.is_closed())
{
observer = None;
yield_timer = None;
}
if observer.is_some() || termination {
let _ = response_tx.send(Err(CellError::Busy));
continue;
}
if matches!(mode, ObserveMode::PendingFrontier) && pending_frontier_ready {
pending_frontier_ready = false;
match send_cell_event(
response_tx,
CellEvent::Pending {
content_items: std::mem::take(&mut content_items),
pending_tool_call_ids: std::mem::take(&mut pending_tool_call_ids),
},
) {
Ok(()) => {}
Err(CellEvent::Pending {
content_items: undelivered_items,
pending_tool_call_ids: undelivered_tool_call_ids,
}) => {
content_items = undelivered_items;
pending_tool_call_ids = undelivered_tool_call_ids;
pending_frontier_ready = true;
}
Err(event) => {
panic!("pending delivery returned an unexpected event: {event:?}")
}
}
continue;
}
observer = Some(Observer { mode, response_tx });
yield_timer = observer.as_ref().and_then(observer_timer);
if runtime_paused && matches!(mode, ObserveMode::YieldAfter(_)) {
pending_frontier_ready = false;
pending_tool_call_ids.clear();
}
resume_for_observation(
mode,
&mut runtime_paused,
&runtime_tx,
&runtime_control_tx,
);
}
_ = async {
if let Some(yield_timer) = yield_timer.as_mut() {
yield_timer.await;
} else {
std::future::pending::<()>().await;
}
} => {
yield_timer = None;
restore_undelivered_yield(
send_observer_event(
observer.take(),
CellEvent::Yielded {
content_items: std::mem::take(&mut content_items),
},
),
&mut content_items,
);
}
maybe_event = async {
if runtime_closed {
std::future::pending::<Option<RuntimeEvent>>().await
} else {
event_rx.recv().await
}
}, if !yield_deadline_elapsed => {
let Some(event) = maybe_event else {
runtime_closed = true;
if termination || cancellation_token.is_cancelled() {
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::Cancel,
task_failure_handler.as_ref(),
).await;
finish_termination(
&cell_state,
observer.take().map(|observer| observer.response_tx),
CellEvent::Terminated {
content_items: std::mem::take(&mut content_items),
},
);
break;
}
if !runtime_failure_reported {
if let Some(task_failure_handler) = &task_failure_handler {
runtime_failure_reported = true;
task_failure_handler(
"code-mode V8 runtime thread ended unexpectedly".to_string(),
);
}
}
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::DrainNotifications,
task_failure_handler.as_ref(),
)
.await;
let event = CellEvent::Completed {
content_items: std::mem::take(&mut content_items),
error_text: Some("exec runtime ended unexpectedly".to_string()),
};
let rejected_event = match host
.commit_completion(
HashMap::new(),
event,
/*pending_initial_yield_items*/ None,
Arc::clone(&cell_state),
)
.await
{
CompletionCommit::Committed => None,
CompletionCommit::Rejected(event) => Some(event),
};
match cell_state.deliver_completion(
observer.take().map(|observer| observer.response_tx),
) {
CompletionDelivery::Delivered => break,
CompletionDelivery::Buffered => {}
CompletionDelivery::Rejected(response_tx) => {
finish_termination(
&cell_state,
response_tx,
CellEvent::Terminated {
content_items: rejected_completion_content(rejected_event),
},
);
break;
}
}
continue;
};
match event {
RuntimeEvent::Started => {
yield_timer = observer.as_ref().and_then(observer_timer);
}
RuntimeEvent::Pending => {
runtime_paused = true;
if matches!(
observer.as_ref().map(|observer| observer.mode),
Some(ObserveMode::PendingFrontier)
) {
yield_timer = None;
pending_frontier_ready = false;
match send_observer_event(
observer.take(),
CellEvent::Pending {
content_items: std::mem::take(&mut content_items),
pending_tool_call_ids: std::mem::take(
&mut pending_tool_call_ids,
),
},
) {
Ok(()) => {}
Err(CellEvent::Pending {
content_items: undelivered_items,
pending_tool_call_ids: undelivered_tool_call_ids,
}) => {
content_items = undelivered_items;
pending_tool_call_ids = undelivered_tool_call_ids;
pending_frontier_ready = true;
}
Err(event) => {
panic!("pending delivery returned an unexpected event: {event:?}")
}
}
} else {
pending_tool_call_ids.clear();
let _ = runtime_control_tx.send(RuntimeControlCommand::Continue);
runtime_paused = false;
}
}
RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)),
RuntimeEvent::YieldRequested => {
let yield_observer = matches!(
observer.as_ref().map(|observer| observer.mode),
Some(ObserveMode::YieldAfter(_))
);
if yield_observer {
yield_timer = None;
restore_undelivered_yield(
send_observer_event(
observer.take(),
CellEvent::Yielded {
content_items: std::mem::take(&mut content_items),
},
),
&mut content_items,
);
}
}
RuntimeEvent::Notify { call_id, text } => {
spawn_notification(
&mut notification_tasks,
Arc::clone(&host),
call_id,
text,
callback_cancellation_token.child_token(),
task_failure_handler.clone(),
);
}
RuntimeEvent::ToolCall {
id,
name,
input,
} => {
pending_tool_call_ids.push(id.clone());
spawn_tool(
&mut tool_tasks,
Arc::clone(&host),
CellToolCall {
id,
name: CellToolName {
name: name.name,
namespace: name.namespace,
},
input,
},
runtime_tx.clone(),
callback_cancellation_token.child_token(),
task_failure_handler.clone(),
);
}
RuntimeEvent::Result { stored_value_writes, error_text } => {
runtime_closed = true;
yield_timer = None;
if termination || cancellation_token.is_cancelled() {
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::Cancel,
task_failure_handler.as_ref(),
).await;
finish_termination(
&cell_state,
observer.take().map(|observer| observer.response_tx),
CellEvent::Terminated {
content_items: std::mem::take(&mut content_items),
},
);
break;
}
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::DrainNotifications,
task_failure_handler.as_ref(),
)
.await;
let event = CellEvent::Completed {
content_items: std::mem::take(&mut content_items),
error_text,
};
let rejected_event = match host
.commit_completion(
stored_value_writes,
event,
/*pending_initial_yield_items*/ None,
Arc::clone(&cell_state),
)
.await
{
CompletionCommit::Committed => None,
CompletionCommit::Rejected(event) => Some(event),
};
match cell_state.deliver_completion(
observer.take().map(|observer| observer.response_tx),
) {
CompletionDelivery::Delivered => break,
CompletionDelivery::Buffered => {}
CompletionDelivery::Rejected(response_tx) => {
finish_termination(
&cell_state,
response_tx,
CellEvent::Terminated {
content_items: rejected_completion_content(rejected_event),
},
);
break;
}
}
}
RuntimeEvent::ThreadPanicked => {
runtime_failure_reported = true;
}
}
}
task_result = notification_tasks.join_next(), if !notification_tasks.is_empty() => {
report_task_result(
task_result,
"notification",
task_failure_handler.as_ref(),
);
}
task_result = tool_tasks.join_next(), if !tool_tasks.is_empty() => {
report_task_result(task_result, "tool", task_failure_handler.as_ref());
}
}
}
// Reject requests that arrive while asynchronous terminal cleanup runs.
cell_state.tombstone();
drop(command_rx.take());
begin_termination(
&runtime_tx,
&runtime_control_tx,
&runtime_terminate_handle,
&cancellation_token,
);
finish_callbacks(
&callback_cancellation_token,
&mut notification_tasks,
&mut tool_tasks,
CallbackCompletion::Cancel,
task_failure_handler.as_ref(),
)
.await;
host.closed().await;
}
fn send_observer_event(observer: Option<Observer>, event: CellEvent) -> Result<(), CellEvent> {
let Some(observer) = observer else {
return Err(event);
};
send_cell_event(observer.response_tx, event)
}
fn send_cell_event(
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
event: CellEvent,
) -> Result<(), CellEvent> {
match response_tx.send(Ok(event)) {
Ok(()) => Ok(()),
Err(Ok(event)) => Err(event),
Err(Err(error)) => panic!("cell event delivery returned an actor error: {error:?}"),
}
}
fn restore_undelivered_yield(delivery: Result<(), CellEvent>, content_items: &mut Vec<OutputItem>) {
match delivery {
Ok(()) => {}
Err(CellEvent::Yielded {
content_items: mut undelivered_items,
}) => {
undelivered_items.append(content_items);
*content_items = undelivered_items;
}
Err(event) => panic!("yield delivery returned an unexpected event: {event:?}"),
}
}
fn rejected_completion_content(event: Option<CellEvent>) -> Vec<OutputItem> {
match event {
Some(CellEvent::Completed { content_items, .. }) => content_items,
None => Vec::new(),
Some(event) => panic!("completion commit rejected an unexpected event: {event:?}"),
}
}
fn finish_termination(
cell_state: &CellState,
observer_tx: Option<oneshot::Sender<Result<CellEvent, CellError>>>,
event: CellEvent,
) {
if let Some(event) = cell_state.finish_termination(event) {
if let Some(observer_tx) = observer_tx {
let _ = observer_tx.send(Ok(event));
}
}
}
fn observer_timer(observer: &Observer) -> Option<std::pin::Pin<Box<tokio::time::Sleep>>> {
match observer.mode {
ObserveMode::YieldAfter(duration) => Some(Box::pin(tokio::time::sleep(duration))),
ObserveMode::PendingFrontier => None,
}
}
fn resume_for_observation(
mode: ObserveMode,
runtime_paused: &mut bool,
runtime_tx: &std::sync::mpsc::Sender<RuntimeCommand>,
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
) {
if *runtime_paused {
let control = match mode {
ObserveMode::YieldAfter(_) => RuntimeControlCommand::Continue,
ObserveMode::PendingFrontier => RuntimeControlCommand::Resume,
};
let _ = runtime_control_tx.send(control);
*runtime_paused = false;
} else if matches!(mode, ObserveMode::PendingFrontier) {
let _ = runtime_tx.send(RuntimeCommand::ObservePendingFrontier);
}
}
fn begin_termination(
runtime_tx: &std::sync::mpsc::Sender<RuntimeCommand>,
runtime_control_tx: &std::sync::mpsc::Sender<RuntimeControlCommand>,
runtime_terminate_handle: &v8::IsolateHandle,
cancellation_token: &CancellationToken,
) {
cancellation_token.cancel();
let _ = runtime_tx.send(RuntimeCommand::Terminate);
let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate);
let _ = runtime_terminate_handle.terminate_execution();
}
@@ -0,0 +1,442 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use serde_json::Value as JsonValue;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken;
use crate::code_mode::v8::session_runtime::CellEvent;
use crate::code_mode::v8::session_runtime::ObserveMode;
use crate::code_mode::v8::session_runtime::OutputItem;
use crate::code_mode::v8::session_runtime::ToolName;
pub(crate) type CellEventFuture =
Pin<Box<dyn Future<Output = Result<CellEvent, CellError>> + Send + 'static>>;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum CellError {
Busy,
AlreadyTerminating,
Closed,
}
pub(crate) struct CellToolCall {
pub(crate) id: String,
pub(crate) name: ToolName,
pub(crate) input: Option<JsonValue>,
}
/// Connects a cell actor to session-owned callbacks and stored values.
///
/// Implementations should forward callback cancellation to downstream work.
/// Implementations must not return from `closed` until the session can no longer
/// route requests to the cell.
pub(crate) trait CellHost: Send + Sync + 'static {
fn invoke_tool(
&self,
invocation: CellToolCall,
cancellation_token: CancellationToken,
) -> impl Future<Output = Result<JsonValue, String>> + Send;
fn notify(
&self,
call_id: String,
text: String,
cancellation_token: CancellationToken,
) -> impl Future<Output = Result<(), String>> + Send;
fn commit_completion(
&self,
stored_value_writes: HashMap<String, JsonValue>,
event: CellEvent,
pending_initial_yield_items: Option<Vec<OutputItem>>,
cell_state: Arc<CellState>,
) -> impl Future<Output = CompletionCommit> + Send;
fn closed(&self) -> impl Future<Output = ()> + Send;
}
#[derive(Clone)]
pub(crate) struct CellHandle {
command_tx: mpsc::UnboundedSender<CellCommand>,
state: Arc<CellState>,
}
impl CellHandle {
pub(super) fn new(
command_tx: mpsc::UnboundedSender<CellCommand>,
state: Arc<CellState>,
) -> Self {
Self { command_tx, state }
}
pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture {
if !self.state.accepting_observations() {
return closed_event();
}
let (response_tx, response_rx) = oneshot::channel();
if self
.command_tx
.send(CellCommand::Observe { mode, response_tx })
.is_err()
{
return closed_event();
}
response_event(response_rx)
}
pub(crate) fn terminate(&self) -> CellEventFuture {
self.state.request_termination()
}
}
/// The single linearization point for a cell's terminal outcome.
///
/// The cancellation token is a child of the owning session token. Callback
/// tokens are children of this token, so cancellation flows strictly from the
/// session to the cell and then to its callbacks.
///
/// The mutex is held only for synchronous phase transitions and terminal
/// delivery. Runtime execution, observation waits, and callbacks never run
/// while it is held.
pub(crate) struct CellState {
phase: Mutex<CellPhase>,
cancellation_token: CancellationToken,
}
enum CellPhase {
Running,
Terminating {
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
},
Completed {
// Set only when `yield_control()` races the create-to-first-observe handoff.
pending_initial_yield_items: Option<Vec<OutputItem>>,
event: CellEvent,
},
CompletionClaimed(CellEvent),
Tombstone,
}
pub(crate) enum CompletionDelivery {
Delivered,
Buffered,
Rejected(Option<oneshot::Sender<Result<CellEvent, CellError>>>),
}
/// Result of atomically publishing a completed cell and its session side effects.
#[derive(Debug, PartialEq)]
pub(crate) enum CompletionCommit {
Committed,
Rejected(CellEvent),
}
pub(crate) enum ObservationDelivery {
Running(oneshot::Sender<Result<CellEvent, CellError>>),
Delivered,
Buffered,
Closed,
}
impl CellState {
pub(crate) fn new(cancellation_token: CancellationToken) -> Self {
Self {
phase: Mutex::new(CellPhase::Running),
cancellation_token,
}
}
pub(crate) fn accepting_observations(&self) -> bool {
let accepting_phase = matches!(
*self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
CellPhase::Running | CellPhase::Completed { .. }
);
accepting_phase && !self.cancellation_token.is_cancelled()
}
pub(crate) fn request_termination(&self) -> CellEventFuture {
let mut phase = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
CellPhase::Running => {
let (response_tx, response_rx) = oneshot::channel();
*phase = CellPhase::Terminating { response_tx };
self.cancellation_token.cancel();
response_event(response_rx)
}
CellPhase::Terminating { response_tx } => {
*phase = CellPhase::Terminating { response_tx };
Box::pin(async { Err(CellError::AlreadyTerminating) })
}
CellPhase::Completed {
pending_initial_yield_items,
event,
} => {
let event = prepend_initial_yield(event, pending_initial_yield_items);
*phase = CellPhase::CompletionClaimed(event.clone());
self.cancellation_token.cancel();
ready_event(event)
}
CellPhase::CompletionClaimed(event) => {
*phase = CellPhase::CompletionClaimed(event);
Box::pin(async { Err(CellError::AlreadyTerminating) })
}
CellPhase::Tombstone => closed_event(),
}
}
pub(crate) fn commit_completion(
&self,
event: CellEvent,
pending_initial_yield_items: Option<Vec<OutputItem>>,
commit: impl FnOnce(),
) -> CompletionCommit {
let mut phase = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() {
return CompletionCommit::Rejected(event);
}
commit();
*phase = CellPhase::Completed {
pending_initial_yield_items,
event,
};
CompletionCommit::Committed
}
pub(crate) fn deliver_completion(
&self,
response_tx: Option<oneshot::Sender<Result<CellEvent, CellError>>>,
) -> CompletionDelivery {
let mut phase = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let (pending_initial_yield_items, event) =
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
CellPhase::Completed {
pending_initial_yield_items,
event,
} => (pending_initial_yield_items, event),
previous => {
*phase = previous;
return CompletionDelivery::Rejected(response_tx);
}
};
let Some(response_tx) = response_tx else {
*phase = CellPhase::Completed {
pending_initial_yield_items,
event,
};
return CompletionDelivery::Buffered;
};
match response_tx.send(Ok(event)) {
Ok(()) => {
self.cancellation_token.cancel();
CompletionDelivery::Delivered
}
Err(Ok(event)) => {
*phase = CellPhase::Completed {
pending_initial_yield_items,
event,
};
CompletionDelivery::Buffered
}
Err(Err(error)) => {
panic!("completion delivery unexpectedly carried an actor error: {error:?}")
}
}
}
pub(crate) fn route_observation(
&self,
mode: ObserveMode,
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
) -> ObservationDelivery {
let mut phase = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
CellPhase::Running => {
*phase = CellPhase::Running;
ObservationDelivery::Running(response_tx)
}
CellPhase::Completed {
pending_initial_yield_items: Some(content_items),
event,
} if matches!(mode, ObserveMode::YieldAfter(_)) => {
match response_tx.send(Ok(CellEvent::Yielded { content_items })) {
Ok(()) => {
*phase = CellPhase::Completed {
pending_initial_yield_items: None,
event,
};
ObservationDelivery::Buffered
}
Err(Ok(CellEvent::Yielded { content_items })) => {
*phase = CellPhase::Completed {
pending_initial_yield_items: Some(content_items),
event,
};
ObservationDelivery::Buffered
}
Err(Ok(event)) => {
panic!("initial yield delivery returned an unexpected event: {event:?}")
}
Err(Err(error)) => {
panic!("initial yield delivery returned an actor error: {error:?}")
}
}
}
CellPhase::Completed {
pending_initial_yield_items,
event,
} => {
let delivered_event =
prepend_initial_yield(event.clone(), pending_initial_yield_items.clone());
match response_tx.send(Ok(delivered_event)) {
Ok(()) => {
self.cancellation_token.cancel();
ObservationDelivery::Delivered
}
Err(Ok(_)) => {
*phase = CellPhase::Completed {
pending_initial_yield_items,
event,
};
ObservationDelivery::Buffered
}
Err(Err(error)) => {
panic!("completion delivery unexpectedly carried an actor error: {error:?}")
}
}
}
CellPhase::Terminating {
response_tx: termination_tx,
} => {
*phase = CellPhase::Terminating {
response_tx: termination_tx,
};
let _ = response_tx.send(Err(CellError::Closed));
ObservationDelivery::Closed
}
CellPhase::CompletionClaimed(event) => {
*phase = CellPhase::CompletionClaimed(event);
let _ = response_tx.send(Err(CellError::Closed));
ObservationDelivery::Closed
}
CellPhase::Tombstone => {
let _ = response_tx.send(Err(CellError::Closed));
ObservationDelivery::Closed
}
}
}
pub(crate) fn finish_termination(&self, event: CellEvent) -> Option<CellEvent> {
let mut phase = self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let observer_event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) {
CellPhase::Running => Some(event),
CellPhase::Terminating { response_tx } => {
let _ = response_tx.send(Ok(event.clone()));
Some(event)
}
CellPhase::Completed {
pending_initial_yield_items,
event,
} => Some(prepend_initial_yield(event, pending_initial_yield_items)),
CellPhase::CompletionClaimed(completed_event) => Some(completed_event),
CellPhase::Tombstone => None,
};
self.cancellation_token.cancel();
observer_event
}
pub(crate) fn tombstone(&self) {
*self
.phase
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = CellPhase::Tombstone;
self.cancellation_token.cancel();
}
pub(crate) fn cancellation_token(&self) -> CancellationToken {
self.cancellation_token.clone()
}
}
fn prepend_initial_yield(
event: CellEvent,
pending_initial_yield_items: Option<Vec<OutputItem>>,
) -> CellEvent {
let Some(mut pending_initial_yield_items) = pending_initial_yield_items else {
return event;
};
match event {
CellEvent::Yielded { mut content_items } => {
pending_initial_yield_items.append(&mut content_items);
CellEvent::Yielded {
content_items: pending_initial_yield_items,
}
}
CellEvent::Pending {
mut content_items,
pending_tool_call_ids,
} => {
pending_initial_yield_items.append(&mut content_items);
CellEvent::Pending {
content_items: pending_initial_yield_items,
pending_tool_call_ids,
}
}
CellEvent::Completed {
mut content_items,
error_text,
} => {
pending_initial_yield_items.append(&mut content_items);
CellEvent::Completed {
content_items: pending_initial_yield_items,
error_text,
}
}
CellEvent::Terminated { mut content_items } => {
pending_initial_yield_items.append(&mut content_items);
CellEvent::Terminated {
content_items: pending_initial_yield_items,
}
}
}
}
pub(super) enum CellCommand {
Observe {
mode: ObserveMode,
response_tx: oneshot::Sender<Result<CellEvent, CellError>>,
},
}
fn response_event(response_rx: oneshot::Receiver<Result<CellEvent, CellError>>) -> CellEventFuture {
Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) })
}
fn ready_event(event: CellEvent) -> CellEventFuture {
Box::pin(async move { Ok(event) })
}
fn closed_event() -> CellEventFuture {
Box::pin(async { Err(CellError::Closed) })
}

Some files were not shown because too many files have changed in this diff Show More