From a7100e048de5bfc985a9bb222f567846921b65da Mon Sep 17 00:00:00 2001 From: limityan Date: Thu, 28 May 2026 18:01:14 +0800 Subject: [PATCH] refactor(runtime-ports): extract dialog and subagent contracts --- docs/architecture/core-decomposition.md | 26 +- docs/plans/core-decomposition-plan.md | 52 ++- scripts/check-core-boundaries.mjs | 417 +++++++++++++++++- .../src/agentic/coordination/coordinator.rs | 36 +- .../src/agentic/coordination/scheduler.rs | 60 +-- .../src/agentic/execution/round_executor.rs | 2 +- .../core/src/agentic/execution/types.rs | 2 +- .../core/src/agentic/subagent_runtime/mod.rs | 45 +- .../tools/implementations/task_tool.rs | 34 +- .../agentic/tools/pipeline/state_manager.rs | 2 +- .../agentic/tools/pipeline/tool_pipeline.rs | 2 +- .../core/src/agentic/tools/pipeline/types.rs | 2 +- .../src/agentic/tools/tool_context_runtime.rs | 6 +- .../src/service/project_context/service.rs | 2 +- src/crates/runtime-ports/src/lib.rs | 181 ++++++++ 15 files changed, 713 insertions(+), 156 deletions(-) diff --git a/docs/architecture/core-decomposition.md b/docs/architecture/core-decomposition.md index 648350991..7e31a836f 100644 --- a/docs/architecture/core-decomposition.md +++ b/docs/architecture/core-decomposition.md @@ -64,7 +64,7 @@ Rust 编译和链接面。 | `bitfun-events` | 已有的传输层无关事件 DTO 和事件抽象 | done:既有基础 crate | | `bitfun-ai-adapters` | 已有 AI provider adapter,以及 provider / protocol DTO 归属 | done:既有 adapter crate | | `bitfun-agent-stream` | Stream 聚合和 stream-focused 测试 | done:stream 聚合已独立 | -| `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event 与 remote image attachment 契约;不拥有 runtime 实现 | +| `bitfun-runtime-ports` | 面向 service/agent 边界的轻量跨层 DTO 和 trait | partial:DTO/trait-only 边界已建立,包含 agent submission/transcript/cancel、remote state、runtime event、remote image attachment、dialog submission policy/outcome、subagent context mode 与 delegation policy 契约;不拥有 runtime 实现 | | `bitfun-agent-runtime` | Sessions、execution、coordination、agent system | target:crate 尚不存在,agent runtime 仍在 core | | `bitfun-agent-tools` | 轻量 tool DTO / contract、portable tool context facts / provider、runtime restriction、host path normalization / runtime artifact URI / remote POSIX path pure contract、provider-neutral tool path resolution / absolute-path check / runtime artifact reference assembly、file guidance marker、file-read freshness comparison、oversized tool-result preview/rendering policy、tool execution result/error/invalid-call presentation policy、allowed-list / collapsed-tool execution gate policy、provider-neutral path policy root matching / denial message、pure manifest/exposure and GetToolSpec presentation/schema/static metadata/detail/result assembly / execution-plan contract、provider-backed tool catalog / GetToolSpec runtime facade、provider-backed GetToolSpec execution result helper / Tool-result vector adapter、generic contextual manifest resolver、generic catalog snapshot provider / GetToolSpec catalog provider、generic registry / static-provider / dynamic-provider / decorator-ref / snapshot-decorator adapter / runtime assembly container、generic readonly/enabled snapshot filter | partial:product registry snapshot access、`ToolUseContext` adapter、session file-read state storage、tool-result filesystem writes、`GetToolSpec` Tool impl 和 concrete tools 仍在 core,并由 core `tools/product_runtime.rs` 作为单一 product runtime owner 组装;core 当前从 `bitfun-tool-packs` provider plan 物化内置工具列表,static-provider 安装 assembly、decorator reference、generic snapshot decorator adapter、provider-backed catalog runtime facade、readonly/enabled 过滤规则、provider-neutral tool path resolution / runtime artifact reference assembly、file guidance/freshness policy、oversized result rendering、tool execution presentation 与 path policy 判定已委托给 `bitfun-agent-tools` | | `bitfun-tool-packs` | 由 feature group 隔离的工具 provider plan | partial:提供 basic / git / mcp / browser-web / computer-use / image-analysis / miniapp / agent-control feature-group 元数据和 product provider group plan;不得声明 concrete tools 已迁移 | @@ -195,6 +195,30 @@ owner 边界,否则不要把一个 feature group 继续拆成更小的 crate assistant-only 大结果;workspace `related_paths` 会进入 workspace service、remote/local validation 与 request context prompt;request-context policy、prompt compression 与 cache-stable assembly 也属于 agent runtime 行为。迁移这些 owner 前必须先同步文档、补保护测试并保留旧路径。 +- 2026-05-28 latest main 进一步把 Task/subagent runtime 变成 fork-aware 语义: + `Task.fork_context=true` 会复用父会话 agent/workspace/tools/prompt cache,但禁止 + `subagent_type`、`workspace_path`、`model_id` 与 DeepReview retry 字段,并且不再是并发安全 + Task 调用。迁移 agent scheduler、subagent runtime、session branch 或 prompt-cache owner 前, + 必须保留 delegation policy、forked context seeding、prompt cache clone、已有上下文 dialog turn + 持久化和递归 subagent 禁止语义。`DialogTriggerSource`、`DialogQueuePriority`、 + `DialogSubmissionPolicy` 与 `DialogSubmitOutcome` 已作为 runtime-port 契约,`DelegationPolicy` 与 `SubagentContextMode` 也已作为 + DTO/decision primitive 迁入 `bitfun-runtime-ports`,core 保留旧路径 re-export;当前 + boundary check 已锁定 fork-aware Task 启动回执、child delegation policy 和 + `` 结构化标记,防止后续 owner 迁移误删 + assistant-visible delivery contract。 +- 2026-05-28 latest main 还强化了工具可靠性边界:Write 内容生成会拒绝/清理 + tool-invocation syntax,AskUserQuestion / TodoWrite 支持受限 truncation recovery, + `ToolRuntimeRestrictions` 支持 per-tool denial message,`tool_result_storage` 在返回前显式 + flush 持久化文件。迁移 tool pipeline、concrete Write/Edit/Read/Task、runtime artifact 或 + tool-result persistence 时,必须把这些行为作为等价 baseline,而不能只看类型是否可移动。 +- MCP local stdio runtime 的 initialize 现在有局部 timeout、`notifications/initialized` + 发送和 pending waiter drain;该 timeout 不应被误推广成所有 MCP tool/resource request 的 + 默认 request timeout。后续 MCP owner 迁移或服务集成收敛必须保留 initialize/request timeout + 作用域、channel close cleanup 和 remote/local transport 差异。 +- CLI package workflow / Homebrew notifier 与 mobile-web session search、rename、delete 已进入主干。 + 它们扩大了产品矩阵验证范围,但不改变 contract crate 归属:CLI TUI / packaging 仍在 + `src/apps/cli` 和 CI workflow,mobile session 操作仍是 mobile product surface + 既有 session + API 的组合,不应下沉到 `core-types`、`runtime-ports`、`agent-tools` 或 service owner crate。 - 最新 Web 启动优化把 startup trace、deferred background scheduler、narrow tool initializer 与历史会话 hydrate 放在 web app / Flow Chat surface。后续不能为了“共享启动能力”把 `startupTrace`、`backgroundTaskScheduler`、history hydration 或 tool warmup 下沉到 core contract diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index 2f8bb2417..f71fe6f50 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -245,6 +245,41 @@ git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts streaming 都提高了 agent runtime / AI adapter 边界门槛;HR-C 与 AI/stream 相关工作不得把 provider-specific reasoning/tool-call schema 写入 provider-neutral manifest。 +**2026-05-28 latest-main resync:** + +- `TaskTool` 已支持 `fork_context=true`。该模式复用父会话 agent/workspace/tools/prompt cache, + 但禁止 `subagent_type`、`workspace_path`、`model_id` 和 DeepReview retry 字段,并且 + forked Task 不是并发安全调用。HR-C 迁移 scheduler/coordinator/subagent runtime/session branch + 前,必须保护 delegation policy、forked context seeding、prompt cache clone、 + `start_dialog_turn_with_existing_context` 和递归 subagent 禁止语义。 +- `DialogTriggerSource` 已复用 `AgentSubmissionSource` 的 `bitfun-runtime-ports` + 契约;`DialogQueuePriority`、`DialogSubmissionPolicy` 与 `DialogSubmitOutcome` + 也已迁入 `bitfun-runtime-ports`。 + `DelegationPolicy` 与 `SubagentContextMode` 已迁入 `bitfun-runtime-ports`,core + `agentic::subagent_runtime` 只保留旧路径 re-export 和 core-owned `queue_timing`。这一步 + 只移动 portable DTO/decision primitive,不移动 scheduler、coordinator、session branch、 + prompt cache 或 background delivery runtime。 +- 工具可靠性最新变化必须纳入 HR-A 或任何 tool pipeline 迁移 baseline:Write 内容生成会拒绝 + tool-invocation syntax;AskUserQuestion / TodoWrite 只在安全边界恢复 truncation; + `ToolRuntimeRestrictions` 支持 per-tool denial message;`tool_result_storage` 写 runtime + artifact 后显式 flush,不能在 owner 迁移中退化为仅依赖 drop。 +- MCP local stdio initialize timeout 已限定在 initialize 阶段,并补充 + `notifications/initialized` 与 pending waiter drain。后续 MCP/service-integrations 迁移不得把 + initialize timeout 扩散为普通 tool/resource request timeout,也不得丢失 channel close cleanup。 +- CLI package workflow / Homebrew notifier 和 mobile-web session search / rename / delete + 已进入产品矩阵。H5 或产品形态验证需要覆盖 `bitfun-cli`、CLI packaging 影响面和 + `pnpm run build:mobile-web`;但 CLI TUI/packaging 与 mobile session UI 仍是 app surface, + 不作为 core/service owner 外移前置条件。 +- `scripts/check-core-boundaries.mjs` 已同步 latest-main 的 fork-aware Task 保护锚点: + `fork_context`、`SubagentContextMode::Fork`、child `DelegationPolicy` 传递、 + `background_task_id` 与 `` 启动回执均在 core + 侧锁定,并禁止 core 重新定义已迁入 `runtime-ports` 的 dialog/subagent portable contract; + 后续迁移 agent runtime 前必须先保留或替换这些等价保护。 +- 同一 guardrail PR 也已把 prompt cache clone、existing-context dialog turn、tool-call + truncation recovery、per-tool denial message、tool-result file flush、MCP + initialize-scoped timeout、`notifications/initialized` 和 pending waiter drain 纳入 + boundary check。后续 HR-A / HR-C / MCP 迁移不得绕过这些 latest-main 行为基线。 + --- ## 1. 当前问题与风险合集 @@ -1825,6 +1860,15 @@ contract、product-domain facade 与 H4 boundary closure 已分别闭环;后 `bitfun-core default = []`、per-product feature set、构建矩阵和 release 能力调整仍作为 H5 的独立评估; 不得与 HR-A/HR-B/HR-C 的 runtime owner 迁移混合。 +2026-05-28 最新主干复核后,后续队列不新增零散 PR,但每个高风险主题的保护范围需要扩大: + +- HR-A 若继续深迁 tool runtime,必须同时覆盖 Write sanitizer、AskUserQuestion/TodoWrite + truncation recovery、per-tool denial message 和 tool-result flush 行为。 +- HR-C 若继续迁移 service/agent runtime,必须把 `fork_context`、prompt cache clone、 + existing-context dialog turn、MCP initialize timeout scope 与 pending waiter drain 纳入等价测试。 +- H5 若进入 feature/build-benefit 评估,必须把 CLI package workflow 和 mobile-web session + search/rename/delete 作为产品矩阵影响面,而不是只检查 desktop/server/core。 + **低风险准备 PR 合并锁定(2026-05-19):** 后续不再把低风险准备工作拆成 4 个小 PR。当前 product-domain owner-helper PR @@ -2143,7 +2187,7 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 - 本次 rebase 到最新 `gcwing/main` 后,PR #719 remote workspace guard、#721 companion preset、#715/#722 ACP fallback/timeout、PR #766 ACP config reuse、PR #774 usage/cache 与 Responses schema 修复、PR #776 desktop close-to-tray 默认值、per-mode subagent availability、DeepResearch citation renumber hook 和 search fallback/context 修复均已进入主干;它们不改变当前文档护栏 PR 的代码行为,但会把后续 workspace/search、agent registry/runtime、ACP/Web surface、AI usage/adapter 与 tool runtime 外移的等价性门槛抬高。 - 质量边界:本阶段证明已拆 owner crate 不依赖回 `bitfun-core`,并新增关键语义 baseline 约束 MCP config failure / catalog replacement invalidation / dynamic manifest、tool manifest / `GetToolSpec` collapsed exposure、MiniApp storage layout adapter 等价和 remote search scan-fallback retry gate;不声明 remote connect、`ToolUseContext`、concrete tool implementation、MiniApp IO / worker runtime 或 function-agent runtime 的外移完成。 - boundary check 已扩展到 `core-types`、`runtime-ports` 和 `agent-tools` 的轻量边界,并覆盖 Cargo inline 依赖和 dependency table 依赖声明,后续不能绕过脚本把重 runtime、concrete service、platform adapter 或 CLI/TUI presentation 依赖带入这些 contract crate。 -- boundary check 现在锁定已纳入脚本的 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。2026-05-19 新增识别的 remote ACP config reuse、AI usage/cache semantics、Responses flat tool schema adapter boundary 与 desktop close-to-tray surface 先作为后续迁移的复核清单;真正迁移这些 owner 时必须补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 +- boundary check 现在锁定已纳入脚本的 latest-main owner anchor:mode-scoped subagent availability、`Multitask` / `GeneralPurpose` registration、fork-aware Task start acknowledgement / delegation policy、background subagent delivery、CLI subagent management surface、DeepResearch citation renumber hook、remote workspace startup guard、local/remote search fallback、ACP startup timeout、Web startup/history hydration、Web operation diff fallback 和 built-in MiniApp seed/update path。2026-05-19 新增识别的 remote ACP config reuse、AI usage/cache semantics、Responses flat tool schema adapter boundary 与 desktop close-to-tray surface 先作为后续迁移的复核清单;真正迁移这些 owner 时必须补 port/provider 或 surface contract 设计,并同步更新脚本与等价测试。 - boundary check 也已锁定 `bitfun-core::service::git`、`bitfun-core::service::remote_ssh::types`、remote-SSH workspace path/identity/unresolved-key helper、MiniApp storage layout、`bitfun-core::service::mcp::{tool_info,tool_name}`、`bitfun-core::service::mcp::protocol::{types,jsonrpc}`、`bitfun-core::service::mcp::config::{location,cursor_format,json_config,service_helpers}`、`bitfun-core::service::mcp::server::config`、`bitfun-core::service::mcp::auth` 和 `bitfun-core::service::announcement::types` 的旧路径 facade-only / 禁止回流状态,并禁止在 `MCPServerProcess` runtime 文件重新定义已外移的 server type/status contract、auth error classifier 和 legacy remote header fallback helper,也禁止在 remote transport 重新实现 Authorization 归一化、client capability 构造和 rmcp result mapping;本轮新增禁止 core registry 重新拥有 `IndexMap` 工具容器或 dynamic metadata map。 - 后续迁移必须拆成可独立审核的提交:先补 port/provider 设计和等价测试;`remote-connect` 完整 runtime、`ToolUseContext` / concrete tool implementation、product-domain runtime 必须一次迁移一个 owner 主题。 - concrete tool implementation 或 product registry / manifest assembly 外移必须先有工具清单和 manifest 等价测试,并保留 dynamic provider metadata;不能把注册名解析、snapshot wrapper 或 runtime restriction 行为改成隐式约定。 @@ -2159,8 +2203,10 @@ HR3:service / agent runtime deep owner migration 的主要风险和控制点 ```powershell node scripts/check-core-boundaries.mjs cargo check -p bitfun-core --features product-full +cargo check -p bitfun-cli cargo test --workspace cargo build -p bitfun-desktop +pnpm run build:mobile-web pnpm run desktop:build:fast pnpm run desktop:build:release-fast git diff -- package.json scripts/dev.cjs scripts/desktop-tauri-build.mjs scripts/ensure-openssl-windows.mjs scripts/ci/setup-openssl-windows.ps1 BitFun-Installer @@ -2372,6 +2418,10 @@ subagent visibility、background delivery、DeepResearch citation renumber hook - 可迁移只读 facts、queue/restore decision、remote workspace DTO、workspace/session response assembly helper、port/provider contract 和 core adapter binding。 +- 已迁移的低风险 contract:dialog submission source / priority / policy / outcome、 + subagent context mode 与 delegation policy 归属 `bitfun-runtime-ports`;core 旧路径只作为兼容 re-export。queue wait timer 因依赖 + `Instant` / `Duration` 且服务 DeepReview admission timing,仍保留 core-owned,后续若外移需 + 单独证明不是把 runtime state 放入 DTO/trait crate。 - concrete scheduler/session restore、workspace-root source、persistence/workspace service reads、 `ImageContextData` concrete impl、remote-SSH runtime、terminal adapter、agent registry/scheduler、 goal-mode coordinator binding、request-context assembly 与 prompt compression runtime 继续 diff --git a/scripts/check-core-boundaries.mjs b/scripts/check-core-boundaries.mjs index 86c1164d6..b58bf828e 100644 --- a/scripts/check-core-boundaries.mjs +++ b/scripts/check-core-boundaries.mjs @@ -694,6 +694,51 @@ const forbiddenContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', + patterns: [ + { + regex: /\bstruct\s+DelegationPolicy\b/, + message: + 'core subagent runtime must not redefine DelegationPolicy; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+SubagentContextMode\b/, + message: + 'core subagent runtime must not redefine SubagentContextMode; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/coordinator.rs', + patterns: [ + { + regex: /\benum\s+DialogTriggerSource\b/, + message: + 'core coordinator must not redefine DialogTriggerSource; use bitfun-runtime-ports', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + patterns: [ + { + regex: /\benum\s+DialogQueuePriority\b/, + message: + 'core scheduler must not redefine DialogQueuePriority; use bitfun-runtime-ports', + }, + { + regex: /\bstruct\s+DialogSubmissionPolicy\b/, + message: + 'core scheduler must not redefine DialogSubmissionPolicy; use bitfun-runtime-ports', + }, + { + regex: /\benum\s+DialogSubmitOutcome\b/, + message: + 'core scheduler must not redefine DialogSubmitOutcome; use bitfun-runtime-ports', + }, + ], + }, { path: 'src/crates/core/src/agentic/tools/file_read_state_runtime.rs', patterns: [ @@ -1681,6 +1726,19 @@ const forbiddenContentRules = [ ]; const forbiddenContentUnderRules = [ + { + path: 'src/crates/core/src', + reason: + 'core must use runtime-ports as the owner path for portable subagent contracts', + patterns: [ + { + regex: + /crate::agentic::subagent_runtime(?:::|\s*::|::\{)(?:[^;\n]*\b(?:DelegationPolicy|SubagentContextMode)\b)/, + message: + 'DelegationPolicy and SubagentContextMode must be imported from bitfun-runtime-ports, not the core compatibility re-export', + }, + ], + }, { path: 'src/crates/product-domains/src', reason: @@ -1843,6 +1901,128 @@ const requiredContentRules = [ }, ], }, + { + path: 'src/crates/core/src/agentic/session/session_manager.rs', + reason: + 'core session manager must keep forked Task prompt-cache and existing-context turn baselines until session branch ownership migrates', + patterns: [ + { + regex: /\bpub async fn clone_prompt_cache\b/, + message: 'missing prompt cache clone runtime entry point', + }, + { + regex: /\bpub async fn start_dialog_turn_with_existing_context\b/, + message: 'missing existing-context dialog turn entry point', + }, + { + regex: /\bstart_dialog_turn_with_existing_context_persists_turn_and_snapshot\b/, + message: 'missing existing-context dialog turn persistence regression', + }, + { + regex: /\bclone_prompt_cache_copies_runtime_and_persisted_entries\b/, + message: 'missing prompt cache clone runtime/disk regression', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', + reason: + 'core tool pipeline must keep latest-main truncation and per-tool denial behavior until tool runtime ownership migrates', + patterns: [ + { + regex: /\bfn build_truncation_recovery_notice\b/, + message: 'missing tool-call truncation recovery notice helper', + }, + { + regex: /\btruncation_notice_for_interactive_tools_does_not_claim_file_write\b/, + message: 'missing interactive-tool truncation recovery regression', + }, + { + regex: /\btruncation_notice_for_write_tools_keeps_write_continuation_guidance\b/, + message: 'missing write-tool truncation recovery regression', + }, + { + regex: /\bdenied_tool_messages\b/, + message: 'missing per-tool denial message propagation', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/restrictions.rs', + reason: + 'core tool restrictions facade must preserve per-tool denial messages while runtime restrictions live in agent-tools', + patterns: [ + { + regex: /\bdenied_tool_messages\b/, + message: 'missing per-tool denial message field propagation', + }, + { + regex: /\bcustom_deny_message_overrides_generic_runtime_error\b/, + message: 'missing custom deny message regression', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', + reason: + 'core tool-result storage must keep explicit file flush until runtime artifact ownership migrates', + patterns: [ + { + regex: /\basync fn write_once\b/, + message: 'missing single-write persistence helper', + }, + { + regex: /file\.flush\(\)\.await/, + message: 'missing explicit persisted tool-result flush', + }, + ], + }, + { + path: 'src/crates/services-integrations/src/mcp/server/connection.rs', + reason: + 'services-integrations MCP connection must keep initialize-scoped timeout and channel-close cleanup until MCP owner migration is reviewed', + patterns: [ + { + regex: /\bsend_request_with_id\b/, + message: 'missing stable local JSON-RPC request id path', + }, + { + regex: /\binitialize_timeout\b/, + message: 'missing initialize-scoped timeout', + }, + { + regex: /notifications\/initialized/, + message: 'missing MCP initialized notification', + }, + { + regex: /\bpending\.clear\(\)/, + message: 'missing pending request waiter drain on channel close', + }, + { + regex: /\blocal_tool_calls_do_not_inherit_initialize_timeout\b/, + message: 'missing local tool request timeout-scope regression', + }, + { + regex: /\blocal_initialize_uses_initialize_timeout\b/, + message: 'missing local initialize timeout regression', + }, + ], + }, + { + path: 'src/crates/services-integrations/src/mcp/protocol/transport.rs', + reason: + 'services-integrations MCP local transport must keep explicit request ids and stdin flush semantics', + patterns: [ + { + regex: /\bpub async fn send_request_with_id\b/, + message: 'missing explicit JSON-RPC request id send path', + }, + { + regex: /\.flush\(\)\s*\.await/, + message: 'missing local MCP stdin flush', + }, + ], + }, { path: 'src/crates/core/Cargo.toml', reason: @@ -2031,7 +2211,7 @@ const requiredContentRules = [ { path: 'src/crates/runtime-ports/src/lib.rs', reason: - 'runtime-ports must keep remote runtime boundary contracts DTO/trait-only', + 'runtime-ports must keep remote and subagent runtime boundary contracts DTO/trait-only', patterns: [ { regex: /\bpub trait AgentTurnCancellationPort\b/, @@ -2049,6 +2229,65 @@ const requiredContentRules = [ regex: /\bpub fn remote_image\b/, message: 'missing remote image attachment helper contract', }, + { + regex: /\bpub type DialogTriggerSource = AgentSubmissionSource\b/, + message: 'missing dialog trigger source compatibility contract', + }, + { + regex: /\bdialog_trigger_source_reuses_agent_submission_source_contract\b/, + message: 'missing dialog trigger source alias regression', + }, + { + regex: /\bpub enum DialogQueuePriority\b/, + message: 'missing dialog queue priority contract', + }, + { + regex: /\bpub struct DialogSubmissionPolicy\b/, + message: 'missing dialog submission policy contract', + }, + { + regex: /\bdialog_submission_policy_preserves_current_surface_queue_defaults\b/, + message: 'missing dialog submission policy regression', + }, + { + regex: /\bpub enum DialogSubmitOutcome\b/, + message: 'missing dialog submit outcome contract', + }, + { + regex: /\bdialog_submit_outcome_preserves_started_and_queued_fields\b/, + message: 'missing dialog submit outcome regression', + }, + { + regex: /\bpub struct DelegationPolicy\b/, + message: 'missing delegation policy contract', + }, + { + regex: /\bpub enum SubagentContextMode\b/, + message: 'missing subagent context mode contract', + }, + { + regex: /\bdelegation_policy_child_blocks_recursive_spawn_without_losing_depth\b/, + message: 'missing delegation policy contract regression', + }, + { + regex: /\bsubagent_context_mode_preserves_fork_wire_value\b/, + message: 'missing subagent context mode contract regression', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', + reason: + 'core subagent runtime must preserve legacy import path while runtime-ports owns portable subagent contracts', + patterns: [ + { + regex: /pub\(crate\) use bitfun_runtime_ports::\{DelegationPolicy, SubagentContextMode\};/, + message: 'missing core compatibility re-export for subagent runtime contracts', + }, + { + regex: /pub\(crate\) mod queue_timing;/, + message: 'queue timing must remain core-owned until it has a reviewed non-DTO owner', + }, ], }, { @@ -2469,6 +2708,22 @@ const requiredContentRules = [ regex: /agent submission port does not yet accept generic attachments/, message: 'missing generic attachment guard on agent submission port', }, + { + regex: /pub use bitfun_runtime_ports::DialogTriggerSource;/, + message: 'missing dialog trigger source compatibility re-export', + }, + ], + }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + reason: + 'core scheduler must preserve legacy submission policy import path while runtime-ports owns portable dialog policy contracts', + patterns: [ + { + regex: + /pub use bitfun_runtime_ports::\{DialogQueuePriority, DialogSubmissionPolicy, DialogSubmitOutcome\};/, + message: 'missing dialog submission policy compatibility re-export', + }, ], }, { @@ -3770,8 +4025,20 @@ const requiredContentRules = [ { path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', reason: - 'core Task tool must continue owning background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', + 'core Task tool must continue owning fork-aware background subagent launch semantics until a reviewed agent-runtime port preserves delivery behavior', patterns: [ + { + regex: /\bfork_context\b/, + message: 'missing Task fork_context schema and validation surface', + }, + { + regex: /\bSubagentContextMode::Fork\b/, + message: 'missing forked subagent context mode path', + }, + { + regex: /delegation_policy\(\)\.spawn_child\(\)/, + message: 'missing child delegation policy propagation', + }, { regex: /"run_in_background"/, message: 'missing Task run_in_background schema flag', @@ -3785,8 +4052,16 @@ const requiredContentRules = [ message: 'missing background task id result contract', }, { - regex: /Background subagent/, - message: 'missing assistant-visible background subagent acknowledgement', + regex: /Background \{\} started successfully/, + message: 'missing assistant-visible background start acknowledgement', + }, + { + regex: / rule.path === 'src/crates/core/src/agentic/subagent_runtime/mod.rs', + ); + if (!coreSubagentRuntimeRule) { + throw new Error('missing core subagent runtime boundary rule'); + } + const coreSubagentRuntimeRuleText = coreSubagentRuntimeRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { + if (!coreSubagentRuntimeRuleText.includes(contract)) { + throw new Error(`core subagent runtime boundary rule must forbid contract: ${contract}`); + } + } + const coreCoordinatorRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/coordination/coordinator.rs', + ); + if (!coreCoordinatorRule) { + throw new Error('missing core coordinator boundary rule'); + } + const coreCoordinatorRuleText = coreCoordinatorRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + if (!coreCoordinatorRuleText.includes('DialogTriggerSource')) { + throw new Error('core coordinator boundary rule must forbid DialogTriggerSource redefinition'); + } + const coreSchedulerRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/core/src/agentic/coordination/scheduler.rs', + ); + if (!coreSchedulerRule) { + throw new Error('missing core scheduler boundary rule'); + } + const coreSchedulerRuleText = coreSchedulerRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of ['DialogQueuePriority', 'DialogSubmissionPolicy', 'DialogSubmitOutcome']) { + if (!coreSchedulerRuleText.includes(contract)) { + throw new Error(`core scheduler boundary rule must forbid contract: ${contract}`); + } + } + const coreSubagentRuntimeOwnerPathRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/core/src', + ); + if (!coreSubagentRuntimeOwnerPathRule) { + throw new Error('missing core subagent runtime owner-path boundary rule'); + } + const coreSubagentRuntimeOwnerPathRuleText = coreSubagentRuntimeOwnerPathRule.patterns + .map((pattern) => pattern.regex.source) + .join('\n'); + for (const contract of ['DelegationPolicy', 'SubagentContextMode']) { + if (!coreSubagentRuntimeOwnerPathRuleText.includes(contract)) { + throw new Error( + `core subagent runtime owner-path rule must forbid compatibility import: ${contract}`, + ); + } + } const productDomainProfile = dependencyProfileRules.find( (rule) => rule.crateName === 'product-domains', @@ -5984,8 +6315,69 @@ function runManifestParserSelfTest() { 'RemoteControlStatePort', 'RuntimeEventSink', 'remote_image', + 'DialogTriggerSource', + 'dialog_trigger_source_reuses_agent_submission_source_contract', + 'DialogQueuePriority', + 'DialogSubmissionPolicy', + 'dialog_submission_policy_preserves_current_surface_queue_defaults', + 'DialogSubmitOutcome', + 'dialog_submit_outcome_preserves_started_and_queued_fields', + 'DelegationPolicy', + 'SubagentContextMode', + 'delegation_policy_child_blocks_recursive_spawn_without_losing_depth', + 'subagent_context_mode_preserves_fork_wire_value', + ], + }, + { + path: 'src/crates/core/src/agentic/subagent_runtime/mod.rs', + contracts: [ + 'bitfun_runtime_ports', + 'DelegationPolicy', + 'SubagentContextMode', + 'queue_timing', + ], + }, + { + path: 'src/crates/core/src/agentic/session/session_manager.rs', + contracts: [ + 'clone_prompt_cache', + 'start_dialog_turn_with_existing_context', + 'start_dialog_turn_with_existing_context_persists_turn_and_snapshot', + 'clone_prompt_cache_copies_runtime_and_persisted_entries', ], }, + { + path: 'src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs', + contracts: [ + 'build_truncation_recovery_notice', + 'truncation_notice_for_interactive_tools_does_not_claim_file_write', + 'truncation_notice_for_write_tools_keeps_write_continuation_guidance', + 'denied_tool_messages', + ], + }, + { + path: 'src/crates/core/src/agentic/tools/restrictions.rs', + contracts: ['denied_tool_messages', 'custom_deny_message_overrides_generic_runtime_error'], + }, + { + path: 'src/crates/core/src/agentic/tools/tool_result_storage.rs', + contracts: ['write_once', 'file\\.flush\\(\\)\\.await'], + }, + { + path: 'src/crates/services-integrations/src/mcp/server/connection.rs', + contracts: [ + 'send_request_with_id', + 'initialize_timeout', + 'notifications/initialized', + 'pending\\.clear\\(\\)', + 'local_tool_calls_do_not_inherit_initialize_timeout', + 'local_initialize_uses_initialize_timeout', + ], + }, + { + path: 'src/crates/services-integrations/src/mcp/protocol/transport.rs', + contracts: ['send_request_with_id', '\\.flush\\(\\)\\s*\\.await'], + }, { path: 'src/crates/agent-tools/src/framework.rs', contracts: [ @@ -6095,8 +6487,13 @@ function runManifestParserSelfTest() { 'AgentTurnCancellationPort', 'RemoteControlStatePort', 'generic attachments', + 'DialogTriggerSource', ], }, + { + path: 'src/crates/core/src/agentic/coordination/scheduler.rs', + contracts: ['DialogQueuePriority', 'DialogSubmissionPolicy', 'DialogSubmitOutcome'], + }, { path: 'src/crates/core/src/service_agent_runtime.rs', contracts: [ @@ -6410,7 +6807,17 @@ function runManifestParserSelfTest() { }, { path: 'src/crates/core/src/agentic/tools/implementations/task_tool.rs', - contracts: ['run_in_background', 'start_background_subagent', 'background_task_id', 'Background subagent'], + contracts: [ + 'fork_context', + 'SubagentContextMode::Fork', + 'delegation_policy\\(\\)\\.spawn_child\\(\\)', + 'run_in_background', + 'start_background_subagent', + 'background_task_id', + 'Background \\{\\} started successfully', + ', } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DialogTriggerSource { - DesktopUi, - DesktopApi, - AgentSession, - ScheduledJob, - RemoteRelay, - Bot, - Cli, -} +pub use bitfun_runtime_ports::DialogTriggerSource; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AssistantBootstrapSkipReason { @@ -5022,28 +5013,7 @@ impl bitfun_runtime_ports::AgentSubmissionPort for ConversationCoordinator { let turn_id = resolve_agent_submission_turn_id(&request); - let trigger_source = match request - .source - .unwrap_or(bitfun_runtime_ports::AgentSubmissionSource::Bot) - { - bitfun_runtime_ports::AgentSubmissionSource::DesktopUi => { - DialogTriggerSource::DesktopUi - } - bitfun_runtime_ports::AgentSubmissionSource::DesktopApi => { - DialogTriggerSource::DesktopApi - } - bitfun_runtime_ports::AgentSubmissionSource::AgentSession => { - DialogTriggerSource::AgentSession - } - bitfun_runtime_ports::AgentSubmissionSource::ScheduledJob => { - DialogTriggerSource::ScheduledJob - } - bitfun_runtime_ports::AgentSubmissionSource::RemoteRelay => { - DialogTriggerSource::RemoteRelay - } - bitfun_runtime_ports::AgentSubmissionSource::Bot => DialogTriggerSource::Bot, - bitfun_runtime_ports::AgentSubmissionSource::Cli => DialogTriggerSource::Cli, - }; + let trigger_source = request.source.unwrap_or(DialogTriggerSource::Bot); let user_message_metadata = if request.metadata.is_empty() { None } else { diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 007b9c3b0..101d04360 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -32,65 +32,7 @@ use uuid::Uuid; const MAX_QUEUE_DEPTH: usize = 20; -/// Result of [`DialogScheduler::submit`]: whether this message began executing immediately -/// or was placed in the per-session queue. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DialogSubmitOutcome { - Started { session_id: String, turn_id: String }, - Queued { session_id: String, turn_id: String }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum DialogQueuePriority { - Low = 0, - Normal = 1, - High = 2, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DialogSubmissionPolicy { - pub trigger_source: DialogTriggerSource, - pub queue_priority: DialogQueuePriority, - pub skip_tool_confirmation: bool, -} - -impl DialogSubmissionPolicy { - pub const fn new( - trigger_source: DialogTriggerSource, - queue_priority: DialogQueuePriority, - skip_tool_confirmation: bool, - ) -> Self { - Self { - trigger_source, - queue_priority, - skip_tool_confirmation, - } - } - - pub const fn for_source(trigger_source: DialogTriggerSource) -> Self { - let (queue_priority, skip_tool_confirmation) = match trigger_source { - DialogTriggerSource::AgentSession => (DialogQueuePriority::Low, true), - DialogTriggerSource::ScheduledJob => (DialogQueuePriority::Low, true), - DialogTriggerSource::DesktopUi - | DialogTriggerSource::DesktopApi - | DialogTriggerSource::Cli => (DialogQueuePriority::Normal, false), - DialogTriggerSource::RemoteRelay | DialogTriggerSource::Bot => { - (DialogQueuePriority::Normal, true) - } - }; - Self::new(trigger_source, queue_priority, skip_tool_confirmation) - } - - pub const fn with_queue_priority(mut self, queue_priority: DialogQueuePriority) -> Self { - self.queue_priority = queue_priority; - self - } - - pub const fn with_skip_tool_confirmation(mut self, skip_tool_confirmation: bool) -> Self { - self.skip_tool_confirmation = skip_tool_confirmation; - self - } -} +pub use bitfun_runtime_ports::{DialogQueuePriority, DialogSubmissionPolicy, DialogSubmitOutcome}; #[derive(Debug, Clone)] pub struct AgentSessionReplyRoute { diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 800fe16de..a2550872c 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -1854,7 +1854,7 @@ mod tests { model_name: "test-model".to_string(), agent_type: "test-agent".to_string(), context_vars: HashMap::new(), - delegation_policy: crate::agentic::subagent_runtime::DelegationPolicy::top_level(), + delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), runtime_tool_restrictions: ToolRuntimeRestrictions::default(), steering_interrupt: None, cancellation_token: CancellationToken::new(), diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 542b86f95..43db92696 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -4,11 +4,11 @@ use crate::agentic::core::Message; use crate::agentic::round_preempt::{ DialogRoundInjectionInterrupt, DialogRoundInjectionSource, DialogRoundPreemptSource, }; -use crate::agentic::subagent_runtime::DelegationPolicy; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; +use bitfun_runtime_ports::DelegationPolicy; use serde_json::Value; use std::collections::HashMap; use std::sync::Arc; diff --git a/src/crates/core/src/agentic/subagent_runtime/mod.rs b/src/crates/core/src/agentic/subagent_runtime/mod.rs index a77411552..4910a7a2b 100644 --- a/src/crates/core/src/agentic/subagent_runtime/mod.rs +++ b/src/crates/core/src/agentic/subagent_runtime/mod.rs @@ -7,46 +7,5 @@ pub(crate) mod queue_timing; -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -pub(crate) struct DelegationPolicy { - pub allow_subagent_spawn: bool, - pub nesting_depth: u8, -} - -impl Default for DelegationPolicy { - fn default() -> Self { - Self::top_level() - } -} - -impl DelegationPolicy { - pub(crate) fn top_level() -> Self { - Self { - allow_subagent_spawn: true, - nesting_depth: 0, - } - } - - pub(crate) fn spawn_child(self) -> Self { - Self { - allow_subagent_spawn: false, - nesting_depth: self.nesting_depth.saturating_add(1), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum SubagentContextMode { - #[default] - Fresh, - Fork, -} - -impl SubagentContextMode { - pub(crate) fn as_str(self) -> &'static str { - match self { - Self::Fresh => "fresh", - Self::Fork => "fork", - } - } -} +#[allow(unused_imports)] +pub(crate) use bitfun_runtime_ports::{DelegationPolicy, SubagentContextMode}; diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 9f23a7a99..06899009a 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -17,7 +17,6 @@ use crate::agentic::deep_review_policy::{ DeepReviewRunManifestGate, DeepReviewSubagentRole, DEEP_REVIEW_AGENT_TYPE, }; use crate::agentic::events::DeepReviewQueueStatus; -use crate::agentic::subagent_runtime::SubagentContextMode; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -28,6 +27,7 @@ use crate::service::config::types::AIConfig; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::timing::elapsed_ms_u64; use async_trait::async_trait; +use bitfun_runtime_ports::SubagentContextMode; use log::{debug, warn}; use serde_json::{json, Value}; use std::collections::HashMap; @@ -522,6 +522,16 @@ Usage notes: .map(|agent| agent.id) .collect() } + + fn background_subagent_started_assistant_message( + delegate_target_label: &str, + background_task_id: &str, + ) -> String { + format!( + "Background {} started successfully.\nIts final result will be delivered back automatically to you when it is finished. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", + delegate_target_label, background_task_id + ) + } } #[async_trait] @@ -1295,9 +1305,9 @@ impl Tool for TaskTool { "run_in_background": true, "background_task_id": background_result.background_task_id, }), - result_for_assistant: Some(format!( - "Background {} started successfully.\nIts final result will be delivered back automatically to you when it is finished. Do not poll for status updates. If your current path is blocked on this result and there is no other useful local work to do, it is fine to end the current turn.", - delegate_target_label, background_result.background_task_id + result_for_assistant: Some(Self::background_subagent_started_assistant_message( + &delegate_target_label, + &background_result.background_task_id, )), image_attachments: None, }]); @@ -1693,11 +1703,11 @@ mod tests { use crate::agentic::deep_review_policy::{ DeepReviewBudgetTracker, DeepReviewExecutionPolicy, DeepReviewSubagentRole, }; - use crate::agentic::subagent_runtime::DelegationPolicy; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::util::BitFunError; use async_trait::async_trait; + use bitfun_runtime_ports::DelegationPolicy; use serde_json::json; use std::collections::HashMap; use std::sync::Arc; @@ -1805,6 +1815,20 @@ mod tests { assert!(schema.get("allOf").is_none()); } + #[test] + fn background_subagent_start_acknowledgement_keeps_structured_task_marker() { + let message = TaskTool::background_subagent_started_assistant_message( + "GeneralPurpose", + "bg-subagent-123", + ); + + assert!(message.starts_with("Background GeneralPurpose started successfully.")); + assert!(message.contains("")); + assert!(message.contains("Do not poll for status updates.")); + assert!(message.ends_with("")); + assert!(!message.contains("background_task_id=")); + } + #[tokio::test] async fn validate_input_requires_subagent_type_when_not_forking() { let validation = TaskTool::new() diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index abe170c1b..9d3ea6548 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -315,7 +315,7 @@ mod tests { workspace: None, context_vars: HashMap::new(), subagent_parent_info: None, - delegation_policy: crate::agentic::subagent_runtime::DelegationPolicy::top_level(), + delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), collapsed_tools: Vec::new(), unlocked_collapsed_tools: Vec::new(), allowed_tools: Vec::new(), diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index cb8a35586..a4a4d1990 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1488,7 +1488,7 @@ mod tests { workspace: None, context_vars: HashMap::new(), subagent_parent_info: None, - delegation_policy: crate::agentic::subagent_runtime::DelegationPolicy::top_level(), + delegation_policy: bitfun_runtime_ports::DelegationPolicy::top_level(), collapsed_tools: Vec::new(), unlocked_collapsed_tools: Vec::new(), allowed_tools: Vec::new(), diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 6738c25e5..acc6573a9 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -3,10 +3,10 @@ use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; use crate::agentic::round_preempt::DialogRoundInjectionInterrupt; -use crate::agentic::subagent_runtime::DelegationPolicy; use crate::agentic::tools::ToolRuntimeRestrictions; use crate::agentic::workspace::WorkspaceServices; use crate::agentic::WorkspaceBinding; +use bitfun_runtime_ports::DelegationPolicy; use std::collections::HashMap; use std::time::SystemTime; diff --git a/src/crates/core/src/agentic/tools/tool_context_runtime.rs b/src/crates/core/src/agentic/tools/tool_context_runtime.rs index eccd83bf9..16ac340ec 100644 --- a/src/crates/core/src/agentic/tools/tool_context_runtime.rs +++ b/src/crates/core/src/agentic/tools/tool_context_runtime.rs @@ -7,7 +7,6 @@ use crate::agentic::coordination::get_global_coordinator; use crate::agentic::deep_review::tool_context; -use crate::agentic::subagent_runtime::DelegationPolicy; use crate::agentic::session::EvidenceLedgerCheckpoint; use crate::agentic::tools::computer_use_host::ComputerUseHostRef; use crate::agentic::tools::framework::{ @@ -33,6 +32,7 @@ use crate::service::remote_ssh::workspace_state::remote_workspace_runtime_root; use crate::service::{get_workspace_runtime_service_arc, WorkspaceRuntimeContext}; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_tools::{PortableToolContextProvider, ToolContextFacts, ToolWorkspaceKind}; +use bitfun_runtime_ports::DelegationPolicy; use log::warn; use serde_json::Value; use sha2::{Digest, Sha256}; @@ -1317,6 +1317,7 @@ mod task_context_tests { SubagentParentInfo, ToolExecutionContext, ToolExecutionOptions, ToolTask, }; use crate::agentic::tools::ToolRuntimeRestrictions; + use bitfun_runtime_ports::DelegationPolicy; use serde_json::json; use std::collections::{BTreeSet, HashMap}; use tokio_util::sync::CancellationToken; @@ -1365,8 +1366,7 @@ mod task_context_tests { session_id: "parent_session".to_string(), dialog_turn_id: "parent_turn".to_string(), }), - delegation_policy: - crate::agentic::subagent_runtime::DelegationPolicy::top_level().spawn_child(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), collapsed_tools: vec!["WebFetch".to_string()], unlocked_collapsed_tools: vec!["WebFetch".to_string()], allowed_tools: vec!["WebFetch".to_string()], diff --git a/src/crates/core/src/service/project_context/service.rs b/src/crates/core/src/service/project_context/service.rs index 8d8a6e2f6..17bc98418 100644 --- a/src/crates/core/src/service/project_context/service.rs +++ b/src/crates/core/src/service/project_context/service.rs @@ -9,10 +9,10 @@ use super::types::{ FileConflictAction, ImportedDocument, ProjectContextConfig, }; use crate::agentic::coordination::{get_global_coordinator, SubagentExecutionRequest}; -use crate::agentic::subagent_runtime::{DelegationPolicy, SubagentContextMode}; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::service::bootstrap::ensure_workspace_gitignore_ignores_bitfun; use crate::util::errors::{BitFunError, BitFunResult}; +use bitfun_runtime_ports::{DelegationPolicy, SubagentContextMode}; use log::{debug, warn}; use std::collections::HashSet; use std::path::{Path, PathBuf}; diff --git a/src/crates/runtime-ports/src/lib.rs b/src/crates/runtime-ports/src/lib.rs index db451a767..7571816f2 100644 --- a/src/crates/runtime-ports/src/lib.rs +++ b/src/crates/runtime-ports/src/lib.rs @@ -89,6 +89,68 @@ pub enum AgentSubmissionSource { Cli, } +pub type DialogTriggerSource = AgentSubmissionSource; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DialogQueuePriority { + Low = 0, + Normal = 1, + High = 2, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DialogSubmissionPolicy { + pub trigger_source: DialogTriggerSource, + pub queue_priority: DialogQueuePriority, + pub skip_tool_confirmation: bool, +} + +impl DialogSubmissionPolicy { + pub const fn new( + trigger_source: DialogTriggerSource, + queue_priority: DialogQueuePriority, + skip_tool_confirmation: bool, + ) -> Self { + Self { + trigger_source, + queue_priority, + skip_tool_confirmation, + } + } + + pub const fn for_source(trigger_source: DialogTriggerSource) -> Self { + let (queue_priority, skip_tool_confirmation) = match trigger_source { + DialogTriggerSource::AgentSession => (DialogQueuePriority::Low, true), + DialogTriggerSource::ScheduledJob => (DialogQueuePriority::Low, true), + DialogTriggerSource::DesktopUi + | DialogTriggerSource::DesktopApi + | DialogTriggerSource::Cli => (DialogQueuePriority::Normal, false), + DialogTriggerSource::RemoteRelay | DialogTriggerSource::Bot => { + (DialogQueuePriority::Normal, true) + } + }; + Self::new(trigger_source, queue_priority, skip_tool_confirmation) + } + + pub const fn with_queue_priority(mut self, queue_priority: DialogQueuePriority) -> Self { + self.queue_priority = queue_priority; + self + } + + pub const fn with_skip_tool_confirmation(mut self, skip_tool_confirmation: bool) -> Self { + self.skip_tool_confirmation = skip_tool_confirmation; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DialogSubmitOutcome { + Started { session_id: String, turn_id: String }, + Queued { session_id: String, turn_id: String }, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentInputAttachment { @@ -296,6 +358,52 @@ pub trait SessionTranscriptReader: Send + Sync { ) -> PortResult; } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct DelegationPolicy { + pub allow_subagent_spawn: bool, + pub nesting_depth: u8, +} + +impl Default for DelegationPolicy { + fn default() -> Self { + Self::top_level() + } +} + +impl DelegationPolicy { + pub fn top_level() -> Self { + Self { + allow_subagent_spawn: true, + nesting_depth: 0, + } + } + + pub fn spawn_child(self) -> Self { + Self { + allow_subagent_spawn: false, + nesting_depth: self.nesting_depth.saturating_add(1), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubagentContextMode { + #[default] + Fresh, + Fork, +} + +impl SubagentContextMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Fork => "fork", + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -346,6 +454,54 @@ mod tests { assert!(json.get("turnId").is_none()); } + #[test] + fn dialog_trigger_source_reuses_agent_submission_source_contract() { + let json = serde_json::to_value(DialogTriggerSource::Cli) + .expect("serialize dialog trigger source"); + + assert_eq!(json, serde_json::json!("cli")); + } + + #[test] + fn dialog_submission_policy_preserves_current_surface_queue_defaults() { + let remote = DialogSubmissionPolicy::for_source(DialogTriggerSource::RemoteRelay); + assert_eq!(remote.queue_priority, DialogQueuePriority::Normal); + assert!(remote.skip_tool_confirmation); + + let bot = DialogSubmissionPolicy::for_source(DialogTriggerSource::Bot); + assert_eq!(bot.queue_priority, DialogQueuePriority::Normal); + assert!(bot.skip_tool_confirmation); + + let agent_session = DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession); + assert_eq!(agent_session.queue_priority, DialogQueuePriority::Low); + assert!(agent_session.skip_tool_confirmation); + + let cli = DialogSubmissionPolicy::for_source(DialogTriggerSource::Cli); + assert_eq!(cli.queue_priority, DialogQueuePriority::Normal); + assert!(!cli.skip_tool_confirmation); + } + + #[test] + fn dialog_submit_outcome_preserves_started_and_queued_fields() { + let started = DialogSubmitOutcome::Started { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + }; + let queued = DialogSubmitOutcome::Queued { + session_id: "session_1".to_string(), + turn_id: "turn_2".to_string(), + }; + + assert_eq!( + started, + DialogSubmitOutcome::Started { + session_id: "session_1".to_string(), + turn_id: "turn_1".to_string(), + } + ); + assert_ne!(started, queued); + } + #[test] fn agent_submission_request_serializes_explicit_turn_id_contract() { let mut metadata = serde_json::Map::new(); @@ -469,6 +625,31 @@ mod tests { assert!(json.get("provider_id").is_none()); } + #[test] + fn subagent_context_mode_preserves_fork_wire_value() { + assert_eq!(SubagentContextMode::default(), SubagentContextMode::Fresh); + assert_eq!(SubagentContextMode::Fresh.as_str(), "fresh"); + assert_eq!(SubagentContextMode::Fork.as_str(), "fork"); + + let json = serde_json::to_value(SubagentContextMode::Fork) + .expect("serialize subagent context mode"); + + assert_eq!(json, serde_json::json!("fork")); + } + + #[test] + fn delegation_policy_child_blocks_recursive_spawn_without_losing_depth() { + let top_level = DelegationPolicy::top_level(); + assert!(top_level.allow_subagent_spawn); + assert_eq!(top_level.nesting_depth, 0); + + let child = top_level.spawn_child(); + + assert!(!child.allow_subagent_spawn); + assert_eq!(child.nesting_depth, 1); + assert_eq!(child.spawn_child().nesting_depth, 2); + } + #[test] fn dynamic_tool_descriptor_omits_missing_provider_id() { let descriptor = DynamicToolDescriptor {