diff --git a/docs/architecture/session-application-coordinators/spec.md b/docs/architecture/session-application-coordinators/spec.md index bc5248cb8..c3cea2e2b 100644 --- a/docs/architecture/session-application-coordinators/spec.md +++ b/docs/architecture/session-application-coordinators/spec.md @@ -136,10 +136,15 @@ Owns executable assignment and runtime-setting policy: - create/subagent/transfer assignment resolution; - transfer impact, batch move/delete, and single-session transfer; -- model, project, permission, generation settings, disabled tools, and subagent-enabled settings; +- model, project, permission, generation settings, and disabled tools; - ACP config options and commands; - subagent Tape link finalization. +Subagent availability is not Session assignment state. The DeepChat runtime derives it from the +current Agent delegation policy, normalized slots, and `sessionKind`; the retained +`new_sessions.subagent_enabled` column is physical compatibility data and is not an authorization +input. + Session deletion remains a lifecycle transaction. Assignment may call a required narrow lifecycle deletion port, but the composition graph must not use optional setters or circular construction. Assignment resolution shared with lifecycle may be implemented as a focused pure policy module; it @@ -253,6 +258,8 @@ defined as `Pick` and must not be grouped under a r - Project updates retain their current non-transactional order; no rollback is introduced. - ACP model lock, workdir requirement, permission modes, generation settings, disabled tools, and config/command behavior remain unchanged. +- Create and transfer do not copy or reinterpret the legacy `subagent_enabled` value. Existing + Sessions observe their current Agent delegation policy on the next tool-profile resolution. - Subagent parent/slot/agent validation, ACP forced runtime settings, and Tape link parent-child checks remain explicit at finalization. diff --git a/docs/architecture/session-management.md b/docs/architecture/session-management.md index 330f9f88d..315addebb 100644 --- a/docs/architecture/session-management.md +++ b/docs/architecture/session-management.md @@ -60,6 +60,11 @@ sequenceDiagram `new_sessions.agent_id` 对应的 strict `AgentDescriptor.kind`;unknown、disabled、malformed 或 cached identity mismatch 失败关闭,不 fallback。 +`new_sessions.subagent_enabled` 仅作为旧数据库兼容列保留,默认值仍为 `0`。当前 Session DTO、创建、 +transfer、remote、cron 与 renderer draft 都不读写或解释该列。Subagent 可用性只由当前 Agent 的 delegation +policy、正规化 slots 与 `sessionKind` 推导;因此已有 Session 会在下一次 tool-profile 解析时读取最新 Agent +配置,不需要重建或迁移 Session row。 + ## Typed handle 共同 handle 保留双方真实共有的: @@ -112,6 +117,9 @@ ACP-provider source 只清 compatibility binding,不被误判成 direct ACP。 - Subagent 与普通 session 共享 app/message schema,用 `sessionKind`、`parentSessionId`、`subagentMeta` 区分;child backend 仍由 manager 选择。子任务结算时父 session 记录 frozen-head Tape link;显式 cross-Tape View 只读 direct child,child entries 不复制进父 effective view。 +- regular DeepChat parent 只有在 Agent policy 开启且至少存在一个有效 slot 时才获得 + `subagent_orchestrator`;Subagent child 始终失败关闭,不能递归委派。模型是否实际调用由任务收益和用户 + 当轮指令决定,Agent Settings 是唯一持久化用户开关。 - Remote 通过四个 consumer-owned session ports 调用 coordinator;active-generation lookup/cancel 仍使用 `AgentManagerGenerationPort`,不扫描 presenter runtime maps。 - Cron 的 composition-owned starter 通过 Lifecycle 创建 detached app session、通过 Turn send/cancel; diff --git a/docs/architecture/subagent-capability-policy/plan.md b/docs/architecture/subagent-capability-policy/plan.md new file mode 100644 index 000000000..13e910112 --- /dev/null +++ b/docs/architecture/subagent-capability-policy/plan.md @@ -0,0 +1,108 @@ +# Subagent Capability Policy - Implementation Plan + +## Phase 1: Specify the policy boundary + +- Record Agent configuration as the only persisted delegation policy. +- Define the closed capability, default-on migration matrix, Session compatibility, cache contract, + security boundary, and model delegation guidance. +- Keep run guardrails, host-policy isolation, and Tape lineage as unchanged dependencies. + +## Phase 2: Migrate defaults and enforce Agent invariants + +- Advance `unifiedAgentsMigrationVersion` to 3. +- Migrate only the built-in legacy disabled-empty state to default-on, seed defaults for enabled + empty configurations, and preserve custom disabled policies. +- Mark the version only after all writes succeed so retries are idempotent. +- Validate the merged Agent configuration at repository create/update boundaries. +- Make Settings restore defaults on enable, retain disabled slots, and prevent deletion of the last + enabled slot. +- Cover migration retry, explicit disable preservation, invalid direct writes, and UI behavior. + +## Phase 3: Centralize runtime capability + +- Add one pure capability resolver shared by tool-profile and execution paths. +- Carry the snapshot through the internal tool-definition context and include its canonical + `cacheKey` in the tool profile fingerprint. +- Build the model schema from that snapshot and re-resolve policy before run admission. +- Initially retain the legacy Session boolean as an additional compatibility gate so this phase is + independently correct before the Session surface is removed. +- Reserve and reclassify `subagent_orchestrator` so MCP and generic disabled-tool policy cannot + shadow or independently control it. + +## Phase 4: Retire Session-level state + +- Remove the Session route, application ports, assignment fields, create/transfer/cron/remote + propagation, renderer Session/Draft state, and composer item. +- Remove the compatibility gate from capability admission; Agent policy becomes the only user + policy. +- Keep the database column and legacy reader compatibility while leaving all new rows at the + existing default. +- Prove legacy `0` and `1` values are non-interfering and existing Sessions observe Agent changes on + the next turn. + +## Phase 5: Guide model delegation + +- Update the orchestrator description and default prompt with conservative delegation criteria, + user opt-out precedence, overlapping-write cautions, and cost/latency guidance. +- Update Agent Settings copy without adding another switch. +- Add request-level tool snapshot coverage and configuration-race/active-run integration tests. + +## Phase 6: Validate and finalize contracts + +- Run targeted and full main/renderer suites, native SQLite coverage, typecheck, format, i18n, lint, + and architecture guards. +- Review the complete branch for hidden writes, migration compatibility, cache correctness, + security boundaries, performance, naming, test depth, and maintenance cost. +- Update current session/application and tool-system architecture contracts only for validated + behavior. + +## Commit Strategy + +1. `docs(agent): specify subagent capability` +2. `fix(agent): migrate subagent defaults` +3. `fix(agent): centralize subagent capability` +4. `refactor(session): remove subagent toggle` +5. `fix(agent): guide subagent delegation` +6. `docs(agent): finalize subagent capability` + +Any additional remediation commit must name its concrete behavior. Existing commits are not amended +or rebased. + +## Review Gate + +Before every commit: + +1. Inspect status, the complete unstaged diff/stat/check, and run the smallest sufficient tests. +2. Review P0-P3 for hidden writes, compatibility, migration/retry boundaries, performance, + authorization, naming, test quality, and maintenance cost. +3. Fix every in-scope actionable finding and repeat the review. +4. Stage explicit task paths only; inspect the complete staged diff/stat/check and repeat the same + severity review. +5. Commit only when the staged change has no unrelated file and no actionable P0-P3 finding. + +After implementation, repeat the review over `dev...HEAD`. If a finding requires a new global +policy, database-column removal, child permission change, or external protocol expansion, stop and +request authorization rather than expanding this goal. + +## Compatibility and Rollback + +- Agent config fields remain readable by older versions. +- The default migration changes only JSON config and is idempotent; rollback code can still read + the resulting policy and slots. +- The legacy Session column remains present with its original default, so older database readers do + not fail schema checks. +- Older Session values are ignored by new code and are not destructively rewritten. +- No provider/ACP registry refresh or full build is part of this change. + +## Validation Strategy + +- Repository/config tests: defaults, migration matrix, idempotency, failure retry, write invariant. +- Tool tests: capability reasons, slot schema, cache refresh, call-time revalidation, reserved name, + generic-disabled non-interference, child recursion prevention. +- Session tests: create/transfer/cron/remote inputs, legacy column compatibility, existing Session + Agent-policy refresh, ACP non-interference. +- Renderer tests: no composer/draft toggle, Settings default restoration and final-slot protection. +- Integration tests: actual provider tool snapshot appears/disappears without restart, active runs + retain their admitted snapshot, Tape finalization and child activity remain unchanged. +- Final commands: targeted Vitest suites, `test:main:native-sqlite`, full main and renderer suites, + typecheck, format/check, i18n, and lint. diff --git a/docs/architecture/subagent-capability-policy/spec.md b/docs/architecture/subagent-capability-policy/spec.md new file mode 100644 index 000000000..3325781dd --- /dev/null +++ b/docs/architecture/subagent-capability-policy/spec.md @@ -0,0 +1,221 @@ +# Subagent Capability Policy - Specification + +> Status: **implemented and validated** + +## Problem + +DeepChat currently models Subagent availability through several independent states: + +1. `DeepChatAgentConfig.subagentEnabled` is the Agent-level default. +2. `new_sessions.subagent_enabled` is copied into each regular Session and can be toggled from the + composer. +3. `DeepChatAgentConfig.subagents` must contain at least one valid slot before + `subagent_orchestrator` can be exposed. +4. The runtime tool catalog is cached without the Agent Subagent policy or slot configuration in + its fingerprint. + +These states can disagree. The composer can report that Subagents are enabled while the model never +receives `subagent_orchestrator`, and Agent configuration changes can remain hidden behind a stale +tool catalog. The same capability is also controlled by both a dedicated boolean and the generic +disabled-Agent-tool mechanism. + +## Decision + +Agent configuration is the single persisted policy source for Subagent delegation. The policy is +enabled by default, and the model decides whether a particular task benefits from delegation. The +runtime remains the final authority for availability, slot selection, ownership, permissions, +timeouts, cancellation, and recursion prevention. + +DeepChat keeps one advanced Agent-level opt-out. It removes the Session/Draft/composer toggle and +does not add a global user-facing switch. + +## Capability Contract + +Runtime availability is represented by a closed capability snapshot: + +```ts +type DeepChatSubagentCapability = + | { + available: true + slots: DeepChatSubagentSlot[] + cacheKey: string + } + | { + available: false + reason: 'policy_disabled' | 'unsupported_session' | 'no_valid_slots' + cacheKey: string + } +``` + +The snapshot obeys these invariants: + +1. Only a regular DeepChat parent Session can have an available capability. +2. A Subagent child Session always resolves `unsupported_session` and cannot recurse. +3. An Agent with `subagentEnabled === false` resolves `policy_disabled` even when slots remain + configured. +4. An enabled Agent must have at least one structurally valid, normalized slot. Corrupt enabled + configuration resolves `no_valid_slots` and fails closed. +5. `cacheKey` covers every field that changes the model-visible tool definition: policy state, + slot ID, target type, target Agent ID, display name, and description. +6. Tool-definition construction uses one immutable capability snapshot. Tool execution resolves + the current capability again so a policy disabled between definition and invocation cannot be + bypassed. +7. A run that already passed admission keeps its start-time task and slot snapshot. Later policy + changes affect future calls, not active runs; users cancel active runs through the existing run + controls. + +Target-Agent existence and host-policy validity remain call-time checks. A stale target fails before +the child Session is created and cannot create a Tape link. + +## Agent Configuration and Migration + +`DeepChatAgentConfig.subagentEnabled` and `DeepChatAgentConfig.subagents` remain compatible stored +fields. Their meaning is Agent delegation policy and reusable child slots, not Session state. + +New built-in and custom Agent configurations default to `subagentEnabled = true` with Explorer, +Implementer, and Reviewer self-target slots. + +A custom Agent with an absent config JSON keeps that implicit local Subagent default instead of +inheriting the built-in Agent's opt-out. Other unset configuration fields continue to inherit from +the built-in Agent, and raw catalog reads remain unchanged for compatibility. + +A present but unreadable config JSON does not receive those implicit slots. It resolves as enabled +with zero valid slots so the runtime reports `no_valid_slots` until the configuration is repaired. + +The unified Agent configuration migration advances to version 3 with this matrix: + +| Stored configuration | Migration result | +| --- | --- | +| Built-in `deepchat`: `false` and zero valid slots | `true` plus default slots | +| Any Agent: enabled and zero valid slots | enabled plus default slots | +| Custom Agent: disabled and zero valid slots | unchanged | +| Any Agent: disabled with configured slots | unchanged; slots are retained | +| Missing policy or slots | existing default normalization remains `true` plus default slots | + +The migration is idempotent and records version 3 only after all Agent updates succeed. A failure +leaves the version unchanged so startup can retry safely. + +After migration, Agent writes enforce `enabled => at least one valid slot` at the main-process +repository boundary. The Settings UI restores defaults when enabling an empty legacy form and does +not allow the final slot to be removed while enabled. Disabling an Agent preserves its slots. + +## Tool Exposure and Security + +`subagent_orchestrator` is a dedicated system/model capability rather than an ordinary +user-configurable Agent tool: + +- the Agent-level policy is its only user-controlled hard gate; +- generic `disabledAgentTools` cannot create a second policy source; +- it is absent from the generic tool toggle catalog; +- its name is reserved so an MCP server cannot shadow the privileged built-in orchestrator; +- tool-catalog resolution checks the persisted Agent kind instead of assuming every compatibility + runtime Session is a DeepChat Agent; +- an Agent-kind lookup failure closes only the Subagent capability and does not suppress an + independently resolved Skill or MCP extension policy; +- execution revalidates regular-parent ownership and the current Agent policy before admitting a + new run. + +Existing child permission inheritance, cross-Agent host-policy isolation, direct ACP target +behavior, maximum run count, task count, deadlines, cancellation settlement, and Tape link +finalization do not change. + +## Session and UI Compatibility + +The Session-level capability surface is retired: + +- Session create, detached create, transfer, cron, remote, and draft inputs no longer accept or + propagate `subagentEnabled`; +- `sessions.setSubagentEnabled` is removed; +- renderer Session/Draft stores and the synthetic composer `subagent` item are removed; +- Agent Settings remains the only configuration surface; +- Subagent progress cards, child navigation, logs, wait/kill controls, and cancellation remain. + +The physical `new_sessions.subagent_enabled` column stays in the current database schema for +rollback and old-database compatibility. New code leaves it at the database default and never uses +it to authorize the capability. Legacy imports may contain the field; readers tolerate and ignore +it instead of reintroducing Session policy. Current exports and renderer contracts do not present +it as meaningful state. + +Existing Sessions resolve the current Agent policy on the next tool-catalog resolution. They do not +need to be recreated. A legacy Session value of either `0` or `1` has no effect after retirement. + +## Model Delegation Policy + +When available, the model honors explicit user direction first. For proactive delegation, the +expected benefit must exceed the extra coordination, token, latency, and resource cost. Tool and +default system guidance must require: + +- using Subagents when explicitly requested and available, and never using them when explicitly + declined; +- limiting proactive delegation to genuinely independent, isolatable, or clearly parallel work; +- avoiding proactive delegation for simple, latency-sensitive, or strongly sequential tasks; +- avoiding concurrent write-heavy tasks that can modify overlapping files; +- preferring bounded prompts and observable validation results from each child. + +The Agent-level switch is a hard policy. A one-turn natural-language instruction is model guidance +and does not create another persisted state source. + +## Performance Constraints + +- Capability resolution performs bounded Agent-kind/config point reads and one Session-kind lookup + per tool profile resolution; it does not scan Sessions or Agents. +- Slot normalization remains bounded by `DEEPCHAT_SUBAGENT_SLOT_LIMIT`. +- A stable capability cache key invalidates only when model-visible policy changes. +- No event-only cache invalidation is relied upon for correctness. +- Default exposure adds only the existing orchestrator schema; no discovery tool or additional + model round trip is introduced. + +## Acceptance Criteria + +1. A new or migrated built-in DeepChat Agent exposes `subagent_orchestrator` in a regular Session + without a Session toggle. +2. An Agent-level disable removes the tool from existing Sessions on their next turn without an app + restart. +3. Slot changes update the tool enum and description on the next turn despite a previously cached + catalog. +4. Enabled configuration cannot persist with zero valid slots; disabled configuration retains its + slots. +5. Legacy `subagent_enabled` values do not affect availability, and the physical column remains + schema-compatible. +6. Child Sessions cannot receive or invoke `subagent_orchestrator`. +7. A policy change between tool definition and invocation fails closed, while an admitted active + run is not silently cancelled. +8. An MCP server cannot shadow `subagent_orchestrator`, and generic disabled-tool state cannot + override the dedicated policy. +9. Composer and New Thread UI contain no Subagent toggle; Agent Settings contains the default-on + policy with cost guidance. +10. Existing run guardrails, host-policy isolation, Tape lineage, cross-Tape recall, ACP behavior, + and child activity UI remain compatible. +11. Configless custom Agents retain their own default-on Subagent policy, and non-DeepChat + compatibility Sessions never receive the orchestrator definition. + +## Validation Evidence + +The implementation was verified with targeted Agent migration/repository, tool catalog/runtime, +Session lifecycle/route, ACP, and renderer suites. The final branch validation also passed: + +- full main-process tests: 372 files passed, 16 skipped; 4,300 tests passed, 207 skipped; +- full renderer tests: 168 files and 1,301 tests passed; +- the four native-SQLite suites under Electron's ABI: 4 files and 179 tests passed; +- node and renderer typechecks, repository formatting and format check, i18n validation, lint, and + architecture guards. + +The exact `pnpm run test:main:native-sqlite` shell command could not load the installed native +module because the shell Node runtime requires module ABI 137 while the workspace dependency was +built for Electron ABI 143. The same forced-native suites passed through the repository's Electron +runtime. A full build was intentionally not run because this goal does not refresh provider or ACP +registries. + +## Non-goals + +- A global or enterprise-managed Subagent policy. +- A per-turn hard-disable UI or a three-state automatic/explicit/off policy. +- Recursive Subagents or a change to concurrency, task, timeout, or permission limits. +- Changing slot target model selection or child workspace inheritance. +- Removing the legacy database column in this change. +- Adding a new model tool, lineage UI, or provider build refresh. +- Syncing a GitHub issue or creating a PR. + +## Open Questions + +None. diff --git a/docs/architecture/subagent-capability-policy/tasks.md b/docs/architecture/subagent-capability-policy/tasks.md new file mode 100644 index 000000000..b462c06a6 --- /dev/null +++ b/docs/architecture/subagent-capability-policy/tasks.md @@ -0,0 +1,17 @@ +# Subagent Capability Policy - Tasks + +- [x] SCP-001: Specify the Agent policy, capability, migration, cache, compatibility, and security + contracts. +- [x] SCP-002: Add the default-on migration and enforce enabled-slot write invariants. +- [x] SCP-003: Protect the enabled Settings form from empty slot configuration. +- [x] SCP-004: Add a typed capability snapshot and canonical cache key. +- [x] SCP-005: Build and execute `subagent_orchestrator` from the centralized capability. +- [x] SCP-006: Reserve the orchestrator name and remove generic disabled-tool control. +- [x] SCP-007: Remove Session/Draft capability inputs, state, route, and composer UI. +- [x] SCP-008: Preserve legacy database/import compatibility without authorizing from Session data. +- [x] SCP-009: Add conservative delegation guidance and Agent Settings cost copy. +- [x] SCP-010: Cover migration, cache, runtime, Session, renderer, ACP, and active-run boundaries. +- [x] SCP-011: Complete cumulative P0-P3 review and full validation. +- [x] SCP-012: Update maintained architecture contracts with validated behavior. +- [x] SCP-013: Keep implicit custom defaults independent and enforce Agent kind at catalog + resolution. diff --git a/docs/architecture/tool-system.md b/docs/architecture/tool-system.md index f3543e9dc..8494b1ce0 100644 --- a/docs/architecture/tool-system.md +++ b/docs/architecture/tool-system.md @@ -17,7 +17,7 @@ | `AgentBashHandler` | `src/main/presenter/toolPresenter/agentTools/agentBashHandler.ts` | 命令执行与后台 session | | `AgentFffSearchHandler` | `src/main/presenter/toolPresenter/agentTools/agentFffSearchHandler.ts` | FFF-backed `glob` / `grep` code search | | `chatSettingsTools` | `src/main/presenter/toolPresenter/agentTools/chatSettingsTools.ts` | chat/session settings 工具 | -| `SubagentOrchestratorTool` | `src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts` | subagent orchestration | +| `SubagentOrchestratorTool` | `src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts` | Agent-policy-gated subagent orchestration with call-time revalidation | | `AgentPlanTool` | `src/main/presenter/toolPresenter/agentTools/agentPlanTool.ts` | `agent-core/update_plan` | | `AgentTapeToolHandler` | `src/main/presenter/toolPresenter/agentTools/agentTapeTools.ts` | 模型可调用的原子 Tape recall pair:`tape_search` / `tape_context` | | `AgentImageGenerationTool` | `src/main/presenter/toolPresenter/agentTools/agentImageGenerationTool.ts` | image generation tool | @@ -64,6 +64,14 @@ catalog。它只返回 `user-configurable` Agent tools,不解析 MCP catalog conversation MCP access context 或 runtime cache。`tools.listDefinitions` 保持原 IPC name/wire shape,内部 读取该 configurable catalog。 +`subagent_orchestrator` 属于 `system-model` capability,不进入 configurable catalog,也不受 +`disabledAgentTools` 控制。其名称 reserved,MCP definition 不能 shadow 内建 orchestrator;持久化 +disabled-tool 正规化会移除遗留同名值。模型可见性由一个 closed capability snapshot 决定:regular +DeepChat parent 必须同时满足当前 Agent policy 开启和至少一个有效 slot,Subagent child 始终不可用。 +capability 的 canonical `cacheKey` 覆盖 policy 与所有 model-visible slot 字段,并进入 tool-profile +fingerprint,因此配置变化不依赖事件式 cache invalidation。definition 使用同一 snapshot 构建;真正 +调用前重新解析当前 capability 并失败关闭,而已经 admission 的 run 保留启动时 task/slot snapshot。 + Tape 的 exposure matrix 为: | Tool | Exposure | Model behavior | diff --git a/src/main/agent/deepchat/deepChatAgentRepository.ts b/src/main/agent/deepchat/deepChatAgentRepository.ts index 99ae807cd..7022704b1 100644 --- a/src/main/agent/deepchat/deepChatAgentRepository.ts +++ b/src/main/agent/deepchat/deepChatAgentRepository.ts @@ -4,6 +4,7 @@ import type { AppSessionId } from '@/agent/shared/agentSessionIds' import { normalizeDisabledAgentTools } from '@/agent/shared/agentSessionNormalization' import type { AgentRow } from '@/presenter/sqlitePresenter/tables/agents' import { + assertDeepChatSubagentConfigInvariant, createDefaultDeepChatSubagentSlots, normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' @@ -54,6 +55,33 @@ const normalizeExplicitDisabledAgentTools = (config: DeepChatAgentConfig): DeepC } } +const prepareConfigWrite = (config: DeepChatAgentConfig): DeepChatAgentConfig => { + const normalized = normalizeExplicitDisabledAgentTools(config) + assertDeepChatSubagentConfigInvariant(normalized) + return normalized +} + +const createImplicitSubagentPolicyConfig = (): DeepChatAgentConfig => + normalizeDeepChatSubagentConfig({}) + +const createFailClosedSubagentPolicyConfig = (): DeepChatAgentConfig => + normalizeDeepChatSubagentConfig({ subagentEnabled: true, subagents: [] }) + +const parseDeepChatConfigRow = (row?: AgentRow): DeepChatAgentConfig | null => { + if (!row || row.agent_type !== 'deepchat') return null + const config = parseJson(row.config_json) + return config ? normalizeDeepChatSubagentConfig(config) : null +} + +const resolveDeepChatConfigRow = (row?: AgentRow): DeepChatAgentConfig => { + const config = parseDeepChatConfigRow(row) + if (config) return config + if (!row || row.agent_type !== 'deepchat') return {} + return row.config_json + ? createFailClosedSubagentPolicyConfig() + : createImplicitSubagentPolicyConfig() +} + const normalizeNullableStringList = ( value: string[] | null | undefined ): string[] | null | undefined => { @@ -137,9 +165,7 @@ export class DeepChatAgentRepository { protected: true, icon: sanitizeString(defaults?.icon), avatarJson: stringifyJson(defaults?.avatar ?? null), - configJson: stringifyJson( - defaults?.config ? normalizeExplicitDisabledAgentTools(defaults.config) : null - ) + configJson: stringifyJson(defaults?.config ? prepareConfigWrite(defaults.config) : null) }) } else { rows.update(BUILTIN_DEEPCHAT_AGENT_ID, { enabled: true, protected: true }) @@ -159,9 +185,7 @@ export class DeepChatAgentRepository { description: sanitizeString(input.description), icon: sanitizeString(input.icon), avatarJson: stringifyJson(input.avatar ?? null), - configJson: stringifyJson( - input.config ? normalizeExplicitDisabledAgentTools(input.config) : null - ) + configJson: stringifyJson(input.config ? prepareConfigWrite(input.config) : null) }) return this.dependencies.rows.get(id) as AgentRow } @@ -175,7 +199,7 @@ export class DeepChatAgentRepository { const nextConfig = updates.config === undefined ? currentConfig - : normalizeExplicitDisabledAgentTools({ + : prepareConfigWrite({ ...currentConfig, ...clone(updates.config ?? {}) }) @@ -215,24 +239,24 @@ export class DeepChatAgentRepository { } getConfig(agentId: string): DeepChatAgentConfig | null { - const row = this.dependencies.rows.get(agentId) - if (!row || row.agent_type !== 'deepchat') return null - const config = parseJson(row.config_json) - return config ? normalizeDeepChatSubagentConfig(config) : null + return parseDeepChatConfigRow(this.dependencies.rows.get(agentId)) } resolveConfig(agentId: string): DeepChatAgentConfig { - const builtin = this.getConfig(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} + const { rows } = this.dependencies + const builtin = resolveDeepChatConfigRow(rows.get(BUILTIN_DEEPCHAT_AGENT_ID)) if (agentId === BUILTIN_DEEPCHAT_AGENT_ID) return mergeDeepChatConfig({}, builtin) - return mergeDeepChatConfig(builtin, this.getConfig(agentId) ?? {}) + + const agentRow = rows.get(agentId) + const override = resolveDeepChatConfigRow(agentRow) + return mergeDeepChatConfig(builtin, override) } listResolvedConfigs(): Array<{ agentId: string; config: DeepChatAgentConfig }> { const rows = this.dependencies.rows.list({ agentType: 'deepchat' }) const configByAgentId = new Map( rows.map((row) => { - const parsed = parseJson(row.config_json) - return [row.id, parsed ? normalizeDeepChatSubagentConfig(parsed) : null] as const + return [row.id, resolveDeepChatConfigRow(row)] as const }) ) const builtin = configByAgentId.get(BUILTIN_DEEPCHAT_AGENT_ID) ?? {} diff --git a/src/main/agent/shared/appSessionService.ts b/src/main/agent/shared/appSessionService.ts index 1a59f8c81..d64c9e510 100644 --- a/src/main/agent/shared/appSessionService.ts +++ b/src/main/agent/shared/appSessionService.ts @@ -62,7 +62,6 @@ export class AppSessionService implements AppSessionReadPort { options?: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionKind parentSessionId?: string | null subagentMeta?: DeepChatSubagentMeta | null @@ -73,7 +72,6 @@ export class AppSessionService implements AppSessionReadPort { this.dependencies.newSessionsTable.create(id, agentId, title, projectDir, { isDraft: options?.isDraft, disabledAgentTools: options?.disabledAgentTools, - subagentEnabled: options?.subagentEnabled, sessionKind: options?.sessionKind, parentSessionId: options?.parentSessionId, subagentMetaJson: options?.subagentMeta ? JSON.stringify(options.subagentMeta) : null @@ -153,7 +151,6 @@ export class AppSessionService implements AppSessionReadPort { | 'isDraft' | 'sessionKind' | 'parentSessionId' - | 'subagentEnabled' | 'subagentMeta' > > @@ -170,7 +167,6 @@ export class AppSessionService implements AppSessionReadPort { project_dir?: string | null is_pinned?: number is_draft?: number - subagent_enabled?: number session_kind?: SessionKind parent_session_id?: string | null subagent_meta_json?: string | null @@ -179,9 +175,6 @@ export class AppSessionService implements AppSessionReadPort { if (fields.projectDir !== undefined) dbFields.project_dir = fields.projectDir if (fields.isPinned !== undefined) dbFields.is_pinned = fields.isPinned ? 1 : 0 if (fields.isDraft !== undefined) dbFields.is_draft = fields.isDraft ? 1 : 0 - if (fields.subagentEnabled !== undefined) { - dbFields.subagent_enabled = fields.subagentEnabled ? 1 : 0 - } if (fields.sessionKind !== undefined) dbFields.session_kind = fields.sessionKind if (fields.parentSessionId !== undefined) { dbFields.parent_session_id = fields.parentSessionId @@ -254,7 +247,6 @@ export class AppSessionService implements AppSessionReadPort { is_draft: number session_kind: string parent_session_id: string | null - subagent_enabled: number subagent_meta_json: string | null created_at: number updated_at: number @@ -269,7 +261,6 @@ export class AppSessionService implements AppSessionReadPort { isDraft: row.is_draft === 1, sessionKind: row.session_kind === 'subagent' ? 'subagent' : 'regular', parentSessionId: row.parent_session_id ?? null, - subagentEnabled: row.subagent_enabled === 1, subagentMeta: parseSubagentMeta(row.subagent_meta_json), createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/src/main/presenter/agentRuntimePresenter/toolResolver.ts b/src/main/presenter/agentRuntimePresenter/toolResolver.ts index 3208a8e80..ec5a6e80c 100644 --- a/src/main/presenter/agentRuntimePresenter/toolResolver.ts +++ b/src/main/presenter/agentRuntimePresenter/toolResolver.ts @@ -1,7 +1,11 @@ import type { IConfigPresenter, ISkillPresenter } from '@shared/presenter' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' -import type { DeepChatSessionState } from '@shared/types/agent-interface' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSessionState +} from '@shared/types/agent-interface' import type { SQLitePresenter } from '../sqlitePresenter' import type { DeepChatAgentInstance, @@ -15,6 +19,8 @@ import { type AgentExtensionPolicy } from '@/agent/deepchat/resources/systemPromptBuilder' import { createToolCatalogPort } from './toolAdapters' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' type ToolResolverSkillPort = Pick @@ -68,16 +74,18 @@ export class DeepChatToolResolver { resourceInstance.getAgentId()?.trim() || this.dependencies.getSessionAgentId(sessionId) || 'deepchat' - const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) + const toolPolicy = await this.resolveAgentToolPolicy(sessionId, resourceInstance) + const policy = toolPolicy.extensionPolicy const effectiveActiveSkillNames = activeSkillNamesOverride === undefined - ? await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance) + ? await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance, policy) : filterSkillNamesByPolicy(activeSkillNamesOverride, policy) - const profile = await this.resolveToolProfile( + const profile = this.resolveToolProfile( sessionId, projectDir, effectiveActiveSkillNames, policy, + toolPolicy.subagentCapability, resourceInstance ) this.dependencies.assertCurrent(sessionId, resourceInstance) @@ -94,6 +102,7 @@ export class DeepChatToolResolver { conversationId: sessionId, agentWorkspacePath: projectDir, activeSkillNames: effectiveActiveSkillNames, + subagentCapability: toolPolicy.subagentCapability, ...(enabledMcpServerIds === undefined ? {} : { enabledMcpServerIds }) } } @@ -127,22 +136,17 @@ export class DeepChatToolResolver { } } - private async resolveToolProfile( + private resolveToolProfile( sessionId: string, projectDir: string | null, - activeSkillNamesOverride?: string[], - extensionPolicy?: AgentExtensionPolicy, + activeSkillNamesOverride: string[], + extensionPolicy: AgentExtensionPolicy, + subagentCapability: DeepChatSubagentCapability, resourceInstance?: DeepChatAgentInstance - ): Promise<{ kind: DeepChatToolProfileKind; fingerprint: string }> { + ): { kind: DeepChatToolProfileKind; fingerprint: string } { const normalizedProjectDir = projectDir?.trim() || null const skillsEnabled = this.dependencies.configPresenter.getSkillsEnabled() - const policy = - extensionPolicy ?? (await this.resolveAgentExtensionPolicy(sessionId, resourceInstance)) - const activeSkillNames = filterSkillNamesByPolicy( - activeSkillNamesOverride ?? - (await this.resolveActiveSkillNamesForToolProfile(sessionId, resourceInstance)), - policy - ) + const activeSkillNames = filterSkillNamesByPolicy(activeSkillNamesOverride, extensionPolicy) const disabledAgentTools = this.getDisabledAgentTools(sessionId) const state = resourceInstance?.getRuntimeState() ?? this.dependencies.getRuntimeState(sessionId) @@ -164,17 +168,19 @@ export class DeepChatToolResolver { disabledAgentTools: [...disabledAgentTools].sort((left, right) => left.localeCompare(right) ), - enabledSkillNames: this.normalizeNullablePolicyList(policy.enabledSkillNames), - enabledMcpServerIds: this.normalizeNullablePolicyList(policy.enabledMcpServerIds), + enabledSkillNames: this.normalizeNullablePolicyList(extensionPolicy.enabledSkillNames), + enabledMcpServerIds: this.normalizeNullablePolicyList(extensionPolicy.enabledMcpServerIds), skillsEnabled, - activeSkillNames + activeSkillNames, + subagentCapability: subagentCapability.cacheKey }) } } async resolveActiveSkillNamesForToolProfile( sessionId: string, - resourceInstance?: DeepChatAgentInstance + resourceInstance?: DeepChatAgentInstance, + extensionPolicy?: AgentExtensionPolicy ): Promise { if ( !this.dependencies.configPresenter.getSkillsEnabled() || @@ -184,7 +190,8 @@ export class DeepChatToolResolver { } try { - const policy = await this.resolveAgentExtensionPolicy(sessionId, resourceInstance) + const policy = + extensionPolicy ?? (await this.resolveAgentExtensionPolicy(sessionId, resourceInstance)) return filterSkillNamesByPolicy( normalizeStringList(await this.dependencies.skillPresenter.getActiveSkills(sessionId)), policy @@ -198,6 +205,64 @@ export class DeepChatToolResolver { } } + private async resolveAgentToolPolicy( + sessionId: string, + resourceInstance?: DeepChatAgentInstance + ): Promise<{ + extensionPolicy: AgentExtensionPolicy + subagentCapability: DeepChatSubagentCapability + }> { + const agentId = + resourceInstance?.getAgentId()?.trim() || + this.dependencies.getSessionAgentId(sessionId) || + 'deepchat' + const sessionRow = this.dependencies.sqlitePresenter.newSessionsTable?.get?.(sessionId) + const resolveCapability = (agentType: AgentType | null, config?: DeepChatAgentConfig | null) => + resolveDeepChatSubagentCapability({ + agentType, + sessionKind: sessionRow?.session_kind ?? null, + agentPolicyEnabled: config?.subagentEnabled !== false, + slots: config?.subagents + }) + + const resolveConfig = this.dependencies.configPresenter.resolveDeepChatAgentConfig + const [agentTypeResult, configResult] = await Promise.allSettled([ + this.dependencies.configPresenter.getAgentType(agentId), + typeof resolveConfig === 'function' + ? this.dependencies.configPresenter.resolveDeepChatAgentConfig(agentId) + : Promise.resolve(null) + ]) + const agentType = agentTypeResult.status === 'fulfilled' ? agentTypeResult.value : null + + if (agentTypeResult.status === 'rejected') { + console.warn( + `[DeepChatAgent] Failed to resolve Agent type for tool policy ${agentId}:`, + agentTypeResult.reason + ) + } + if (configResult.status === 'rejected') { + console.warn( + `[DeepChatAgent] Failed to resolve tool policy for agent ${agentId}:`, + configResult.reason + ) + return { + extensionPolicy: {}, + subagentCapability: resolveCapability(agentType, null) + } + } + + const config = configResult.value + return { + extensionPolicy: config + ? { + enabledSkillNames: config.enabledSkillNames, + enabledMcpServerIds: config.enabledMcpServerIds + } + : {}, + subagentCapability: resolveCapability(agentType, config) + } + } + async resolveAgentExtensionPolicy( sessionId: string, resourceInstance?: DeepChatAgentInstance diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index a4ecc75de..38654ea6a 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -75,7 +75,11 @@ import { AcpLaunchSpecService } from '@/agent/acp/launch/acpLaunchSpecService' import { AcpProvider } from '../llmProviderPresenter/providers/acpProvider' import { resolveAcpAgentAlias } from '@shared/utils/acpAgentAlias' import { AgentRepository, BUILTIN_DEEPCHAT_AGENT_ID } from '../agentRepository' -import { normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' +import { + createDefaultDeepChatSubagentSlots, + normalizeDeepChatSubagentConfig, + normalizeDeepChatSubagentSlots +} from '@shared/lib/deepchatSubagents' import type { SQLitePresenter } from '../sqlitePresenter' import type { SettingsKey, SettingsSnapshotValues } from '@shared/contracts/routes' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' @@ -175,7 +179,7 @@ const defaultProviders = DEFAULT_PROVIDERS.map((provider) => ({ })) const PROVIDERS_STORE_KEY = 'providers' -const UNIFIED_AGENTS_MIGRATION_VERSION = 2 +const UNIFIED_AGENTS_MIGRATION_VERSION = 3 const DEPRECATED_BUILTIN_PROVIDER_IDS = ['qwenlm', 'laoshi'] as const type AnthropicLegacyProvider = LLM_PROVIDER & { authMode?: 'apikey' | 'oauth' } type ModelSelection = { providerId: string; modelId: string } @@ -931,7 +935,8 @@ export class ConfigPresenter implements IConfigPresenter { config: this.buildLegacyBuiltinDeepChatConfig() }) - let migratedVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 + const storedMigrationVersion = this.getSetting('unifiedAgentsMigrationVersion') ?? 0 + let migratedVersion = storedMigrationVersion let registryAgentsSynced = false if (migratedVersion < 1) { this.acpCatalogConfigAdapter.getManualAgents().forEach((agent) => { @@ -946,6 +951,36 @@ export class ConfigPresenter implements IConfigPresenter { migratedVersion = 1 } + const shouldMigrateSubagentDefaults = migratedVersion < 3 + if (shouldMigrateSubagentDefaults) { + for (const agent of repository.listAgents({ agentType: 'deepchat' })) { + const config = repository.getDeepChatAgentConfig(agent.id) + if (!config) { + continue + } + + const slots = normalizeDeepChatSubagentSlots(config.subagents) + const isBuiltinLegacyDisabledEmpty = + agent.id === BUILTIN_DEEPCHAT_AGENT_ID && + config.subagentEnabled === false && + slots.length === 0 + const isEnabledEmpty = config.subagentEnabled !== false && slots.length === 0 + if (!isBuiltinLegacyDisabledEmpty && !isEnabledEmpty) { + continue + } + + const updated = repository.updateDeepChatAgent(agent.id, { + config: { + ...(isBuiltinLegacyDisabledEmpty ? { subagentEnabled: true } : {}), + subagents: createDefaultDeepChatSubagentSlots() + } + }) + if (!updated) { + throw new Error(`Failed to migrate DeepChat Agent ${agent.id} Subagent defaults.`) + } + } + } + if (migratedVersion < 2) { for (const agent of repository.listAgents({ agentType: 'deepchat' })) { const config = repository.getDeepChatAgentConfig(agent.id) ?? {} @@ -959,11 +994,22 @@ export class ConfigPresenter implements IConfigPresenter { disabledAgentTools.length !== config.disabledAgentTools.length || disabledAgentTools.some((tool) => !config.disabledAgentTools?.includes(tool)) ) { - repository.updateDeepChatAgent(agent.id, { + const updated = repository.updateDeepChatAgent(agent.id, { config: { disabledAgentTools } }) + if (!updated) { + throw new Error(`Failed to migrate DeepChat Agent ${agent.id} tool defaults.`) + } } } + migratedVersion = 2 + } + + if (shouldMigrateSubagentDefaults) { + migratedVersion = 3 + } + + if (migratedVersion !== storedMigrationVersion) { this.store.set('unifiedAgentsMigrationVersion', UNIFIED_AGENTS_MIGRATION_VERSION) } diff --git a/src/main/presenter/configPresenter/systemPromptHelper.ts b/src/main/presenter/configPresenter/systemPromptHelper.ts index 1ed4f348d..86dfb8452 100644 --- a/src/main/presenter/configPresenter/systemPromptHelper.ts +++ b/src/main/presenter/configPresenter/systemPromptHelper.ts @@ -2,6 +2,7 @@ import { SystemPrompt } from '@shared/presenter' import ElectronStore from 'electron-store' import { publishDeepchatEvent } from '@/routes/publishDeepchatEvent' import { emitDefaultSystemPromptChanged } from './eventPublishers' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' type SetSetting = (key: string, value: T) => void @@ -28,7 +29,7 @@ You have access to powerful tools — use them proactively: - **Terminal** (exec, process): Run builds, tests, git commands, package managers. Use \`background: true\` for long-running tasks. Always check process output before launching another command. - **Browser** (YoBrowser): Automate web interactions, take screenshots, inspect DOM elements when web research or testing is needed. - **Skills**: Specialized knowledge modules. Before starting domain-specific work, check if a relevant skill exists with \`skill_list\` and \`skill_view\`. Load it to inherit expert-level guidance. -- **Subagents**: For complex tasks with independent subtasks, use the subagent orchestrator to delegate work in parallel or chain mode. This is especially powerful for: (a) exploring multiple code paths simultaneously, (b) implementing and reviewing in parallel, (c) any task where isolated context prevents cross-contamination. +- **Subagents**: When \`subagent_orchestrator\` is available, let the expected benefit exceed its coordination cost. ${DEEPCHAT_SUBAGENT_MODEL_GUIDANCE} - **MCP tools**: External integrations (databases, APIs, services). Use them when they extend your capabilities beyond file/code operations. ### Code Quality diff --git a/src/main/presenter/cronJobs/runSessionStarter.ts b/src/main/presenter/cronJobs/runSessionStarter.ts index 054f5d47a..231887543 100644 --- a/src/main/presenter/cronJobs/runSessionStarter.ts +++ b/src/main/presenter/cronJobs/runSessionStarter.ts @@ -42,7 +42,6 @@ export const createCronJobRunSessionStarter = (deps: { defaultModelPreset?: { providerId?: string; modelId?: string } | null permissionMode?: 'default' | 'auto_approve' | 'full_access' disabledAgentTools?: string[] - subagentEnabled?: boolean systemPrompt?: string } | null @@ -65,9 +64,6 @@ export const createCronJobRunSessionStarter = (deps: { ...(job.toolPolicy === 'snapshot' && snapshotConfig?.disabledAgentTools ? { disabledAgentTools: snapshotConfig.disabledAgentTools } : {}), - ...(snapshotConfig?.subagentEnabled !== undefined - ? { subagentEnabled: snapshotConfig.subagentEnabled } - : {}), ...(systemPrompt ? { generationSettings: { systemPrompt } } : {}), metadata: { source: 'cron_job', diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index fe8f37440..34a60c479 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -99,7 +99,7 @@ import { PluginPresenter } from './pluginPresenter' import { AgentRepository, BUILTIN_DEEPCHAT_AGENT_ID } from './agentRepository' import type { SQLitePresenter } from './sqlitePresenter' import { DatabaseSecurityPresenter } from './databaseSecurityPresenter' -import { normalizeDeepChatSubagentSlots } from '@shared/lib/deepchatSubagents' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' import { subscribeDeepChatInternalSessionUpdates } from './agentRuntimePresenter/internalSessionEvents' import type { AcpAsLlmProviderPermissionPort, @@ -124,6 +124,12 @@ import { SessionHistorySearch } from '@/routes/sessions/sessionHistorySearch' import { SessionTranslation } from '@/routes/sessions/sessionTranslation' import { AgentSessionExportService } from './exporter/agentSessionExporter' import { createMemoryProviderBindings } from './memoryProviderBindings' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSubagentCapability, + SessionKind +} from '@shared/types/agent-interface' type MemoryMaintenanceConfigChangeTarget = Pick< MemoryPresenter, @@ -141,6 +147,39 @@ export const routeDeepChatAgentMemoryMaintenanceConfigChanged = ( } } +type ResolveConversationSubagentCapabilityInput = { + sessionId: string + agentId: string + agentType: AgentType | null + sessionKind: SessionKind + resolveConfig: (agentId: string) => Promise +} + +export const resolveConversationSubagentCapability = async ( + input: ResolveConversationSubagentCapabilityInput +): Promise => { + let config: DeepChatAgentConfig | null = null + + if (input.agentType === 'deepchat' && input.sessionKind === 'regular') { + try { + config = await input.resolveConfig(input.agentId) + } catch (error) { + console.warn('[Presenter] Failed to resolve Subagent policy:', { + sessionId: input.sessionId, + agentId: input.agentId, + error + }) + } + } + + return resolveDeepChatSubagentCapability({ + agentType: input.agentType, + sessionKind: input.sessionKind, + agentPolicyEnabled: config?.subagentEnabled !== false, + slots: config?.subagents + }) +} + // Coordinates presenters and owns main-process IPC wiring. export class Presenter implements IPresenter { private static instance: Presenter @@ -329,12 +368,13 @@ export class Presenter implements IPresenter { const disabledAgentTools = await this.sessionAgentAssignmentCoordinator.getSessionDisabledAgentTools(session.id) const activeSkills = await this.skillPresenter.getActiveSkills(session.id) - const availableSubagentSlots = - agentType === 'deepchat' && session.sessionKind === 'regular' - ? normalizeDeepChatSubagentSlots( - (await this.configPresenter.resolveDeepChatAgentConfig(session.agentId)).subagents - ) - : [] + const subagentCapability = await resolveConversationSubagentCapability({ + sessionId: session.id, + agentId: session.agentId, + agentType, + sessionKind: session.sessionKind, + resolveConfig: (agentId) => this.configPresenter.resolveDeepChatAgentConfig(agentId) + }) return { sessionId: session.id, @@ -350,9 +390,8 @@ export class Presenter implements IPresenter { activeSkills, sessionKind: session.sessionKind, parentSessionId: session.parentSessionId ?? null, - subagentEnabled: session.subagentEnabled, subagentMeta: session.subagentMeta ?? null, - availableSubagentSlots + subagentCapability } }, searchTape: async (conversationId, query, options) => { diff --git a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts index fce174b87..7300fb835 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentCoordinator.ts @@ -272,29 +272,6 @@ export class SessionAgentAssignmentCoordinator .handle.settings.setPermissionMode(mode) } - async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { - const session = this.requireSession(sessionId) - if (session.sessionKind !== 'regular') { - throw new Error('Only regular sessions can change subagent state.') - } - - if (this.dependencies.runtime.getSessionAgentKind(toAppSessionId(sessionId)) !== 'deepchat') { - throw new Error('Only DeepChat sessions can change subagent state.') - } - - this.dependencies.sessions.update(sessionId, { subagentEnabled: enabled }) - if (!this.dependencies.sessions.get(sessionId)) { - throw new Error(`Session not found after update: ${sessionId}`) - } - - this.dependencies.projection.notify({ sessionIds: [sessionId], reason: 'updated' }) - const materialized = await this.dependencies.projection.materialize(sessionId) - if (!materialized) { - throw new Error(`Failed to build session state for sessionId: ${sessionId}`) - } - return materialized - } - async setSessionModel( sessionId: string, providerId: string, @@ -521,8 +498,7 @@ export class SessionAgentAssignmentCoordinator this.dependencies.sessions.updateAgentId(sessionId, target.agentId) this.dependencies.sessions.update(sessionId, { - projectDir: target.projectDir, - subagentEnabled: session.sessionKind === 'regular' ? target.subagentEnabled : false + projectDir: target.projectDir }) this.dependencies.sessions.updateDisabledAgentTools(sessionId, target.disabledAgentTools) await this.syncAcpSessionWorkdir( diff --git a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts index 7926937da..660fcc23f 100644 --- a/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts +++ b/src/main/presenter/sessionApplication/agentAssignmentPolicy.ts @@ -69,11 +69,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort disabledAgentTools: descriptor.kind === 'deepchat' ? normalizeDisabledAgentTools(input.disabledAgentTools ?? agentConfig?.disabledAgentTools) - : [], - subagentEnabled: - descriptor.kind === 'deepchat' - ? (input.subagentEnabled ?? agentConfig?.subagentEnabled ?? false) - : false + : [] } } @@ -198,8 +194,7 @@ export class SessionAgentAssignmentPolicy implements SessionAssignmentPolicyPort null, permissionMode: resolveAssignmentPermissionMode(agentConfig?.permissionMode), generationSettings: this.mergeDefaultGenerationSettings(agentConfig), - disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools), - subagentEnabled: agentConfig?.subagentEnabled === true + disabledAgentTools: normalizeDisabledAgentTools(agentConfig?.disabledAgentTools) } } diff --git a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts index 832248f33..8e59af60a 100644 --- a/src/main/presenter/sessionApplication/lifecycleCoordinator.ts +++ b/src/main/presenter/sessionApplication/lifecycleCoordinator.ts @@ -52,7 +52,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { permissionMode: input.permissionMode, generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, preserveExplicitNullProjectDir: true }) const { @@ -62,8 +61,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { projectDir, permissionMode, generationSettings, - disabledAgentTools, - subagentEnabled + disabledAgentTools } = assignment logger.info( `[SessionLifecycleCoordinator] createSession agent=${agentId} webContentsId=${webContentsId}` @@ -74,8 +72,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { const title = normalizedInput.text.slice(0, 50) || 'New Chat' const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, { isDraft: false, - disabledAgentTools, - subagentEnabled + disabledAgentTools }) logger.info(`[SessionLifecycleCoordinator] session created id=${sessionId}`) @@ -112,7 +109,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled, subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), @@ -141,8 +137,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { projectDir, permissionMode, generationSettings, - disabledAgentTools, - subagentEnabled + disabledAgentTools } = await this.dependencies.assignmentPolicy.resolveCreateAssignment({ agentId: input.agentId?.trim() || 'deepchat', providerId: input.providerId, @@ -151,14 +146,12 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { permissionMode: input.permissionMode, generationSettings: input.generationSettings, disabledAgentTools: input.disabledAgentTools, - subagentEnabled: input.subagentEnabled, preserveExplicitNullProjectDir: false }) const sessionId = this.dependencies.sessions.create(agentId, title, projectDir, { isDraft: false, disabledAgentTools, - subagentEnabled, metadata: input.metadata ?? null }) try { @@ -190,7 +183,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled, subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), @@ -240,7 +232,6 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { { isDraft: false, disabledAgentTools: runtimeConfig.disabledAgentTools, - subagentEnabled: false, sessionKind: 'subagent', parentSessionId, subagentMeta @@ -308,7 +299,7 @@ export class SessionLifecycleCoordinator implements SessionLifecyclePort { canonicalAgentId, 'New Chat', projectDir, - { isDraft: true, subagentEnabled: false } + { isDraft: true } ) try { await this.ensureSessionRuntimeInitialized(sessionId, { diff --git a/src/main/presenter/sessionApplication/ports.ts b/src/main/presenter/sessionApplication/ports.ts index 0fc33e007..ef2450396 100644 --- a/src/main/presenter/sessionApplication/ports.ts +++ b/src/main/presenter/sessionApplication/ports.ts @@ -296,7 +296,6 @@ export interface CreateAssignmentInput { permissionMode?: PermissionMode generationSettings?: Partial disabledAgentTools?: string[] - subagentEnabled?: boolean preserveExplicitNullProjectDir: boolean } @@ -309,7 +308,6 @@ export interface ResolvedSessionAssignment { permissionMode: PermissionMode generationSettings?: Partial disabledAgentTools: string[] - subagentEnabled: boolean } export interface SubagentAssignmentInput { @@ -344,7 +342,6 @@ export interface ResolvedTransferTarget { permissionMode: PermissionMode generationSettings?: Partial disabledAgentTools: string[] - subagentEnabled: boolean } export interface SessionAssignmentPolicyPort { @@ -364,10 +361,7 @@ export interface SessionAssignmentPolicyPort { export interface SessionAssignmentStorePort { get(sessionId: string): SessionRecord | null list(filters?: SessionListFilters): SessionRecord[] - update( - sessionId: string, - fields: Partial> - ): void + update(sessionId: string, fields: Partial>): void updateAgentId(sessionId: string, agentId: string): void getDisabledAgentTools(sessionId: string): string[] updateDisabledAgentTools(sessionId: string, disabledAgentTools: string[]): void @@ -414,7 +408,6 @@ export interface SessionLifecycleStorePort { options?: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionKind parentSessionId?: string | null subagentMeta?: DeepChatSubagentMeta | null @@ -520,7 +513,6 @@ export interface SessionAgentAssignmentPort { ): Promise getPermissionMode(sessionId: string): Promise setPermissionMode(sessionId: string, mode: PermissionMode): Promise - setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise setSessionModel(sessionId: string, providerId: string, modelId: string): Promise setSessionProjectDir(sessionId: string, projectDir: string | null): Promise getSessionGenerationSettings(sessionId: string): Promise diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index bae517ad4..7c945ede7 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -110,7 +110,6 @@ export class NewSessionsTable extends BaseTable { isPinned?: boolean activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: 'regular' | 'subagent' parentSessionId?: string | null subagentMetaJson?: string | null @@ -132,13 +131,12 @@ export class NewSessionsTable extends BaseTable { is_draft, active_skills, disabled_agent_tools, - subagent_enabled, session_kind, parent_session_id, subagent_meta_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) .run( id, @@ -149,7 +147,6 @@ export class NewSessionsTable extends BaseTable { options?.isDraft ? 1 : 0, JSON.stringify(options?.activeSkills ?? []), JSON.stringify(options?.disabledAgentTools ?? []), - options?.subagentEnabled ? 1 : 0, options?.sessionKind === 'subagent' ? 'subagent' : 'regular', options?.parentSessionId ?? null, options?.subagentMetaJson ?? null, @@ -273,7 +270,6 @@ export class NewSessionsTable extends BaseTable { | 'is_draft' | 'active_skills' | 'disabled_agent_tools' - | 'subagent_enabled' | 'session_kind' | 'parent_session_id' | 'subagent_meta_json' @@ -307,10 +303,6 @@ export class NewSessionsTable extends BaseTable { setClauses.push('disabled_agent_tools = ?') params.push(fields.disabled_agent_tools) } - if (fields.subagent_enabled !== undefined) { - setClauses.push('subagent_enabled = ?') - params.push(fields.subagent_enabled) - } if (fields.session_kind !== undefined) { setClauses.push('session_kind = ?') params.push(fields.session_kind) diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 0dabe6a3c..f02d1aaea 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -53,6 +53,7 @@ import { cronJobActionNeedsPermission } from './cronJobTool' import { isYoBrowserUnavailableError } from '../../browser/YoBrowserErrors' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' // Consider moving to a shared handlers location in future refactoring import { @@ -371,6 +372,7 @@ export class AgentToolManager { agentWorkspacePath: string | null conversationId?: string activeSkillNames?: string[] + subagentCapability?: DeepChatSubagentCapability catalogPurpose?: 'runtime' | 'configurable' }): Promise { const defs: MCPToolDefinition[] = [] @@ -449,13 +451,18 @@ export class AgentToolManager { } // 2.5. Subagent orchestration tool (deepchat regular sessions only) - if (isAgentMode && context.conversationId && this.subagentOrchestratorTool) { + if ( + isAgentMode && + acceptsExposure('system-model') && + context.conversationId && + this.subagentOrchestratorTool + ) { try { - const subagentToolDefinition = await this.subagentOrchestratorTool.getToolDefinition( - context.conversationId + const subagentToolDefinition = this.subagentOrchestratorTool.getToolDefinition( + context.subagentCapability ) if (subagentToolDefinition) { - appendDefinitions([subagentToolDefinition], 'user-configurable') + appendDefinitions([subagentToolDefinition], 'system-model') } } catch (error) { logger.warn('[AgentToolManager] Failed to resolve subagent tool availability', { error }) diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts index 3abb46b08..d220a4c45 100644 --- a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -6,8 +6,11 @@ import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.pres import type { AgentToolCallResult } from './agentToolManager' import type { AgentToolRuntimePort, ConversationSessionInfo } from '../runtimePorts' import { awaitWithAbort } from '@/lib/awaitWithAbort' +import { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' +import type { DeepChatSubagentCapability } from '@shared/types/agent-interface' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' -export const SUBAGENT_ORCHESTRATOR_TOOL_NAME = 'subagent_orchestrator' +export { SUBAGENT_ORCHESTRATOR_TOOL_NAME } from '@shared/agentTools' const DEFAULT_RUN_TIMEOUT_MS = 300000 const MIN_RUN_TIMEOUT_MS = 1000 const MAX_RUN_TIMEOUT_MS = 1800000 @@ -16,6 +19,7 @@ const SUBAGENT_WORKDIR_RULE = 'Every child session inherits the same working directory as the parent session.' const SUBAGENT_PROMPT_DESCRIPTION = [ 'Describe only the delegated subtask itself.', + 'Keep its scope bounded and request concrete evidence or validation.', 'The child session uses the same working directory as the parent session.', 'When a child waits for permission or a question, open that child sessionId from progress to respond.' ].join(' ') @@ -741,30 +745,6 @@ export class SubagentOrchestratorTool { } } - private async getAvailableSession( - conversationId?: string - ): Promise { - if (!conversationId) { - return null - } - - const session = await this.runtimePort.resolveConversationSessionInfo(conversationId) - if (!session) { - return null - } - - return session.agentType === 'deepchat' && - session.sessionKind === 'regular' && - session.subagentEnabled === true && - session.availableSubagentSlots.length > 0 - ? session - : null - } - - async isAvailable(conversationId?: string): Promise { - return Boolean(await this.getAvailableSession(conversationId)) - } - private buildSlotIdParameter(slots: DeepChatSubagentSlot[]) { const normalizedSlots = [...slots] .map((slot) => ({ @@ -816,19 +796,18 @@ export class SubagentOrchestratorTool { } } - async getToolDefinition(conversationId?: string): Promise { - const session = await this.getAvailableSession(conversationId) - if (!session) { + getToolDefinition(capability?: DeepChatSubagentCapability): MCPToolDefinition | null { + if (!capability?.available) { return null } - const slotIdParameter = this.buildSlotIdParameter(session.availableSubagentSlots) + const slotIdParameter = this.buildSlotIdParameter(capability.slots) return { type: 'function', function: { name: SUBAGENT_ORCHESTRATOR_TOOL_NAME, - description: `Delegate up to 5 tasks to configured subagents, run them in parallel or in chain mode, and return a single aggregated markdown result after every child session finishes. Use background=true for long-running subagent work, then use operation=list/info/log/wait/kill with the returned runId. ${SUBAGENT_WORKDIR_RULE}`, + description: `Delegate up to 5 tasks to configured Subagents and aggregate their results. ${DEEPCHAT_SUBAGENT_MODEL_GUIDANCE} Use parallel mode only for independent tasks and chain mode when later tasks depend on earlier results. Use background=true for long-running work, then use operation=list/info/log/wait/kill with the returned runId. ${SUBAGENT_WORKDIR_RULE}`, parameters: { type: 'object', properties: { @@ -934,12 +913,14 @@ export class SubagentOrchestratorTool { if ( parent.agentType !== 'deepchat' || parent.sessionKind !== 'regular' || - parent.subagentEnabled !== true + !parent.subagentCapability.available ) { - throw new Error( - 'subagent_orchestrator is only available in DeepChat regular sessions with subagents enabled.' - ) + const reason = parent.subagentCapability.available + ? 'unsupported_session' + : parent.subagentCapability.reason + throw new Error(`subagent_orchestrator is unavailable for the current session (${reason}).`) } + const subagentCapability = parent.subagentCapability if (args.operation !== 'run') { return this.handleRunOperation(args, conversationId, options) @@ -966,7 +947,7 @@ export class SubagentOrchestratorTool { ) } - const slotMap = new Map(parent.availableSubagentSlots.map((slot) => [slot.id, slot])) + const slotMap = new Map(subagentCapability.slots.map((slot) => [slot.id, slot])) const now = Date.now() const runTimeoutMs = args.runTimeoutMs ?? DEFAULT_RUN_TIMEOUT_MS const tasks = taskSpecs.map((task, index): MutableTaskState => { diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 62874308e..0baa5d620 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -17,6 +17,7 @@ import { QUESTION_TOOL_NAME } from '@/presenter/toolPresenter/agentTools/questio import { ToolMapper, type ToolSource } from './toolMapper' import { CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, TAPE_TOOL_NAMES, getAgentToolExposure, isUserConfigurableAgentTool @@ -75,6 +76,7 @@ const RESERVED_AGENT_TOOL_NAMES = new Set([ IMAGE_GENERATE_TOOL_NAME, UPDATE_PLAN_TOOL_NAME, CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, ...Object.values(TAPE_TOOL_NAMES) ]) @@ -186,7 +188,8 @@ export class ToolPresenter implements IToolPresenter { supportsVision, agentWorkspacePath, conversationId: context.conversationId, - activeSkillNames: context.activeSkillNames + activeSkillNames: context.activeSkillNames, + subagentCapability: context.subagentCapability }), 'agent' ) diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index f316a092d..33a1ea3b6 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -6,7 +6,7 @@ import type { } from '@shared/presenter' import type { DeepChatSubagentMeta, - DeepChatSubagentSlot, + DeepChatSubagentCapability, AgentTapeContextOptions, AgentTapeContextResult, AgentTapeSearchOptions, @@ -47,9 +47,8 @@ export interface ConversationSessionInfo { activeSkills: string[] sessionKind: SessionKind parentSessionId: string | null - subagentEnabled: boolean subagentMeta: DeepChatSubagentMeta | null - availableSubagentSlots: DeepChatSubagentSlot[] + subagentCapability: DeepChatSubagentCapability } export interface CreateSubagentSessionInput { diff --git a/src/main/routes/debug/createMockChatSession.ts b/src/main/routes/debug/createMockChatSession.ts index 84ac39cfa..3be4bfe9f 100644 --- a/src/main/routes/debug/createMockChatSession.ts +++ b/src/main/routes/debug/createMockChatSession.ts @@ -97,13 +97,12 @@ export function createDebugMockChatSession(db: DebugMockChatDatabase): DebugMock is_draft, active_skills, disabled_agent_tools, - subagent_enabled, session_kind, parent_session_id, subagent_meta_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ) const insertDeepchatSession = db.prepare( `INSERT INTO deepchat_sessions ( @@ -138,7 +137,6 @@ export function createDebugMockChatSession(db: DebugMockChatDatabase): DebugMock 0, '[]', '[]', - 0, 'regular', null, null, diff --git a/src/main/routes/index.ts b/src/main/routes/index.ts index fd1ae308e..1519dc7f2 100644 --- a/src/main/routes/index.ts +++ b/src/main/routes/index.ts @@ -292,7 +292,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -3203,15 +3202,6 @@ export async function dispatchDeepchatRoute( return sessionsSetPermissionModeRoute.output.parse({ updated: true }) } - case sessionsSetSubagentEnabledRoute.name: { - const input = sessionsSetSubagentEnabledRoute.input.parse(rawInput) - const session = await runtime.sessionAssignmentPort.setSessionSubagentEnabled( - input.sessionId, - input.enabled - ) - return sessionsSetSubagentEnabledRoute.output.parse({ session }) - } - case sessionsSetModelRoute.name: { const input = sessionsSetModelRoute.input.parse(rawInput) const session = await runtime.sessionAssignmentPort.setSessionModel( diff --git a/src/renderer/api/SessionClient.ts b/src/renderer/api/SessionClient.ts index ff028e851..a6186d9b9 100644 --- a/src/renderer/api/SessionClient.ts +++ b/src/renderer/api/SessionClient.ts @@ -56,7 +56,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -396,14 +395,6 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() await bridge.invoke(sessionsSetPermissionModeRoute.name, { sessionId, mode }) } - async function setSessionSubagentEnabled(sessionId: string, enabled: boolean) { - const result = await bridge.invoke(sessionsSetSubagentEnabledRoute.name, { - sessionId, - enabled - }) - return result.session - } - async function setSessionModel(sessionId: string, providerId: string, modelId: string) { const result = await bridge.invoke(sessionsSetModelRoute.name, { sessionId, @@ -593,7 +584,6 @@ export function createSessionClient(bridge: DeepchatBridge = getDeepchatBridge() setAcpSessionConfigOption, getPermissionMode, setPermissionMode, - setSessionSubagentEnabled, setSessionModel, setSessionProjectDir, getSessionGenerationSettings, diff --git a/src/renderer/settings/components/DeepChatAgentsSettings.vue b/src/renderer/settings/components/DeepChatAgentsSettings.vue index 671ac3e89..deecbe645 100644 --- a/src/renderer/settings/components/DeepChatAgentsSettings.vue +++ b/src/renderer/settings/components/DeepChatAgentsSettings.vue @@ -426,7 +426,7 @@ @@ -446,6 +446,7 @@ variant="ghost" size="sm" class="h-7 px-2 text-xs" + :disabled="form.subagentEnabled && form.subagents.length <= 1" @click="removeSubagentSlot(index)" > {{ t('common.delete') }} @@ -1371,8 +1372,14 @@ const addSubagentSlot = () => { description: '' }) } +const setSubagentEnabled = (enabled: boolean) => { + if (enabled && normalizeDeepChatSubagentSlots(form.subagents).length === 0) { + form.subagents = normalizeDeepChatSubagentSlots(createDefaultDeepChatSubagentSlots()) + } + form.subagentEnabled = enabled +} const removeSubagentSlot = (index: number) => { - if (!form.subagents[index]) { + if (!form.subagents[index] || (form.subagentEnabled && form.subagents.length <= 1)) { return } diff --git a/src/renderer/src/components/chat-input/McpIndicator.vue b/src/renderer/src/components/chat-input/McpIndicator.vue index f3f322bbd..0393fad95 100644 --- a/src/renderer/src/components/chat-input/McpIndicator.vue +++ b/src/renderer/src/components/chat-input/McpIndicator.vue @@ -293,18 +293,11 @@ import { useDraftStore } from '@/stores/ui/draft' import { useAgentStore } from '@/stores/ui/agent' import { useProjectStore } from '@/stores/ui/project' -type ToolGroupItem = - | { - kind: 'tool' - id: string - label: string - toolName: string - } - | { - kind: 'subagent' - id: 'subagent' - label: string - } +type ToolGroupItem = { + id: string + label: string + toolName: string +} type ToolGroup = { name: string @@ -332,25 +325,18 @@ const props = withDefaults( systemPromptOptions?: SystemPromptMenuOption[] selectedSystemPromptId?: string showCustomSystemPromptBadge?: boolean - showSubagentToggle?: boolean - subagentEnabled?: boolean - subagentTogglePending?: boolean }>(), { showSystemPromptSection: false, systemPromptOptions: () => [], selectedSystemPromptId: 'empty', - showCustomSystemPromptBadge: false, - showSubagentToggle: false, - subagentEnabled: false, - subagentTogglePending: false + showCustomSystemPromptBadge: false } ) const emit = defineEmits<{ (e: 'select-system-prompt', optionId: string): void (e: 'open-change', open: boolean): void - (e: 'toggle-subagents', enabled: boolean): void }>() const { t } = useI18n() @@ -475,7 +461,6 @@ const groupedAgentTools = computed(() => { for (const tool of agentTools.value) { const existing = groups.get(tool.server.name) ?? [] existing.push({ - kind: 'tool', id: tool.function.name, label: tool.function.name, toolName: tool.function.name @@ -483,16 +468,6 @@ const groupedAgentTools = computed(() => { groups.set(tool.server.name, existing) } - if (props.showSubagentToggle) { - const existing = groups.get('agent-core') ?? [] - existing.push({ - kind: 'subagent', - id: 'subagent', - label: t('chat.subagents.label') - }) - groups.set('agent-core', existing) - } - return Array.from(groups.entries()) .map(([name, items]) => ({ name, @@ -518,12 +493,9 @@ const groupedAgentTools = computed(() => { const isToolEnabled = (toolName: string) => !disabledToolNames.value.includes(toolName) const isToolPending = (toolName: string) => pendingToolNames.value.includes(toolName) -const isGroupItemEnabled = (item: ToolGroupItem) => - item.kind === 'subagent' ? props.subagentEnabled : isToolEnabled(item.toolName) -const isGroupItemPending = (item: ToolGroupItem) => - item.kind === 'subagent' ? props.subagentTogglePending : isToolPending(item.toolName) -const getGroupToolNames = (group: ToolGroup) => - group.items.flatMap((item) => (item.kind === 'tool' ? [item.toolName] : [])) +const isGroupItemEnabled = (item: ToolGroupItem) => isToolEnabled(item.toolName) +const isGroupItemPending = (item: ToolGroupItem) => isToolPending(item.toolName) +const getGroupToolNames = (group: ToolGroup) => group.items.map((item) => item.toolName) const isGroupEnabled = (group: ToolGroup) => group.items.some((item) => isGroupItemEnabled(item)) const isGroupPending = (group: ToolGroup) => group.items.some((item) => isGroupItemPending(item)) @@ -651,15 +623,6 @@ const toggleAgentTool = async (toolName: string) => { } const toggleGroupItem = async (item: ToolGroupItem) => { - if (item.kind === 'subagent') { - if (!isDeepchatContext.value || props.subagentTogglePending) { - return - } - - emit('toggle-subagents', !props.subagentEnabled) - return - } - await toggleAgentTool(item.toolName) } @@ -681,20 +644,11 @@ const setGroupEnabled = async (group: ToolGroup, enabled: boolean) => { const nextList = Array.from(nextDisabledTools).sort((left, right) => left.localeCompare(right)) const shouldUpdateTools = nextList.join('\n') !== disabledToolNames.value.join('\n') - const shouldUpdateSubagents = - group.items.some((item) => item.kind === 'subagent') && props.subagentEnabled !== enabled - - if (!shouldUpdateTools && !shouldUpdateSubagents) { + if (!shouldUpdateTools) { return } - if (shouldUpdateTools) { - await persistDisabledTools(nextList, groupToolNames) - } - - if (shouldUpdateSubagents) { - emit('toggle-subagents', enabled) - } + await persistDisabledTools(nextList, groupToolNames) } const handleSkillRuntimeChange = (payload: { diff --git a/src/renderer/src/components/chat/ChatStatusBar.vue b/src/renderer/src/components/chat/ChatStatusBar.vue index 40a26747c..5eda711e4 100644 --- a/src/renderer/src/components/chat/ChatStatusBar.vue +++ b/src/renderer/src/components/chat/ChatStatusBar.vue @@ -932,12 +932,8 @@ :system-prompt-options="systemPromptMenuOptions" :selected-system-prompt-id="selectedSystemPromptId" :show-custom-system-prompt-badge="selectedSystemPromptId === '__custom__'" - :show-subagent-toggle="showSubagentToggle" - :subagent-enabled="subagentEnabled" - :subagent-toggle-pending="isSubagentToggleUpdating" @select-system-prompt="onSystemPromptSelect" @open-change="handleSessionPanelOpenChange" - @toggle-subagents="onSubagentToggle" /> @@ -1143,7 +1139,6 @@ const { t } = useI18n() const draftModelSelection = ref(null) const permissionMode = ref('full_access') -const subagentEnabled = ref(false) const localSettings = ref(null) const loadedSettingsSelection = ref(null) const systemPromptList = ref([]) @@ -1170,7 +1165,6 @@ let generationPersistRequestToken = 0 let generationLocalRevision = 0 let unsubscribeAcpConfigOptionsReady: (() => void) | null = null let cancelAcpConfigSyncTask: (() => void) | null = null -const isSubagentToggleUpdating = ref(false) const { numericInputDrafts, @@ -1322,20 +1316,6 @@ const moonshotKimiTemperatureHint = computed(() => ) const canSelectPermissionMode = computed(() => !isAcpAgent.value) -const showSubagentToggle = computed(() => { - if (isAcpAgent.value) { - return false - } - - if (hasActiveSession.value) { - return ( - sessionStore.activeSession?.sessionKind === 'regular' && - inferAgentType(sessionStore.activeSession?.agentId) === 'deepchat' - ) - } - - return selectedAgentType.value === 'deepchat' -}) const providerNameMap = computed(() => { const map = new Map() @@ -2350,29 +2330,6 @@ watch( { immediate: true } ) -watch( - [ - () => sessionStore.activeSessionId, - showSubagentToggle, - () => sessionStore.activeSession?.subagentEnabled, - () => draftStore.subagentEnabled - ], - ([sessionId, canShow, activeEnabled, draftEnabled]) => { - if (!canShow) { - subagentEnabled.value = false - return - } - - if (sessionId) { - subagentEnabled.value = activeEnabled === true - return - } - - subagentEnabled.value = draftEnabled === true - }, - { immediate: true } -) - // Prefer revision/fingerprint deps (no deep watch). Generation settings already // self-coalesce via generationSyncQueued; ACP config uses deferred task cancel. watch( @@ -2999,34 +2956,10 @@ async function selectPermissionMode(mode: PermissionMode) { } } -async function onSubagentToggle(enabled: boolean) { - if (!showSubagentToggle.value || subagentEnabled.value === enabled) { - return - } - - subagentEnabled.value = enabled - const sessionId = sessionStore.activeSessionId - if (!sessionId) { - draftStore.subagentEnabled = enabled - return - } - - isSubagentToggleUpdating.value = true - try { - await sessionStore.setSessionSubagentEnabled(sessionId, enabled) - } catch (error) { - console.warn('[ChatStatusBar] Failed to set subagent toggle:', error) - subagentEnabled.value = sessionStore.activeSession?.subagentEnabled === true - } finally { - isSubagentToggleUpdating.value = false - } -} - defineExpose({ acpConfigState, localSettings, permissionMode, - subagentEnabled, showSystemPromptSection, showReasoningEffort, onTemperatureInput, diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 7cdf9ddef..578f651e5 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Behold seneste par", "deleteConfirm": "Slet agenten \"{name}\"? Dens historik falder tilbage til den indbyggede DeepChat-agent.", "subagentsTitle": "Underagenter", - "subagentsDescription": "Konfigurer de genanvendelige slots til undersessioner, som `subagent_orchestrator` stiller til rådighed.", - "subagentsEnabled": "Aktivér underagenter", + "subagentsDescription": "Tillad denne agent at uddelegere uafhængige delopgaver. Modellen afgør, hvornår konfigurerede pladser bruges; uddelegering bruger ekstra tokens, tid og systemressourcer.", + "subagentsEnabled": "Tillad denne agent at uddelegere delopgaver", "subagentTargetType": "Måltype", "subagentTargetSelf": "Denne agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 3d01d6e92..2adcf9231 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Alle aktivieren", "permissionDefault": "Standardberechtigung", "subagentsTitle": "subagent", - "subagentsDescription": "Wiederverwendbare subagent-Sitzungsplätze für `subagent_orchestrator` konfigurieren.", - "subagentsEnabled": "subagent aktivieren", + "subagentsDescription": "Erlaubt diesem Agenten, unabhängige Teilaufgaben zu delegieren. Das Modell entscheidet, wann konfigurierte Slots verwendet werden; Delegation benötigt zusätzliche Tokens, Zeit und Systemressourcen.", + "subagentsEnabled": "Diesem Agenten das Delegieren von Teilaufgaben erlauben", "subagentTargetType": "Zieltyp", "subagentTargetSelf": "Aktueller Agent", "subagentTargetAgent": "Bestimmter Agent", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 16ba32cf6..20719cc3c 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Full access", "permissionDefault": "Default permission", "subagentsTitle": "Subagents", - "subagentsDescription": "Configure the reusable child-session slots exposed to subagent_orchestrator.", - "subagentsEnabled": "Enable subagents", + "subagentsDescription": "Allow this Agent to delegate independent subtasks. The model decides when to use configured slots; delegation uses additional tokens, time, and system resources.", + "subagentsEnabled": "Allow this Agent to delegate subtasks", "subagentTargetType": "Target type", "subagentTargetSelf": "Self", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index e2b2e34d1..e45c40af7 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Acceso completo", "permissionDefault": "Permiso predeterminado", "subagentsTitle": "Subagents", - "subagentsDescription": "Configura los espacios de sesión secundaria reutilizables expuestos a subagent_orchestrator.", - "subagentsEnabled": "Habilitar subagents", + "subagentsDescription": "Permite que este agente delegue subtareas independientes. El modelo decide cuándo usar los espacios configurados; la delegación consume tokens, tiempo y recursos del sistema adicionales.", + "subagentsEnabled": "Permitir que este agente delegue subtareas", "subagentTargetType": "Tipo de destino", "subagentTargetSelf": "Este Agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 8ca6d156e..ddf1e419c 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "نگه‌داری جفت‌های اخیر", "deleteConfirm": "ایجنت «{name}» حذف شود؟ تاریخچه آن به ایجنت داخلی DeepChat برمی‌گردد.", "subagentsTitle": "زیرایجنت‌ها", - "subagentsDescription": "اسلات‌های قابل‌استفاده‌مجددِ نشست فرزند را که `subagent_orchestrator` در اختیار می‌گذارد پیکربندی کنید.", - "subagentsEnabled": "فعال‌کردن زیرایجنت‌ها", + "subagentsDescription": "به این عامل اجازه دهید زیروظیفه‌های مستقل را واگذار کند. مدل زمان استفاده از اسلات‌های پیکربندی‌شده را تعیین می‌کند؛ واگذاری توکن، زمان و منابع سیستمی بیشتری مصرف می‌کند.", + "subagentsEnabled": "اجازه به این عامل برای واگذاری زیروظیفه‌ها", "subagentTargetType": "نوع مقصد", "subagentTargetSelf": "همین ایجنت", "subagentTargetAgent": "ایجنت", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index b6ed25888..33d602aa7 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Conserver les dernières paires", "deleteConfirm": "Supprimer l'agent « {name} » ? Son historique sera rattaché à l'agent DeepChat intégré.", "subagentsTitle": "Sous-agents", - "subagentsDescription": "Configurez les emplacements de sessions enfants réutilisables exposés par `subagent_orchestrator`.", - "subagentsEnabled": "Activer les sous-agents", + "subagentsDescription": "Autorisez cet agent à déléguer des sous-tâches indépendantes. Le modèle décide quand utiliser les emplacements configurés ; la délégation consomme des jetons, du temps et des ressources système supplémentaires.", + "subagentsEnabled": "Autoriser cet agent à déléguer des sous-tâches", "subagentTargetType": "Type de cible", "subagentTargetSelf": "Cet agent", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index dc145bf19..1c84c76a6 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "שמירת הזוגות האחרונים", "deleteConfirm": "למחוק את הסוכן \"{name}\"? ההיסטוריה שלו תחזור לסוכן DeepChat המובנה.", "subagentsTitle": "סוכני משנה", - "subagentsDescription": "הגדירו את משבצות שיחות-הבת הניתנות לשימוש חוזר ש-`subagent_orchestrator` חושף.", - "subagentsEnabled": "הפעלת סוכני משנה", + "subagentsDescription": "אפשרו לסוכן הזה להאציל תת־משימות עצמאיות. המודל מחליט מתי להשתמש במשבצות שהוגדרו; ההאצלה צורכת טוקנים, זמן ומשאבי מערכת נוספים.", + "subagentsEnabled": "לאפשר לסוכן הזה להאציל תת־משימות", "subagentTargetType": "סוג יעד", "subagentTargetSelf": "הסוכן הזה", "subagentTargetAgent": "סוכן", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 1557b0f6e..8497c6492 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Aktifkan semua", "permissionDefault": "Izin bawaan", "subagentsTitle": "subagent", - "subagentsDescription": "Konfigurasikan slot sesi `subagent_orchestrator` yang dapat digunakan kembali.", - "subagentsEnabled": "Aktifkan subagent", + "subagentsDescription": "Izinkan Agent ini mendelegasikan subtugas independen. Model menentukan kapan slot terkonfigurasi digunakan; delegasi memakai token, waktu, dan sumber daya sistem tambahan.", + "subagentsEnabled": "Izinkan Agent ini mendelegasikan subtugas", "subagentTargetType": "tipe sasaran", "subagentTargetSelf": "Saat iniAgent", "subagentTargetAgent": "Tentukan Agent", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 517b6bc70..cbc47ac7f 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Tutto attivo", "permissionDefault": "Permesso predefinito", "subagentsTitle": "subagent", - "subagentsDescription": "Configura gli slot di sessione subagent riutilizzabili da `subagent_orchestrator`.", - "subagentsEnabled": "Attiva subagent", + "subagentsDescription": "Consenti a questo agente di delegare sottoattività indipendenti. Il modello decide quando usare gli slot configurati; la delega richiede token, tempo e risorse di sistema aggiuntivi.", + "subagentsEnabled": "Consenti a questo agente di delegare sottoattività", "subagentTargetType": "Tipo target", "subagentTargetSelf": "Agent corrente", "subagentTargetAgent": "Agent specifico", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index e2027df1b..9dd5a970c 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保持する直近ペア数", "deleteConfirm": "エージェント「{name}」を削除しますか? その履歴は内蔵 DeepChat エージェントに戻されます。", "subagentsTitle": "サブエージェント", - "subagentsDescription": "`subagent_orchestrator` で使える再利用可能な子セッションスロットを設定します。", - "subagentsEnabled": "サブエージェントを有効にする", + "subagentsDescription": "このエージェントに独立したサブタスクの委任を許可します。モデルが設定済みスロットを使うタイミングを判断し、委任には追加のトークン、時間、システムリソースが必要です。", + "subagentsEnabled": "このエージェントにサブタスクの委任を許可", "subagentTargetType": "対象タイプ", "subagentTargetSelf": "このエージェント", "subagentTargetAgent": "エージェント", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 4bce16be5..62e9ecfa3 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "최근 페어 유지", "deleteConfirm": "에이전트 \"{name}\"을(를) 삭제할까요? 기록은 내장 DeepChat 에이전트로 이동합니다.", "subagentsTitle": "하위 에이전트", - "subagentsDescription": "`subagent_orchestrator` 에서 노출하는 재사용 가능한 하위 세션 슬롯을 설정합니다.", - "subagentsEnabled": "하위 에이전트 사용", + "subagentsDescription": "이 에이전트가 독립적인 하위 작업을 위임하도록 허용합니다. 모델이 구성된 슬롯을 사용할 시점을 판단하며, 위임에는 추가 토큰, 시간 및 시스템 리소스가 소요됩니다.", + "subagentsEnabled": "이 에이전트의 하위 작업 위임 허용", "subagentTargetType": "대상 유형", "subagentTargetSelf": "현재 에이전트", "subagentTargetAgent": "에이전트", diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index d92dd0d15..a44d2cf4c 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Dayakan semua", "permissionDefault": "Keizinan lalai", "subagentsTitle": "subagent", - "subagentsDescription": "Konfigurasikan slot sesi `subagent_orchestrator` boleh guna semula subagent.", - "subagentsEnabled": "Dayakan subagent", + "subagentsDescription": "Benarkan Agent ini mewakilkan subtugas bebas. Model menentukan masa slot yang dikonfigurasi digunakan; pewakilan menggunakan token, masa dan sumber sistem tambahan.", + "subagentsEnabled": "Benarkan Agent ini mewakilkan subtugas", "subagentTargetType": "jenis sasaran", "subagentTargetSelf": "ejen semasa", "subagentTargetAgent": "Nyatakan ejen", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index af029e381..1b9b02cd8 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Pełny dostęp", "permissionDefault": "Domyślne uprawnienie", "subagentsTitle": "Subagenci", - "subagentsDescription": "Skonfiguruj gniazda sesji podrzędnych wielokrotnego użytku udostępnione subagent_orchestrator.", - "subagentsEnabled": "Włącz subagent", + "subagentsDescription": "Zezwól temu agentowi na delegowanie niezależnych podzadań. Model decyduje, kiedy użyć skonfigurowanych slotów; delegowanie zużywa dodatkowe tokeny, czas i zasoby systemowe.", + "subagentsEnabled": "Zezwól temu agentowi na delegowanie podzadań", "subagentTargetType": "Typ celu", "subagentTargetSelf": "Ja", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index af3d87a73..0d1d62e47 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Manter pares recentes", "deleteConfirm": "Excluir o agente \"{name}\"? O histórico dele voltará para o agente DeepChat integrado.", "subagentsTitle": "Subagentes", - "subagentsDescription": "Configure os slots reutilizáveis de sessões filhas expostos pelo `subagent_orchestrator`.", - "subagentsEnabled": "Ativar subagentes", + "subagentsDescription": "Permita que este agente delegue subtarefas independentes. O modelo decide quando usar os slots configurados; a delegação consome tokens, tempo e recursos do sistema adicionais.", + "subagentsEnabled": "Permitir que este agente delegue subtarefas", "subagentTargetType": "Tipo de destino", "subagentTargetSelf": "Este agente", "subagentTargetAgent": "Agente", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 47648c1a7..bc08803b5 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "Сохранять последние пары", "deleteConfirm": "Удалить агента «{name}»? Его история будет возвращена встроенному агенту DeepChat.", "subagentsTitle": "Субагенты", - "subagentsDescription": "Настройте переиспользуемые слоты дочерних сессий, доступные через `subagent_orchestrator`.", - "subagentsEnabled": "Включить субагентов", + "subagentsDescription": "Разрешите этому агенту делегировать независимые подзадачи. Модель сама решает, когда использовать настроенные слоты; делегирование требует дополнительных токенов, времени и системных ресурсов.", + "subagentsEnabled": "Разрешить этому агенту делегировать подзадачи", "subagentTargetType": "Тип цели", "subagentTargetSelf": "Этот агент", "subagentTargetAgent": "Агент", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 97159631d..071561bcb 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Tam erişim", "permissionDefault": "Varsayılan izin", "subagentsTitle": "Alt temsilciler", - "subagentsDescription": "Subagent_orchestrator'a sunulan yeniden kullanılabilir alt oturum yuvalarını yapılandırın.", - "subagentsEnabled": "subagents'yi etkinleştir", + "subagentsDescription": "Bu Agent'ın bağımsız alt görevleri devretmesine izin verin. Model, yapılandırılmış yuvaları ne zaman kullanacağına karar verir; devretme ek token, zaman ve sistem kaynağı kullanır.", + "subagentsEnabled": "Bu Agent'ın alt görevleri devretmesine izin ver", "subagentTargetType": "Hedef türü", "subagentTargetSelf": "öz", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index c2d4eda31..7a5c4054b 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "Toàn quyền truy cập", "permissionDefault": "Quyền mặc định", "subagentsTitle": "Chất phụ", - "subagentsDescription": "Định cấu hình các vị trí phiên con có thể tái sử dụng được hiển thị cho subagent_orchestrator.", - "subagentsEnabled": "Kích hoạt subagent", + "subagentsDescription": "Cho phép Agent này ủy quyền các tác vụ con độc lập. Mô hình quyết định khi nào dùng các vị trí đã cấu hình; việc ủy quyền tiêu tốn thêm token, thời gian và tài nguyên hệ thống.", + "subagentsEnabled": "Cho phép Agent này ủy quyền tác vụ con", "subagentTargetType": "Loại mục tiêu", "subagentTargetSelf": "bản thân", "subagentTargetAgent": "Agent", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index dcfc4bf6e..01627c650 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -130,8 +130,8 @@ "permissionFullAccess": "全部启用", "permissionDefault": "默认权限", "subagentsTitle": "subagent", - "subagentsDescription": "配置 `subagent_orchestrator` 可复用的 subagent 会话槽位。", - "subagentsEnabled": "启用 subagent", + "subagentsDescription": "允许该智能体委派独立子任务。模型会自行判断何时使用已配置槽位;委派会消耗额外的 token、时间和系统资源。", + "subagentsEnabled": "允许该智能体委派子任务", "subagentTargetType": "目标类型", "subagentTargetSelf": "当前智能体", "subagentTargetAgent": "指定智能体", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index ba2fac92b..95be0e40c 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保留最近訊息對", "deleteConfirm": "確定要刪除智能體「{name}」嗎?它的歷史會話會自動回退到內建 DeepChat 智能體。", "subagentsTitle": "子代理", - "subagentsDescription": "設定 `subagent_orchestrator` 可重用的 subagent 會話槽位。", - "subagentsEnabled": "啟用 subagent", + "subagentsDescription": "允許此智能體委派獨立子任務。模型會自行判斷何時使用已設定的槽位;委派會消耗額外的 token、時間與系統資源。", + "subagentsEnabled": "允許此智能體委派子任務", "subagentTargetType": "目標類型", "subagentTargetSelf": "目前智能體", "subagentTargetAgent": "指定智能體", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 0f1af3c8b..7e83d3c10 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2546,8 +2546,8 @@ "compactionRetainPairs": "保留最近訊息對", "deleteConfirm": "確定要刪除智能體「{name}」嗎?它的歷史會話會自動回退到內建 DeepChat 智能體。", "subagentsTitle": "subagent", - "subagentsDescription": "設定 `subagent_orchestrator` 可重用的 subagent 會話槽位。", - "subagentsEnabled": "啟用 subagent", + "subagentsDescription": "允許此智慧體委派獨立子任務。模型會自行判斷何時使用已設定的槽位;委派會消耗額外的 token、時間與系統資源。", + "subagentsEnabled": "允許此智慧體委派子任務", "subagentTargetType": "目標類型", "subagentTargetSelf": "目前智能體", "subagentTargetAgent": "指定智能體", diff --git a/src/renderer/src/pages/NewThreadPage.vue b/src/renderer/src/pages/NewThreadPage.vue index 2a76e50a3..960932497 100644 --- a/src/renderer/src/pages/NewThreadPage.vue +++ b/src/renderer/src/pages/NewThreadPage.vue @@ -838,7 +838,6 @@ async function submitText(text: string, files: MessageFile[]) { const isAcp = isAcpSelectedAgent.value const draftPermissionMode = draftStore.permissionMode const draftDisabledAgentTools = [...draftStore.disabledAgentTools] - const draftSubagentEnabled = draftStore.subagentEnabled const draftGenerationSettings = draftStore.toGenerationSettings() try { @@ -889,7 +888,6 @@ async function submitText(text: string, files: MessageFile[]) { modelId, permissionMode: draftPermissionMode, disabledAgentTools: isAcp ? undefined : draftDisabledAgentTools, - subagentEnabled: isAcp ? false : draftSubagentEnabled, generationSettings: draftGenerationSettings, activeSkills: messagePayload.activeSkills }) @@ -943,7 +941,6 @@ const applyDraftDefaultsForSelectedAgent = async (requestSeq: number): Promise { ) const permissionMode = ref('full_access') const disabledAgentTools = ref([...DEFAULT_DISABLED_AGENT_TOOLS]) - const subagentEnabled = ref(false) const pendingStartDeeplink = ref(null) let nextStartToken = 0 @@ -104,7 +103,6 @@ export const useDraftStore = defineStore('draft', () => { modelId: modelId.value, permissionMode: permissionMode.value, disabledAgentTools: [...disabledAgentTools.value], - subagentEnabled: subagentEnabled.value, generationSettings: toGenerationSettings() } } @@ -174,7 +172,6 @@ export const useDraftStore = defineStore('draft', () => { agentId.value = 'deepchat' permissionMode.value = 'full_access' disabledAgentTools.value = [...DEFAULT_DISABLED_AGENT_TOOLS] - subagentEnabled.value = false resetGenerationSettings() } @@ -213,7 +210,6 @@ export const useDraftStore = defineStore('draft', () => { videoGeneration, permissionMode, disabledAgentTools, - subagentEnabled, pendingStartDeeplink, toGenerationSettings, toCreateInput, diff --git a/src/renderer/src/stores/ui/session.ts b/src/renderer/src/stores/ui/session.ts index 4f76a08b9..1356a6423 100644 --- a/src/renderer/src/stores/ui/session.ts +++ b/src/renderer/src/stores/ui/session.ts @@ -40,7 +40,6 @@ export interface UISession { isDraft: boolean sessionKind: SessionKind parentSessionId: string | null - subagentEnabled: boolean subagentMeta: DeepChatSubagentMeta | null metadata?: SessionMetadata | null createdAt: number @@ -107,7 +106,6 @@ function mapToUISession(session: SessionListItem | SessionWithState): UISession isDraft: Boolean(session.isDraft), sessionKind: session.sessionKind, parentSessionId: session.parentSessionId ?? null, - subagentEnabled: session.subagentEnabled, subagentMeta: session.subagentMeta ?? null, ...(metadata ? { metadata } : {}), createdAt: session.createdAt, @@ -870,20 +868,6 @@ export const useSessionStore = defineStore('session', () => { } } - async function setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { - error.value = null - try { - const updated = await sessionClient.setSessionSubagentEnabled(sessionId, enabled) - upsertSessions([mapToUISession(updated)]) - if (activeSessionId.value === sessionId) { - applyRestoredSession(updated) - } - } catch (updateError) { - error.value = `Failed to update subagent state: ${updateError}` - throw updateError - } - } - async function setSessionProjectDir(sessionId: string, projectDir: string | null): Promise { error.value = null try { @@ -1125,7 +1109,6 @@ export const useSessionStore = defineStore('session', () => { clearSessionMessages, exportSession, deleteSession, - setSessionSubagentEnabled, setSessionProjectDir, moveSessionToAgent, toggleGroupMode, diff --git a/src/shared/agentTools.ts b/src/shared/agentTools.ts index 0de94fb6b..1ff3317c8 100644 --- a/src/shared/agentTools.ts +++ b/src/shared/agentTools.ts @@ -1,4 +1,5 @@ export const CRON_JOB_AGENT_TOOL_NAME = 'cronjob' +export const SUBAGENT_ORCHESTRATOR_TOOL_NAME = 'subagent_orchestrator' export const DEFAULT_DISABLED_AGENT_TOOLS = [CRON_JOB_AGENT_TOOL_NAME] as const export const TAPE_TOOL_NAMES = Object.freeze({ @@ -13,21 +14,23 @@ export type TapeToolName = (typeof TAPE_TOOL_NAMES)[keyof typeof TAPE_TOOL_NAMES export type AgentToolExposure = 'user-configurable' | 'system-model' | 'diagnostic' | 'runtime-only' -const AGENT_TOOL_EXPOSURE_BY_NAME: Readonly> = - Object.freeze({ - [TAPE_TOOL_NAMES.search]: 'system-model', - [TAPE_TOOL_NAMES.context]: 'system-model', - [TAPE_TOOL_NAMES.info]: 'diagnostic', - [TAPE_TOOL_NAMES.anchors]: 'diagnostic', - [TAPE_TOOL_NAMES.handoff]: 'runtime-only' - }) +const AGENT_TOOL_EXPOSURE_BY_NAME: Readonly> = Object.freeze({ + [TAPE_TOOL_NAMES.search]: 'system-model', + [TAPE_TOOL_NAMES.context]: 'system-model', + [TAPE_TOOL_NAMES.info]: 'diagnostic', + [TAPE_TOOL_NAMES.anchors]: 'diagnostic', + [TAPE_TOOL_NAMES.handoff]: 'runtime-only', + [SUBAGENT_ORCHESTRATOR_TOOL_NAME]: 'system-model' +}) + +const TAPE_TOOL_NAME_SET = new Set(Object.values(TAPE_TOOL_NAMES)) export const isTapeToolName = (toolName: string): toolName is TapeToolName => - Object.prototype.hasOwnProperty.call(AGENT_TOOL_EXPOSURE_BY_NAME, toolName) + TAPE_TOOL_NAME_SET.has(toolName) export const getAgentToolExposure = (toolName: string): AgentToolExposure => - isTapeToolName(toolName) - ? AGENT_TOOL_EXPOSURE_BY_NAME[toolName as TapeToolName] + Object.prototype.hasOwnProperty.call(AGENT_TOOL_EXPOSURE_BY_NAME, toolName) + ? AGENT_TOOL_EXPOSURE_BY_NAME[toolName] : 'user-configurable' export const assertAgentToolExposure = ( diff --git a/src/shared/contracts/common.ts b/src/shared/contracts/common.ts index 4ba1d9322..4d5f99d8c 100644 --- a/src/shared/contracts/common.ts +++ b/src/shared/contracts/common.ts @@ -233,7 +233,6 @@ export const SessionWithStateSchema = z.object({ isDraft: z.boolean().optional(), sessionKind: SessionKindSchema, parentSessionId: EntityIdSchema.nullable().optional(), - subagentEnabled: z.boolean(), subagentMeta: DeepChatSubagentMetaSchema.optional(), createdAt: TimestampMsSchema, updatedAt: TimestampMsSchema, diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 7472ac1ec..80514baab 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -389,7 +389,6 @@ import { sessionsSetModelRoute, sessionsSetPermissionModeRoute, sessionsSetProjectDirRoute, - sessionsSetSubagentEnabledRoute, sessionsSteerPendingInputRoute, sessionsTogglePinnedRoute, sessionsTranslateTextRoute, @@ -820,7 +819,6 @@ const DEEPCHAT_ROUTE_CATALOG_PART_4 = { [sessionsSetAcpSessionConfigOptionRoute.name]: sessionsSetAcpSessionConfigOptionRoute, [sessionsGetPermissionModeRoute.name]: sessionsGetPermissionModeRoute, [sessionsSetPermissionModeRoute.name]: sessionsSetPermissionModeRoute, - [sessionsSetSubagentEnabledRoute.name]: sessionsSetSubagentEnabledRoute, [sessionsSetModelRoute.name]: sessionsSetModelRoute, [sessionsSetProjectDirRoute.name]: sessionsSetProjectDirRoute, [sessionsGetGenerationSettingsRoute.name]: sessionsGetGenerationSettingsRoute, diff --git a/src/shared/contracts/routes/sessions.routes.ts b/src/shared/contracts/routes/sessions.routes.ts index 54916483d..513d6cb79 100644 --- a/src/shared/contracts/routes/sessions.routes.ts +++ b/src/shared/contracts/routes/sessions.routes.ts @@ -94,7 +94,6 @@ export const CreateSessionInputSchema = z.object({ permissionMode: PermissionModeSchema.optional(), activeSkills: z.array(z.string()).optional(), disabledAgentTools: z.array(z.string()).optional(), - subagentEnabled: z.boolean().optional(), generationSettings: SessionGenerationSettingsPatchSchema.optional() }) @@ -602,17 +601,6 @@ export const sessionsSetPermissionModeRoute = defineRouteContract({ }) }) -export const sessionsSetSubagentEnabledRoute = defineRouteContract({ - name: 'sessions.setSubagentEnabled', - input: z.object({ - sessionId: EntityIdSchema, - enabled: z.boolean() - }), - output: z.object({ - session: SessionWithStateSchema - }) -}) - export const sessionsSetModelRoute = defineRouteContract({ name: 'sessions.setModel', input: z.object({ diff --git a/src/shared/lib/deepchatSubagents.ts b/src/shared/lib/deepchatSubagents.ts index 7a5465b79..a10f85366 100644 --- a/src/shared/lib/deepchatSubagents.ts +++ b/src/shared/lib/deepchatSubagents.ts @@ -1,4 +1,10 @@ -import type { DeepChatAgentConfig, DeepChatSubagentSlot } from '@shared/types/agent-interface' +import type { + AgentType, + DeepChatAgentConfig, + DeepChatSubagentCapability, + DeepChatSubagentSlot, + SessionKind +} from '@shared/types/agent-interface' export const DEEPCHAT_SUBAGENT_SLOT_LIMIT = 5 export const DEEPCHAT_SELF_SUBAGENT_SLOT_ID = 'self' @@ -6,6 +12,24 @@ export const DEEPCHAT_EXPLORER_SUBAGENT_SLOT_ID = 'explorer' export const DEEPCHAT_IMPLEMENTER_SUBAGENT_SLOT_ID = 'implementer' export const DEEPCHAT_REVIEWER_SUBAGENT_SLOT_ID = 'reviewer' +export type { DeepChatSubagentCapability } from '@shared/types/agent-interface' + +export const DEEPCHAT_SUBAGENT_MODEL_GUIDANCE = [ + 'Honor explicit user requests about Subagents: use them when requested and available, and never use them for a request that asks you not to.', + 'For proactive delegation, choose only work with clear independent, isolated, or parallel benefit.', + 'Do not proactively delegate simple, latency-sensitive, or strongly sequential tasks.', + 'Do not run write-heavy Subagents in parallel when their files may overlap.', + 'Use bounded task prompts and require concrete evidence or validation from each child.', + 'Delegation adds token usage, latency, and system resource cost.' +].join(' ') + +export interface ResolveDeepChatSubagentCapabilityInput { + agentType: AgentType | null + sessionKind: SessionKind | null | undefined + agentPolicyEnabled: boolean + slots?: DeepChatSubagentSlot[] | null +} + export const createDefaultDeepChatSelfSubagentSlot = (): DeepChatSubagentSlot => ({ id: DEEPCHAT_SELF_SUBAGENT_SLOT_ID, targetType: 'self', @@ -117,6 +141,40 @@ export const normalizeDeepChatSubagentSlots = ( return normalized } +const compareSubagentSlots = (left: DeepChatSubagentSlot, right: DeepChatSubagentSlot): number => + left.id.localeCompare(right.id) || + left.targetType.localeCompare(right.targetType) || + (left.targetAgentId ?? '').localeCompare(right.targetAgentId ?? '') || + left.displayName.localeCompare(right.displayName) || + left.description.localeCompare(right.description) + +const createUnavailableSubagentCapability = ( + reason: Extract['reason'] +): DeepChatSubagentCapability => { + const cacheKey = JSON.stringify({ available: false, reason }) + return { available: false, reason, cacheKey } +} + +export const resolveDeepChatSubagentCapability = ( + input: ResolveDeepChatSubagentCapabilityInput +): DeepChatSubagentCapability => { + if (input.agentType !== 'deepchat' || input.sessionKind !== 'regular') { + return createUnavailableSubagentCapability('unsupported_session') + } + + if (input.agentPolicyEnabled === false) { + return createUnavailableSubagentCapability('policy_disabled') + } + + const slots = normalizeDeepChatSubagentSlots(input.slots).sort(compareSubagentSlots) + if (slots.length === 0) { + return createUnavailableSubagentCapability('no_valid_slots') + } + + const cacheKey = JSON.stringify({ available: true, slots }) + return { available: true, slots, cacheKey } +} + export const normalizeDeepChatSubagentConfig = ( config?: DeepChatAgentConfig | null ): DeepChatAgentConfig => { @@ -130,3 +188,10 @@ export const normalizeDeepChatSubagentConfig = ( : createDefaultDeepChatSubagentSlots() } } + +export const assertDeepChatSubagentConfigInvariant = (config: DeepChatAgentConfig): void => { + const normalized = normalizeDeepChatSubagentConfig(config) + if (normalized.subagentEnabled !== false && (normalized.subagents?.length ?? 0) === 0) { + throw new Error('Enabled DeepChat Subagents require at least one valid slot.') + } +} diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index 0c651b817..c65f5e666 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -546,6 +546,18 @@ export interface DeepChatSubagentSlot { description: string } +export type DeepChatSubagentCapability = + | { + available: true + slots: DeepChatSubagentSlot[] + cacheKey: string + } + | { + available: false + reason: 'policy_disabled' | 'unsupported_session' | 'no_valid_slots' + cacheKey: string + } + export type SessionKind = 'regular' | 'subagent' export interface DeepChatSubagentMeta { @@ -660,7 +672,6 @@ export interface SessionRecord { isDraft?: boolean sessionKind: SessionKind parentSessionId?: string | null - subagentEnabled: boolean subagentMeta?: DeepChatSubagentMeta | null createdAt: number updatedAt: number @@ -755,7 +766,6 @@ export interface CreateSessionInput { permissionMode?: PermissionMode activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean generationSettings?: Partial } @@ -768,7 +778,6 @@ export interface CreateDetachedSessionInput { permissionMode?: PermissionMode activeSkills?: string[] disabledAgentTools?: string[] - subagentEnabled?: boolean generationSettings?: Partial metadata?: SessionMetadata | null } diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts index d76d462ee..8a718c9cf 100644 --- a/src/shared/types/presenters/tool.presenter.d.ts +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -4,7 +4,7 @@ */ import type { MCPToolDefinition, MCPToolCall, MCPToolResponse } from '../core/mcp' -import type { PermissionMode } from '../agent-interface' +import type { DeepChatSubagentCapability, PermissionMode } from '../agent-interface' import type { AgentPlanSnapshot } from '../agent-plan' export type AgentToolProgressUpdate = @@ -30,6 +30,7 @@ export interface ToolDefinitionContext { agentWorkspacePath?: string | null conversationId?: string activeSkillNames?: string[] + subagentCapability?: DeepChatSubagentCapability } export interface ToolCallOptions { diff --git a/test/main/agent/deepchat/deepChatAgentRepository.test.ts b/test/main/agent/deepchat/deepChatAgentRepository.test.ts index 8354bcbf5..d8c2ce1ec 100644 --- a/test/main/agent/deepchat/deepChatAgentRepository.test.ts +++ b/test/main/agent/deepchat/deepChatAgentRepository.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { DeepChatAgentRepository } from '@/agent/deepchat/deepChatAgentRepository' -import { TAPE_TOOL_NAMES } from '@shared/agentTools' +import { SUBAGENT_ORCHESTRATOR_TOOL_NAME, TAPE_TOOL_NAMES } from '@shared/agentTools' function createRepository(sqlitePresenter: any): DeepChatAgentRepository { return new DeepChatAgentRepository({ @@ -17,6 +17,52 @@ function createRepository(sqlitePresenter: any): DeepChatAgentRepository { }) } +function createMutableRepository(initialRows: any[] = []) { + const rows = new Map(initialRows.map((row) => [row.id, row])) + const agentsTable = { + get: (id: string) => rows.get(id), + list: () => [...rows.values()], + create: (input: any) => { + const now = Date.now() + rows.set(input.id, { + id: input.id, + agent_type: input.agentType, + source: input.source, + name: input.name, + enabled: input.enabled ? 1 : 0, + protected: input.protected ? 1 : 0, + description: input.description ?? null, + icon: input.icon ?? null, + avatar_json: input.avatarJson ?? null, + config_json: input.configJson ?? null, + state_json: null, + created_at: now, + updated_at: Date.now() + }) + }, + update: (id: string, input: any) => { + const current = rows.get(id) + if (!current) return + rows.set(id, { + ...current, + name: input.name ?? current.name, + enabled: input.enabled === undefined ? current.enabled : input.enabled ? 1 : 0, + description: input.description === undefined ? current.description : input.description, + icon: input.icon === undefined ? current.icon : input.icon, + avatar_json: input.avatarJson === undefined ? current.avatar_json : input.avatarJson, + config_json: input.configJson === undefined ? current.config_json : input.configJson, + updated_at: Date.now() + }) + }, + delete: (id: string) => rows.delete(id) + } + + return { + repository: createRepository({ agentsTable }), + rows + } +} + describe('DeepChatAgentRepository', () => { it('deletes DeepChat agent memory rows and the agent row in one transaction', () => { const agents = new Map([ @@ -328,6 +374,143 @@ describe('DeepChatAgentRepository', () => { expect(config.subagents?.every((slot) => slot.targetType === 'self')).toBe(true) }) + it('keeps configless custom Agents on their own default Subagent policy', () => { + const { repository } = createMutableRepository() + repository.ensureBuiltin({ + config: { + systemPrompt: 'Builtin prompt', + subagentEnabled: false, + subagents: [ + { + id: 'builtin-reviewer', + targetType: 'self', + displayName: 'Builtin Reviewer', + description: '' + } + ] + } + }) + const created = repository.create({ name: 'Configless' }) + + expect(repository.getConfig(created.id)).toBeNull() + expect(repository.resolveConfig(created.id)).toMatchObject({ + systemPrompt: 'Builtin prompt', + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ] + }) + expect(repository.listResolvedConfigs()).toContainEqual({ + agentId: created.id, + config: expect.objectContaining({ + systemPrompt: 'Builtin prompt', + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ] + }) + }) + }) + + it('fails closed when a stored custom Agent config is unreadable', () => { + const { repository, rows } = createMutableRepository() + repository.ensureBuiltin({ config: { subagentEnabled: false } }) + const created = repository.create({ name: 'Unreadable' }) + rows.get(created.id).config_json = '{broken' + + expect(repository.getConfig(created.id)).toBeNull() + expect(repository.resolveConfig(created.id)).toMatchObject({ + subagentEnabled: true, + subagents: [] + }) + expect(repository.listResolvedConfigs()).toContainEqual({ + agentId: created.id, + config: expect.objectContaining({ subagentEnabled: true, subagents: [] }) + }) + }) + + it('rejects enabled DeepChat Agent writes without a valid Subagent slot', () => { + const { repository, rows } = createMutableRepository() + + expect(() => + repository.create({ + name: 'Invalid Writer', + config: { subagentEnabled: true, subagents: [] } + }) + ).toThrow('Enabled DeepChat Subagents require at least one valid slot.') + expect(rows.size).toBe(0) + + const created = repository.create({ + name: 'Writer', + config: { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + } + }) + const previousConfig = rows.get(created.id)?.config_json + + expect(() => + repository.update(created.id, { + config: { + subagents: [ + { + id: 'invalid-target', + targetType: 'agent', + targetAgentId: ' ', + displayName: 'Invalid', + description: '' + } + ] + } + }) + ).toThrow('Enabled DeepChat Subagents require at least one valid slot.') + expect(rows.get(created.id)?.config_json).toBe(previousConfig) + }) + + it('allows disabled DeepChat Agents to retain empty or configured Subagent slots', () => { + const { repository } = createMutableRepository() + const disabledEmpty = repository.create({ + name: 'Disabled Empty', + config: { subagentEnabled: false, subagents: [] } + }) + const configured = repository.create({ + name: 'Configured', + config: { + subagentEnabled: true, + subagents: [ + { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: '' + } + ] + } + }) + + repository.update(configured.id, { config: { subagentEnabled: false } }) + + expect(repository.getConfig(disabledEmpty.id)).toMatchObject({ + subagentEnabled: false, + subagents: [] + }) + expect(repository.getConfig(configured.id)).toMatchObject({ + subagentEnabled: false, + subagents: [expect.objectContaining({ id: 'explorer' })] + }) + }) + it('keeps non-configurable Tape names out of persisted and resolved Agent configs', () => { const rows = new Map() const agentsTable = { @@ -362,11 +545,15 @@ describe('DeepChatAgentRepository', () => { const repository = createRepository({ agentsTable }) repository.ensureBuiltin({ - config: { disabledAgentTools: [TAPE_TOOL_NAMES.search, 'read'] } + config: { + disabledAgentTools: [TAPE_TOOL_NAMES.search, SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'read'] + } }) const created = repository.create({ name: 'Writer', - config: { disabledAgentTools: [TAPE_TOOL_NAMES.handoff, 'exec'] } + config: { + disabledAgentTools: [TAPE_TOOL_NAMES.handoff, SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'exec'] + } }) expect(repository.getConfig('deepchat')?.disabledAgentTools).toEqual(['read']) diff --git a/test/main/agent/shared/appSessionService.test.ts b/test/main/agent/shared/appSessionService.test.ts index 6769954e0..556eeb0ec 100644 --- a/test/main/agent/shared/appSessionService.test.ts +++ b/test/main/agent/shared/appSessionService.test.ts @@ -55,7 +55,6 @@ describe('AppSessionService', () => { { isDraft: undefined, disabledAgentTools: undefined, - subagentEnabled: undefined, sessionKind: undefined, parentSessionId: undefined, subagentMetaJson: null @@ -103,6 +102,7 @@ describe('AppSessionService', () => { project_dir: '/tmp/proj', is_pinned: 1, is_draft: 0, + subagent_enabled: 1, created_at: 1000, updated_at: 2000 }) @@ -118,11 +118,11 @@ describe('AppSessionService', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1000, updatedAt: 2000 }) + expect(record).not.toHaveProperty('subagentEnabled') }) it('returns stored metadata when present', () => { diff --git a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts index bb56f43e4..d822be425 100644 --- a/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts +++ b/test/main/presenter/agentRuntimePresenter/agentRuntimePresenter.test.ts @@ -6,6 +6,7 @@ import { app } from 'electron' import type { AssistantMessageBlock, ChatMessageRecord, + DeepChatAgentConfig, DeepChatSessionState } from '@shared/types/agent-interface' import { ApiEndpointType, ModelType } from '@shared/model' @@ -36,6 +37,7 @@ import type { AcpAgentDescriptor } from '@/agent/shared/agentDescriptors' import type { AcpAgentConfig } from '@shared/presenter' import type * as schema from '@agentclientprotocol/sdk/dist/schema/index.js' import { nanoid } from 'nanoid' +import { SubagentOrchestratorTool } from '@/presenter/toolPresenter/agentTools/subagentOrchestratorTool' vi.mock('nanoid', () => ({ nanoid: vi.fn(() => 'mock-msg-id') })) @@ -627,6 +629,7 @@ function createMockConfigPresenter() { apiType: providerApiTypes[providerId] ?? 'openai-compatible' })), isKnownModel: vi.fn().mockReturnValue(true), + getAgentType: vi.fn().mockResolvedValue('deepchat'), resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({}), agentSupportsCapability: vi.fn().mockResolvedValue(true) } as any @@ -4093,6 +4096,70 @@ describe('AgentRuntimePresenter', () => { expect(callArgs.run.resources.toolDefinitions).toEqual(tools) }) + it('refreshes provider Subagent tool snapshots from Agent policy between turns', async () => { + let subagentConfig: DeepChatAgentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the change.' + } + ] + } + const subagentTool = new SubagentOrchestratorTool({} as any) + + sqlitePresenter.newSessionsTable.get.mockReturnValue({ + id: 's1', + agent_id: 'deepchat', + session_kind: 'regular' + }) + configPresenter.resolveDeepChatAgentConfig.mockImplementation(async () => subagentConfig) + toolPresenter.getAllToolDefinitions.mockImplementation(async (context: any) => { + const definition = subagentTool.getToolDefinition(context.subagentCapability) + return definition ? [definition] : [] + }) + + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + await agent.processMessage('s1', 'First turn') + + const firstTools = (processStream as ReturnType).mock.calls[0][0].run.resources + .toolDefinitions + expect(firstTools.map((tool: any) => tool.function.name)).toEqual(['subagent_orchestrator']) + expect( + (firstTools[0].function.parameters as any).properties.tasks.items.properties.slotId.enum + ).toEqual(['reviewer']) + + subagentConfig = { ...subagentConfig, subagentEnabled: false } + await agent.processMessage('s1', 'Second turn') + + const secondTools = (processStream as ReturnType).mock.calls[1][0].run.resources + .toolDefinitions + expect(secondTools).toEqual([]) + + subagentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: 'Collect evidence.' + } + ] + } + await agent.processMessage('s1', 'Third turn') + + const thirdTools = (processStream as ReturnType).mock.calls[2][0].run.resources + .toolDefinitions + expect(thirdTools.map((tool: any) => tool.function.name)).toEqual(['subagent_orchestrator']) + expect( + (thirdTools[0].function.parameters as any).properties.tasks.items.properties.slotId.enum + ).toEqual(['explorer']) + expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledTimes(3) + }) + it('skips DeepChat runtime prompt layers and local tools for ACP-backed subagent sessions', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ id: 's-acp-subagent', diff --git a/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts new file mode 100644 index 000000000..980db4af3 --- /dev/null +++ b/test/main/presenter/agentRuntimePresenter/toolResolver.test.ts @@ -0,0 +1,333 @@ +import { describe, expect, it, vi } from 'vitest' +import { DeepChatToolResolver } from '@/presenter/agentRuntimePresenter/toolResolver' +import type { DeepChatAgentConfig } from '@shared/types/agent-interface' + +const createResourceInstance = (agentId = 'deepchat') => { + let cached: { profile: 'general'; fingerprint: string; tools: [] } | undefined + return { + getAgentId: vi.fn(() => agentId), + getRuntimeState: vi.fn(() => ({ + status: 'idle', + providerId: 'openai', + modelId: 'gpt-4.1', + permissionMode: 'full_access' + })), + getToolProfileCache: vi.fn(() => cached), + setToolProfileCache: vi.fn((entry) => { + cached = entry + }) + } +} + +describe('DeepChatToolResolver Subagent capability', () => { + it('refreshes the catalog from capability cache keys without event invalidation', async () => { + let config: DeepChatAgentConfig = { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the result.' + } + ] + } + const row = { + session_kind: 'regular' as const, + subagent_enabled: 0 + } + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), + resolveDeepChatAgentConfig: vi.fn(async () => config) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => row), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { + getAllToolDefinitions, + syncAgentToolContext: vi.fn() + }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(1) + expect(getAllToolDefinitions.mock.calls[0][0].subagentCapability).toMatchObject({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + + row.subagent_enabled = 1 + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(1) + + config = { + ...config, + subagents: [{ ...config.subagents![0], description: 'Review security boundaries.' }] + } + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(2) + expect(getAllToolDefinitions.mock.calls[1][0].subagentCapability.cacheKey).not.toBe( + getAllToolDefinitions.mock.calls[0][0].subagentCapability.cacheKey + ) + + config = { ...config, subagentEnabled: false } + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + expect(getAllToolDefinitions).toHaveBeenCalledTimes(3) + expect(getAllToolDefinitions.mock.calls[2][0].subagentCapability).toMatchObject({ + available: false, + reason: 'policy_disabled' + }) + }) + + it('marks child sessions unsupported before building their catalog', async () => { + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'subagent', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'child-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + }) + + it('does not expose Subagents to regular non-DeepChat compatibility sessions', async () => { + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance('acp-reviewer') + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'acp'), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'acp-reviewer'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'acp-session', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + }) + + it('keeps extension policy when Agent type resolution fails closed', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance('custom-agent') + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn().mockRejectedValue(new Error('catalog unavailable')), + resolveDeepChatAgentConfig: vi.fn(async () => ({ + enabledMcpServerIds: ['server-a'], + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + })) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular' })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'custom-agent'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + enabledMcpServerIds: ['server-a'], + subagentCapability: expect.objectContaining({ + available: false, + reason: 'unsupported_session' + }) + }) + ) + expect(warnSpy).toHaveBeenCalledWith( + '[DeepChatAgent] Failed to resolve Agent type for tool policy custom-agent:', + expect.any(Error) + ) + warnSpy.mockRestore() + }) + + it('fails closed without misreporting config resolution failures as an explicit disable', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const getAllToolDefinitions = vi.fn().mockResolvedValue([]) + const resourceInstance = createResourceInstance() + const resolver = new DeepChatToolResolver({ + configPresenter: { + getSkillsEnabled: vi.fn(() => false), + getAgentType: vi.fn(async () => 'deepchat'), + resolveDeepChatAgentConfig: vi.fn().mockRejectedValue(new Error('config unavailable')) + }, + sqlitePresenter: { + newSessionsTable: { + get: vi.fn(() => ({ session_kind: 'regular', subagent_enabled: 1 })), + getDisabledAgentTools: vi.fn(() => []) + } + }, + toolPresenter: { getAllToolDefinitions }, + deepChatRuntime: { getToolRegistryRevision: vi.fn(() => 1) }, + getDeepChatInstance: vi.fn(() => resourceInstance), + getSessionAgentId: vi.fn(() => 'deepchat'), + getRuntimeState: vi.fn(), + assertCurrent: vi.fn(), + isAcpBackedSubagentSession: vi.fn(() => false), + isStaleInstanceError: vi.fn(() => false) + } as any) + + await resolver.loadToolDefinitionsForSession( + 'session-1', + null, + undefined, + resourceInstance as any + ) + + expect(getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: false, + reason: 'no_valid_slots' + }) + }) + ) + expect(warnSpy).toHaveBeenCalledWith( + '[DeepChatAgent] Failed to resolve tool policy for agent deepchat:', + expect.any(Error) + ) + warnSpy.mockRestore() + }) +}) diff --git a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts index 27f4403dc..c0a96764e 100644 --- a/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts +++ b/test/main/presenter/configPresenter/deprecatedProviderCleanup.test.ts @@ -229,6 +229,12 @@ describe('cleanupDeprecatedBuiltinAgentSelections', () => { describe('initializeUnifiedAgents', () => { it('seeds default disabled tools into existing explicit agent configs once', () => { + const configuredSlot = { + id: 'explorer', + targetType: 'self' as const, + displayName: 'Explorer', + description: '' + } const repository = { ensureBuiltinDeepChatAgent: vi.fn(), listAgents: vi.fn(() => [ @@ -238,12 +244,12 @@ describe('initializeUnifiedAgents', () => { ]), getDeepChatAgentConfig: vi.fn((agentId: string) => agentId === BUILTIN_DEEPCHAT_AGENT_ID - ? { disabledAgentTools: ['tool-a'] } + ? { disabledAgentTools: ['tool-a'], subagents: [configuredSlot] } : agentId === 'deepchat-custom' - ? { disabledAgentTools: [] } - : {} + ? { disabledAgentTools: [], subagents: [configuredSlot] } + : { subagents: [configuredSlot] } ), - updateDeepChatAgent: vi.fn() + updateDeepChatAgent: vi.fn(() => ({})) } const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { getAgentRepositoryOrThrow: vi.fn(() => repository), @@ -266,7 +272,185 @@ describe('initializeUnifiedAgents', () => { config: { disabledAgentTools: [CRON_JOB_AGENT_TOOL_NAME] } }) expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) - expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 2) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + expect(presenter.syncRegistryAgentsToRepository).toHaveBeenCalledTimes(1) + }) + + it('repairs enabled-empty policies before older tool-default writes', () => { + let config = { + subagentEnabled: true, + subagents: [] as Array<{ + id: string + targetType: 'self' + displayName: string + description: string + }>, + disabledAgentTools: [] as string[] + } + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [{ id: BUILTIN_DEEPCHAT_AGENT_ID }]), + getDeepChatAgentConfig: vi.fn(() => config), + updateDeepChatAgent: vi.fn( + (_agentId: string, updates: { config: Partial }) => { + if ( + updates.config.disabledAgentTools && + config.subagentEnabled && + config.subagents.length === 0 + ) { + throw new Error('repository invariant rejected enabled-empty config') + } + config = { ...config, ...updates.config } + return {} + } + ) + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 1), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + const initialize = () => { + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + } + + expect(initialize).not.toThrow() + + expect(repository.updateDeepChatAgent).toHaveBeenNthCalledWith(1, BUILTIN_DEEPCHAT_AGENT_ID, { + config: { subagents: expect.any(Array) } + }) + expect(repository.updateDeepChatAgent).toHaveBeenNthCalledWith(2, BUILTIN_DEEPCHAT_AGENT_ID, { + config: { disabledAgentTools: [CRON_JOB_AGENT_TOOL_NAME] } + }) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('migrates only builtin disabled-empty and enabled-empty Subagent policies', () => { + const configuredSlot = { + id: 'reviewer', + targetType: 'self' as const, + displayName: 'Reviewer', + description: '' + } + const configs = new Map([ + [BUILTIN_DEEPCHAT_AGENT_ID, { subagentEnabled: false, subagents: [] }], + ['enabled-empty', { subagentEnabled: true, subagents: [] }], + ['disabled-empty', { subagentEnabled: false, subagents: [] }], + ['disabled-configured', { subagentEnabled: false, subagents: [configuredSlot] }] + ]) + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [...configs.keys()].map((id) => ({ id }))), + getDeepChatAgentConfig: vi.fn((agentId: string) => configs.get(agentId)), + updateDeepChatAgent: vi.fn(() => ({})) + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 2), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + + expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) + expect(repository.updateDeepChatAgent).toHaveBeenCalledWith(BUILTIN_DEEPCHAT_AGENT_ID, { + config: { + subagentEnabled: true, + subagents: expect.arrayContaining([ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ]) + } + }) + expect(repository.updateDeepChatAgent).toHaveBeenCalledWith('enabled-empty', { + config: { + subagents: expect.arrayContaining([ + expect.objectContaining({ id: 'explorer' }), + expect.objectContaining({ id: 'implementer' }), + expect.objectContaining({ id: 'reviewer' }) + ]) + } + }) + expect(repository.updateDeepChatAgent).not.toHaveBeenCalledWith( + 'disabled-empty', + expect.anything() + ) + expect(repository.updateDeepChatAgent).not.toHaveBeenCalledWith( + 'disabled-configured', + expect.anything() + ) + expect(presenter.store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('records migration v3 only after all Agent updates succeed and can retry', () => { + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(() => [{ id: 'enabled-empty' }]), + getDeepChatAgentConfig: vi.fn(() => ({ subagentEnabled: true, subagents: [] })), + updateDeepChatAgent: vi.fn().mockReturnValueOnce(null).mockReturnValue({}) + } + const store = { set: vi.fn() } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 2), + store, + syncRegistryAgentsToRepository: vi.fn() + }) + const initialize = () => { + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + } + + expect(initialize).toThrow('Failed to migrate DeepChat Agent enabled-empty Subagent defaults.') + expect(store.set).not.toHaveBeenCalled() + + expect(initialize).not.toThrow() + expect(repository.updateDeepChatAgent).toHaveBeenCalledTimes(2) + expect(store.set).toHaveBeenCalledWith('unifiedAgentsMigrationVersion', 3) + }) + + it('does not rerun unified Agent migrations after version 3', () => { + const repository = { + ensureBuiltinDeepChatAgent: vi.fn(), + listAgents: vi.fn(), + getDeepChatAgentConfig: vi.fn(), + updateDeepChatAgent: vi.fn() + } + const presenter = Object.assign(Object.create(ConfigPresenter.prototype), { + getAgentRepositoryOrThrow: vi.fn(() => repository), + buildLegacyBuiltinDeepChatConfig: vi.fn(() => ({})), + getSetting: vi.fn(() => 3), + store: { set: vi.fn() }, + syncRegistryAgentsToRepository: vi.fn() + }) + + ;( + presenter as ConfigPresenter & { + initializeUnifiedAgents(): void + } + ).initializeUnifiedAgents() + + expect(repository.listAgents).not.toHaveBeenCalled() + expect(repository.updateDeepChatAgent).not.toHaveBeenCalled() + expect(presenter.store.set).not.toHaveBeenCalled() expect(presenter.syncRegistryAgentsToRepository).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/presenter/configPresenter/systemPromptHelper.test.ts b/test/main/presenter/configPresenter/systemPromptHelper.test.ts new file mode 100644 index 000000000..1d2184691 --- /dev/null +++ b/test/main/presenter/configPresenter/systemPromptHelper.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { DEFAULT_SYSTEM_PROMPT } from '@/presenter/configPresenter/systemPromptHelper' +import { DEEPCHAT_SUBAGENT_MODEL_GUIDANCE } from '@shared/lib/deepchatSubagents' + +describe('default system prompt', () => { + it('uses the shared conservative Subagent delegation guidance', () => { + expect(DEFAULT_SYSTEM_PROMPT).toContain(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE) + expect(DEFAULT_SYSTEM_PROMPT).toContain('When `subagent_orchestrator` is available') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('use them when requested and available') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('For proactive delegation') + expect(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE).toContain('Do not proactively delegate simple') + }) +}) diff --git a/test/main/presenter/cronJobRunSessionStarter.test.ts b/test/main/presenter/cronJobRunSessionStarter.test.ts index 93d4b9df8..bf7f2a6c7 100644 --- a/test/main/presenter/cronJobRunSessionStarter.test.ts +++ b/test/main/presenter/cronJobRunSessionStarter.test.ts @@ -93,7 +93,6 @@ describe('createCronJobRunSessionStarter', () => { modelId: 'claude-sonnet', permissionMode: 'full_access', disabledAgentTools: ['write_file'], - subagentEnabled: false, generationSettings: { systemPrompt: 'Task-specific system prompt' }, metadata: { source: 'cron_job', diff --git a/test/main/presenter/exporter/agentSessionExporter.test.ts b/test/main/presenter/exporter/agentSessionExporter.test.ts index 13948d188..a0e9f9c61 100644 --- a/test/main/presenter/exporter/agentSessionExporter.test.ts +++ b/test/main/presenter/exporter/agentSessionExporter.test.ts @@ -33,7 +33,6 @@ function createFixture(options?: { isDraft: false, sessionKind: 'regular' as const, parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200 diff --git a/test/main/presenter/index.test.ts b/test/main/presenter/index.test.ts index 17e60561c..7467d8858 100644 --- a/test/main/presenter/index.test.ts +++ b/test/main/presenter/index.test.ts @@ -1,5 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Presenter, routeDeepChatAgentMemoryMaintenanceConfigChanged } from '@/presenter' +import { + Presenter, + resolveConversationSubagentCapability, + routeDeepChatAgentMemoryMaintenanceConfigChanged +} from '@/presenter' import { BUILTIN_DEEPCHAT_AGENT_ID } from '@/presenter/agentRepository' import logger from '@shared/logger' @@ -180,3 +184,62 @@ describe('DeepChat agent memory maintenance config routing', () => { expect(memoryPresenter.onBuiltinDeepChatMemoryMaintenanceConfigChanged).not.toHaveBeenCalled() }) }) + +describe('Conversation Subagent capability resolution', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('applies the current Agent policy to regular DeepChat parent sessions', async () => { + const resolveConfig = vi.fn().mockResolvedValue({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review an independent task.' + } + ] + }) + + await expect( + resolveConversationSubagentCapability({ + sessionId: 'session-1', + agentId: 'deepchat', + agentType: 'deepchat', + sessionKind: 'regular', + resolveConfig + }) + ).resolves.toMatchObject({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + expect(resolveConfig).toHaveBeenCalledWith('deepchat') + }) + + it('fails closed without rejecting when Agent policy resolution fails', async () => { + const error = new Error('config unavailable') + const resolveConfig = vi.fn().mockRejectedValue(error) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect( + resolveConversationSubagentCapability({ + sessionId: 'session-1', + agentId: 'deepchat', + agentType: 'deepchat', + sessionKind: 'regular', + resolveConfig + }) + ).resolves.toMatchObject({ + available: false, + reason: 'no_valid_slots' + }) + expect(resolveConfig).toHaveBeenCalledWith('deepchat') + expect(warn).toHaveBeenCalledWith('[Presenter] Failed to resolve Subagent policy:', { + sessionId: 'session-1', + agentId: 'deepchat', + error + }) + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts index e22b8745a..18ecbf799 100644 --- a/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts +++ b/test/main/presenter/remoteControlPresenter/remoteConversationRunner.test.ts @@ -24,7 +24,6 @@ const createSession = (overrides: Partial = {}): SessionWithSt isPinned: false, isDraft: false, sessionKind: 'regular', - subagentEnabled: false, createdAt: 1, updatedAt: 1, status: 'idle', diff --git a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts index b8220c4eb..f5d03e992 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentCoordinator.test.ts @@ -18,7 +18,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, @@ -165,8 +164,7 @@ function createHarness(initialSessions: SessionRecord[] = [createSession()]) { modelId: 'gpt-4', projectDir: projectDir ?? '/target', permissionMode: 'full_access', - disabledAgentTools: ['write'], - subagentEnabled: true + disabledAgentTools: ['write'] })), assertAcpSessionHasWorkdir: vi.fn((providerId: string, projectDir: string | null) => { if (providerId === 'acp' && !projectDir) throw new Error('workdir required') @@ -266,8 +264,7 @@ describe('SessionAgentAssignmentCoordinator', () => { modelId: 'gpt-4', projectDir, permissionMode: 'full_access', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] } } ) @@ -332,16 +329,6 @@ describe('SessionAgentAssignmentCoordinator', () => { expect(order).toEqual(['store', 'environment', 'runtime-setting', 'acp-workdir']) }) - it('uses descriptor-only lookup before mutating subagent-enabled state', async () => { - const harness = createHarness() - - await harness.coordinator.setSessionSubagentEnabled('s1', true) - - expect(harness.runtime.getSessionAgentKind).toHaveBeenCalledWith('s1') - expect(harness.runtime.resolveSession).not.toHaveBeenCalled() - expect(harness.sessions.update).toHaveBeenCalledWith('s1', { subagentEnabled: true }) - }) - it('falls back to requested model identity when the post-set snapshot is null', async () => { const harness = createHarness() harness.deepchatHandle.snapshot.mockResolvedValue(null) diff --git a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts index 878730a86..d8bbb9948 100644 --- a/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts +++ b/test/main/presenter/sessionApplication/agentAssignmentPolicy.test.ts @@ -61,8 +61,7 @@ describe('SessionAgentAssignmentPolicy', () => { projectDir: '/agent-project', permissionMode: 'auto_approve', generationSettings: { systemPrompt: 'Agent prompt', temperature: 0.2 }, - disabledAgentTools: ['write'], - subagentEnabled: true + disabledAgentTools: ['write'] }) }) @@ -121,7 +120,6 @@ describe('SessionAgentAssignmentPolicy', () => { modelId: 'ignored', projectDir: '/repo', disabledAgentTools: ['write'], - subagentEnabled: true, preserveExplicitNullProjectDir: true }) ).resolves.toMatchObject({ @@ -129,8 +127,7 @@ describe('SessionAgentAssignmentPolicy', () => { agentType: 'acp', providerId: 'acp', modelId: 'claude-acp', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] }) }) diff --git a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts index 7ee59b70f..296cb8586 100644 --- a/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleCoordinator.test.ts @@ -19,7 +19,6 @@ const createRecord = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, @@ -91,7 +90,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { options: { isDraft?: boolean disabledAgentTools?: string[] - subagentEnabled?: boolean sessionKind?: SessionRecord['sessionKind'] parentSessionId?: string | null subagentMeta?: SessionRecord['subagentMeta'] @@ -110,7 +108,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { isDraft: options.isDraft ?? false, sessionKind: options.sessionKind ?? 'regular', parentSessionId: options.parentSessionId ?? null, - subagentEnabled: options.subagentEnabled ?? false, subagentMeta: options.subagentMeta ?? null, metadata: options.metadata ?? null }) @@ -147,7 +144,6 @@ function createHarness(initialSessions: SessionRecord[] = []) { permissionMode?: DeepChatSessionState['permissionMode'] generationSettings?: Partial disabledAgentTools?: string[] - subagentEnabled?: boolean }) => ({ agentId: input.agentId, agentType: input.providerId === 'acp' ? ('acp' as const) : ('deepchat' as const), @@ -156,8 +152,7 @@ function createHarness(initialSessions: SessionRecord[] = []) { projectDir: input.projectDir === undefined ? '/default' : input.projectDir, permissionMode: input.permissionMode ?? ('full_access' as const), generationSettings: input.generationSettings, - disabledAgentTools: input.disabledAgentTools ?? [], - subagentEnabled: input.subagentEnabled ?? false + disabledAgentTools: input.disabledAgentTools ?? [] }) ), resolveAcpDraftAssignment: vi.fn( @@ -316,8 +311,7 @@ describe('SessionLifecycleCoordinator', () => { modelId: 'acp-coder', projectDir: '/repo', permissionMode: 'full_access', - disabledAgentTools: [], - subagentEnabled: false + disabledAgentTools: [] }) harness.workdir.syncAcpSessionWorkdir.mockRejectedValueOnce(initializationError) harness.workdir.clearCompatibilityAcpSession.mockRejectedValueOnce(clearError) diff --git a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts index dc4757ae0..f95a2e9c7 100644 --- a/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts +++ b/test/main/presenter/sessionApplication/lifecycleDeletionTransaction.test.ts @@ -14,7 +14,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts index 7dad0a62f..0f5dc2b8c 100644 --- a/test/main/presenter/sessionApplication/projectionCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/projectionCoordinator.test.ts @@ -14,7 +14,6 @@ const createSessionRecord = (overrides: Partial = {}): SessionRec isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts index 24af64790..3f0d5293f 100644 --- a/test/main/presenter/sessionApplication/runtimeIntegration.test.ts +++ b/test/main/presenter/sessionApplication/runtimeIntegration.test.ts @@ -119,7 +119,12 @@ function createMockSqlitePresenter() { agentId: string, title: string, projectDir: string | null, - options?: { isDraft?: boolean } + options?: { + isDraft?: boolean + sessionKind?: 'regular' | 'subagent' + parentSessionId?: string | null + subagentMetaJson?: string | null + } ) => { const now = Date.now() sessionsStore.set(id, { @@ -129,6 +134,9 @@ function createMockSqlitePresenter() { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, + session_kind: options?.sessionKind === 'subagent' ? 'subagent' : 'regular', + parent_session_id: options?.parentSessionId ?? null, + subagent_meta_json: options?.subagentMetaJson ?? null, created_at: now, updated_at: now }) @@ -629,6 +637,18 @@ function createMockConfigPresenter() { getVerbosityDefault: vi.fn().mockReturnValue(undefined), getSkillsEnabled: vi.fn().mockReturnValue(false), getSetting: vi.fn().mockReturnValue(undefined), + getAgentType: vi.fn().mockResolvedValue('deepchat'), + resolveDeepChatAgentConfig: vi.fn().mockResolvedValue({ + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review an independent task.' + } + ] + }), getAcpAgents: vi.fn().mockResolvedValue([]) } as any } @@ -675,6 +695,7 @@ describe('Integration: createSession end-to-end', () => { let sqlitePresenter: ReturnType let llmProvider: ReturnType let configPresenter: ReturnType + let toolPresenter: ReturnType let lifecycle: ReturnType['lifecycle'] let turn: ReturnType['turn'] let projection: ReturnType @@ -684,12 +705,13 @@ describe('Integration: createSession end-to-end', () => { sqlitePresenter = createMockSqlitePresenter() llmProvider = createMockLlmProviderPresenter() configPresenter = createMockConfigPresenter() + toolPresenter = createMockToolPresenter() const deepchatAgent = new AgentRuntimePresenter( llmProvider, configPresenter, sqlitePresenter, - createMockToolPresenter() + toolPresenter ) const agentManager = createDeepChatManager(deepchatAgent, sqlitePresenter) as any const appSessionService = new AppSessionService({ @@ -799,6 +821,16 @@ describe('Integration: createSession end-to-end', () => { ) expect(streamEndCalls.length).toBeGreaterThanOrEqual(1) expect(streamEndCalls[0][1].sessionId).toBe(session.id) + expect(configPresenter.getAgentType).toHaveBeenCalledWith('deepchat') + expect(configPresenter.resolveDeepChatAgentConfig).toHaveBeenCalledWith('deepchat') + expect(toolPresenter.getAllToolDefinitions).toHaveBeenCalledWith( + expect.objectContaining({ + subagentCapability: expect.objectContaining({ + available: true, + slots: [expect.objectContaining({ id: 'reviewer' })] + }) + }) + ) }) it('session list returns enriched sessions', async () => { diff --git a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts index 092ce997e..b584fcb82 100644 --- a/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts +++ b/test/main/presenter/sessionApplication/sessionApplication.integration.test.ts @@ -449,7 +449,7 @@ function installSessionStore(sqlitePresenter: ReturnType { { isDraft: false, disabledAgentTools: [], - subagentEnabled: false, sessionKind: undefined, parentSessionId: undefined, subagentMetaJson: null @@ -2615,7 +2614,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -2706,7 +2705,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -2778,7 +2777,7 @@ describe('Session application coordinators', () => { project_dir: projectDir, is_pinned: 0, is_draft: options?.isDraft ? 1 : 0, - subagent_enabled: options?.subagentEnabled ? 1 : 0, + subagent_enabled: 0, session_kind: options?.sessionKind ?? 'regular', parent_session_id: options?.parentSessionId ?? null, subagent_meta_json: options?.subagentMetaJson ?? null, @@ -3768,65 +3767,6 @@ describe('Session application coordinators', () => { }) }) - describe('setSessionSubagentEnabled', () => { - it('rejects regular ACP sessions before updating persisted state', async () => { - sqlitePresenter.newSessionsTable.get.mockReturnValue({ - id: 's-acp', - agent_id: 'acp-coder', - title: 'ACP', - project_dir: '/tmp/workspace', - is_pinned: 0, - is_draft: 0, - subagent_enabled: 0, - session_kind: 'regular', - parent_session_id: null, - subagent_meta_json: null, - created_at: 1000, - updated_at: 1000 - }) - - await expect(assignment.setSessionSubagentEnabled('s-acp', true)).rejects.toThrow( - 'Only DeepChat sessions can change subagent state.' - ) - - expect(sqlitePresenter.newSessionsTable.update).not.toHaveBeenCalled() - }) - - it('throws when the updated session state cannot be rebuilt', async () => { - const row = { - id: 's1', - agent_id: 'deepchat', - title: 'Test', - project_dir: null, - is_pinned: 0, - is_draft: 0, - subagent_enabled: 0, - session_kind: 'regular', - parent_session_id: null, - subagent_meta_json: null, - created_at: 1000, - updated_at: 1000 - } - sqlitePresenter.newSessionsTable.get.mockImplementation((id: string) => - id === 's1' ? row : undefined - ) - sqlitePresenter.newSessionsTable.update.mockImplementation((_: string, fields: any) => { - Object.assign(row, fields) - }) - deepChatAgent.getSessionState.mockRejectedValueOnce(new Error('state unavailable')) - - await expect(assignment.setSessionSubagentEnabled('s1', true)).rejects.toThrow( - 'Failed to build session state for sessionId: s1' - ) - - expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', { - subagent_enabled: 1 - }) - expect(row.subagent_enabled).toBe(1) - expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) - }) - }) - describe('setSessionModel', () => { it('updates deepchat session model and emits LIST_UPDATED', async () => { sqlitePresenter.newSessionsTable.get.mockReturnValue({ @@ -4021,9 +3961,6 @@ describe('Session application coordinators', () => { if (fields.project_dir !== undefined) { row.project_dir = fields.project_dir } - if (fields.subagent_enabled !== undefined) { - row.subagent_enabled = fields.subagent_enabled - } }) configPresenter.getAgentType.mockImplementation(async (agentId: string) => { if (agentId === 'deepchat-writer' || agentId === 'deepchat-coder') { @@ -4078,6 +4015,10 @@ describe('Session application coordinators', () => { ).toBeLessThan(deepChatAgent.getSessionState.mock.invocationCallOrder.at(-1)!) expect(updated.agentId).toBe('deepchat-coder') expect(updated.providerId).toBe('anthropic') + expect(row.subagent_enabled).toBe(1) + expect(sqlitePresenter.newSessionsTable.update).toHaveBeenCalledWith('s1', { + project_dir: '/repo' + }) expectSessionsUpdated({ reason: 'updated', sessionIds: ['s1'] }) }) @@ -4331,9 +4272,6 @@ describe('Session application coordinators', () => { if (fields.project_dir !== undefined) { row.project_dir = fields.project_dir } - if (fields.subagent_enabled !== undefined) { - row.subagent_enabled = fields.subagent_enabled - } }) configPresenter.getAgentType.mockImplementation(async (agentId: string) => { if (agentId === 'deepchat-writer' || agentId === 'deepchat-coder') { @@ -4361,6 +4299,8 @@ describe('Session application coordinators', () => { expect(rows.get('s-ready-1').agent_id).toBe('deepchat-coder') expect(rows.get('s-ready-2').agent_id).toBe('deepchat-writer') + expect(rows.get('s-ready-1').subagent_enabled).toBe(1) + expect(rows.get('s-ready-2').subagent_enabled).toBe(1) expectSessionsUpdated({ reason: 'updated' }) }) diff --git a/test/main/presenter/sessionApplication/turnCoordinator.test.ts b/test/main/presenter/sessionApplication/turnCoordinator.test.ts index 54984cea1..f08be6211 100644 --- a/test/main/presenter/sessionApplication/turnCoordinator.test.ts +++ b/test/main/presenter/sessionApplication/turnCoordinator.test.ts @@ -18,7 +18,6 @@ const createSession = (overrides: Partial = {}): SessionRecord => isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 100, updatedAt: 200, diff --git a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts index f0fd1d544..24638946d 100644 --- a/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts +++ b/test/main/presenter/sqlitePresenter/newSessionsTable.test.ts @@ -1,24 +1,29 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { Database, nativeSqliteDescribeIf } from '../../nativeSqliteHarness' -const sqliteModule = await import('better-sqlite3-multiple-ciphers').catch(() => null) -const tableModule = sqliteModule +const tableModule = Database ? await import('@/presenter/sqlitePresenter/tables/newSessions').catch(() => null) : null -const Database = sqliteModule?.default +const activeSkillsTableModule = Database + ? await import('@/presenter/sqlitePresenter/tables/newSessionActiveSkills').catch(() => null) + : null +const disabledToolsTableModule = Database + ? await import('@/presenter/sqlitePresenter/tables/newSessionDisabledAgentTools').catch( + () => null + ) + : null const NewSessionsTable = tableModule?.NewSessionsTable -let sqliteAvailable = false -if (Database) { - try { - const smokeDb = new Database(':memory:') - smokeDb.close() - sqliteAvailable = true - } catch { - sqliteAvailable = false - } -} +const NewSessionActiveSkillsTable = activeSkillsTableModule?.NewSessionActiveSkillsTable +const NewSessionDisabledAgentToolsTable = + disabledToolsTableModule?.NewSessionDisabledAgentToolsTable const DatabaseCtor = Database! const NewSessionsTableCtor = NewSessionsTable! -const describeIfSqlite = sqliteAvailable && NewSessionsTable ? describe : describe.skip +const NewSessionActiveSkillsTableCtor = NewSessionActiveSkillsTable! +const NewSessionDisabledAgentToolsTableCtor = NewSessionDisabledAgentToolsTable! +const describeIfSqlite = nativeSqliteDescribeIf( + Boolean(NewSessionsTable && NewSessionActiveSkillsTable && NewSessionDisabledAgentToolsTable), + 'New Session native table modules are unavailable' +) describeIfSqlite('NewSessionsTable', () => { let db: InstanceType | null @@ -27,6 +32,8 @@ describeIfSqlite('NewSessionsTable', () => { beforeEach(() => { db = new DatabaseCtor(':memory:') table = new NewSessionsTableCtor(db) + new NewSessionActiveSkillsTableCtor(db).createTable() + new NewSessionDisabledAgentToolsTableCtor(db).createTable() table.createTable() }) @@ -103,4 +110,17 @@ describeIfSqlite('NewSessionsTable', () => { .sort() ).toEqual(['first', 'last']) }) + + it('leaves the legacy Subagent policy column at its database-owned value', () => { + table.create('session-1', 'deepchat', 'Session', null) + expect(table.get('session-1')?.subagent_enabled).toBe(0) + + db!.prepare('UPDATE new_sessions SET subagent_enabled = 1 WHERE id = ?').run('session-1') + table.update('session-1', { title: 'Renamed' }) + + expect(table.get('session-1')).toMatchObject({ + title: 'Renamed', + subagent_enabled: 1 + }) + }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts index 251af8c68..34f2b7e65 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentMemoryTools.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import { AGENT_MEMORY_MANUAL_CONTENT_MAX_CHARS } from '@shared/types/agent-memory' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' import { AgentMemoryToolHandler, @@ -22,9 +23,13 @@ const buildRuntimePort = (overrides: Record = {}) => activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, - availableSubagentSlots: [] + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: [] + }) }), isMemoryEnabled: vi.fn().mockReturnValue(true), rememberMemory: vi.fn(), diff --git a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts index 17e4f8a63..7e99ea5f6 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentTapeTools.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { AgentToolManager } from '@/presenter/toolPresenter/agentTools/agentToolManager' import { AgentTapeToolHandler, TAPE_TOOL_NAMES } from '@/presenter/toolPresenter/agentTools' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -30,9 +31,13 @@ const buildRuntimePort = (overrides: Record = {}) => activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, - availableSubagentSlots: [] + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: [] + }) }), getTapeInfo: vi.fn().mockResolvedValue({ sessionId: 'conv-1', diff --git a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts index ac8f01057..fbfb43e63 100644 --- a/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/agentToolManagerSettings.test.ts @@ -4,6 +4,10 @@ import { CHAT_SETTINGS_SKILL_NAME, CHAT_SETTINGS_TOOL_NAMES } from '@/presenter/toolPresenter/agentTools/chatSettingsTools' +import { + DEEPCHAT_SUBAGENT_MODEL_GUIDANCE, + resolveDeepChatSubagentCapability +} from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -384,23 +388,11 @@ describe('AgentToolManager DeepChat settings tool gating', () => { it('builds a stable slotId enum for subagent_orchestrator from the session config', async () => { skillPresenter.getActiveSkills.mockResolvedValue([]) skillPresenter.getActiveSkillsAllowedTools.mockResolvedValue([]) - resolveConversationSessionInfo.mockResolvedValue({ - sessionId: 'conv-1', - agentId: 'deepchat', - agentName: 'DeepChat', + const subagentCapability = resolveDeepChatSubagentCapability({ agentType: 'deepchat', - providerId: 'openai', - modelId: 'gpt-4.1', - projectDir: '/tmp/workspace', - permissionMode: 'full_access', - generationSettings: null, - disabledAgentTools: [], - activeSkills: [], sessionKind: 'regular', - parentSessionId: null, - subagentEnabled: true, - subagentMeta: null, - availableSubagentSlots: [ + agentPolicyEnabled: true, + slots: [ { id: 'writer', targetType: 'self', @@ -422,7 +414,8 @@ describe('AgentToolManager DeepChat settings tool gating', () => { chatMode: 'agent', supportsVision: false, agentWorkspacePath: null, - conversationId: 'conv-1' + conversationId: 'conv-1', + subagentCapability }) const subagentDef = defs.find((def) => def.function.name === 'subagent_orchestrator') @@ -437,8 +430,10 @@ describe('AgentToolManager DeepChat settings tool gating', () => { expect(subagentDef?.function.description).toContain( 'inherits the same working directory as the parent session' ) + expect(subagentDef?.function.description).toContain(DEEPCHAT_SUBAGENT_MODEL_GUIDANCE) expect(promptSchema?.description).toContain( 'The child session uses the same working directory as the parent session' ) + expect(resolveConversationSessionInfo).toHaveBeenCalledTimes(1) }) }) diff --git a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts index b8f762c13..1184960b5 100644 --- a/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts +++ b/test/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.test.ts @@ -6,6 +6,27 @@ import { } from '@/presenter/toolPresenter/agentTools/subagentOrchestratorTool' import type { ConversationSessionInfo } from '@/presenter/toolPresenter/runtimePorts' import type { SubagentTapeLinkInput, SubagentTapeLinkReceipt } from '@shared/types/agent-interface' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' + +const parentSubagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer Clone', + description: 'Review the delegated task.' + } + ] +}) +const childSubagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'subagent', + agentPolicyEnabled: true, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] +}) const buildTapeLinkReceipt = (input: SubagentTapeLinkInput): SubagentTapeLinkReceipt => ({ linkEntry: { sessionId: input.parentSessionId, entryId: 1 }, @@ -34,16 +55,8 @@ const buildSessionInfo = ( activeSkills: [], sessionKind: 'regular', parentSessionId: null, - subagentEnabled: true, subagentMeta: null, - availableSubagentSlots: [ - { - id: 'reviewer', - targetType: 'self', - displayName: 'Reviewer Clone', - description: 'Review the delegated task.' - } - ], + subagentCapability: parentSubagentCapability, ...overrides }) @@ -58,8 +71,7 @@ const buildRuntimePort = ( sessionId: 'child-session', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) ), sendConversationMessage: vi.fn().mockResolvedValue(undefined), @@ -94,6 +106,105 @@ const createDeferredPromise = () => { } describe('SubagentOrchestratorTool', () => { + it('distinguishes explicit requests from proactive delegation guidance', () => { + const tool = new SubagentOrchestratorTool(buildRuntimePort(buildSessionInfo()) as any) + const definition = tool.getToolDefinition(parentSubagentCapability) + const description = definition?.function.description ?? '' + + expect(description).toContain('use them when requested and available') + expect(description).toContain('For proactive delegation') + expect(description).toContain('Do not proactively delegate simple') + }) + + it('fails closed when the Agent policy changes after tool definition', async () => { + const currentParent = buildSessionInfo({ + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] + }) + }) + const runtimePort = buildRuntimePort(currentParent) + const tool = new SubagentOrchestratorTool(runtimePort as any) + + expect(tool.getToolDefinition(parentSubagentCapability)).not.toBeNull() + await expect( + tool.call( + { + mode: 'parallel', + tasks: [{ slotId: 'reviewer', title: 'Review', prompt: 'Review the change.' }] + }, + currentParent.sessionId + ) + ).rejects.toThrow('(policy_disabled)') + expect(runtimePort.createSubagentSession).not.toHaveBeenCalled() + }) + + it('keeps an admitted run on its start-time capability snapshot', async () => { + let currentParent = buildSessionInfo() + const childSession = buildSessionInfo({ + sessionId: 'admitted-child', + sessionKind: 'subagent', + parentSessionId: currentParent.sessionId, + subagentCapability: childSubagentCapability + }) + let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null + const resolveConversationSessionInfo = vi.fn(async () => currentParent) + const sendConversationMessage = vi.fn().mockResolvedValue(undefined) + const cancelConversation = vi.fn().mockResolvedValue(undefined) + const runtimePort = buildRuntimePort(currentParent, { + resolveConversationSessionInfo, + createSubagentSession: vi.fn().mockResolvedValue(childSession), + sendConversationMessage, + cancelConversation, + subscribeDeepChatSessionUpdates: vi.fn((callback) => { + listener = callback + return () => { + listener = null + } + }) + }) + const tool = new SubagentOrchestratorTool(runtimePort as any) + + const running = tool.call( + { + mode: 'parallel', + tasks: [{ slotId: 'reviewer', title: 'Review', prompt: 'Review the change.' }] + }, + currentParent.sessionId + ) + await vi.waitFor(() => expect(sendConversationMessage).toHaveBeenCalled()) + + currentParent = buildSessionInfo({ + subagentCapability: resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: false, + slots: parentSubagentCapability.available ? parentSubagentCapability.slots : [] + }) + }) + listener?.({ + sessionId: childSession.sessionId, + kind: 'blocks', + updatedAt: Date.now(), + previewMarkdown: 'Review complete.', + responseMarkdown: 'Review complete.' + }) + listener?.({ + sessionId: childSession.sessionId, + kind: 'status', + updatedAt: Date.now() + 1, + status: 'idle' + }) + + await expect(running).resolves.toEqual( + expect.objectContaining({ content: expect.stringContaining('Review complete.') }) + ) + expect(resolveConversationSessionInfo).toHaveBeenCalledTimes(1) + expect(cancelConversation).not.toHaveBeenCalled() + }) + it('includes the parent session workdir in the child handoff', async () => { let listener: ((update: DeepChatInternalSessionUpdate) => void) | null = null let handoffMessage = '' @@ -108,8 +219,7 @@ describe('SubagentOrchestratorTool', () => { projectDir: '/workspace/child-session-record', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const resolveConversationWorkdir = vi.fn().mockResolvedValue(resolvedWorkdir) const createSubagentSession = vi.fn().mockResolvedValue(childSession) @@ -217,8 +327,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'deadline-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const cancelConversation = vi.fn().mockResolvedValue(undefined) const runtimePort = buildRuntimePort(parentSession, { @@ -227,7 +336,7 @@ describe('SubagentOrchestratorTool', () => { }) const tool = new SubagentOrchestratorTool(runtimePort as any) - const definition = await tool.getToolDefinition(parentSession.sessionId) + const definition = tool.getToolDefinition(parentSession.subagentCapability) const runTimeoutProperty = (definition?.function.parameters as any).properties.runTimeoutMs expect(runTimeoutProperty).toMatchObject({ type: 'number', @@ -305,8 +414,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'slow-cancel-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) let settleCancellation: (() => void) | undefined const cancelConversation = vi.fn( @@ -383,8 +491,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-completed-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const link = createDeferredPromise() let linkInput: SubagentTapeLinkInput | undefined @@ -462,8 +569,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-error-link-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const link = createDeferredPromise() let linkInput: SubagentTapeLinkInput | undefined @@ -531,8 +637,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'late-deadline-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const childCreation = createDeferredPromise() const cancellation = createDeferredPromise() @@ -592,8 +697,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: `active-child-${childIndex}`, sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) }) }) @@ -653,8 +757,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const createSubagentSession = vi.fn().mockResolvedValue(childSession) const cancelConversation = vi.fn().mockResolvedValue(undefined) @@ -726,8 +829,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = createTapeLinkMock() @@ -932,8 +1034,7 @@ describe('SubagentOrchestratorTool', () => { sessionId, sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) ) const earlyHandoff = createDeferredPromise() @@ -1032,8 +1133,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = vi .fn() @@ -1128,8 +1228,7 @@ describe('SubagentOrchestratorTool', () => { agentName: 'Reviewer Clone', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const linkSubagentTape = vi.fn().mockRejectedValue(new Error('link still failed')) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -1232,8 +1331,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'blocked-handoff-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const abortController = new AbortController() const handoff = createDeferredPromise() @@ -1306,8 +1404,7 @@ describe('SubagentOrchestratorTool', () => { sessionId: 'late-created-child', sessionKind: 'subagent', parentSessionId: parentSession.sessionId, - subagentEnabled: false, - availableSubagentSlots: [] + subagentCapability: childSubagentCapability }) const abortController = new AbortController() const childCreation = createDeferredPromise() diff --git a/test/main/presenter/toolPresenter/toolPresenter.test.ts b/test/main/presenter/toolPresenter/toolPresenter.test.ts index d7bd0fa6c..84a2749b1 100644 --- a/test/main/presenter/toolPresenter/toolPresenter.test.ts +++ b/test/main/presenter/toolPresenter/toolPresenter.test.ts @@ -12,9 +12,11 @@ import { QUESTION_TOOL_NAME } from '@/presenter/toolPresenter/agentTools/questio import { IMAGE_GENERATE_TOOL_NAME } from '@shared/agentImageGenerationTool' import { CRON_JOB_AGENT_TOOL_NAME, + SUBAGENT_ORCHESTRATOR_TOOL_NAME, assertAgentToolExposure, getAgentToolExposure } from '@shared/agentTools' +import { resolveDeepChatSubagentCapability } from '@shared/lib/deepchatSubagents' vi.mock('electron', () => ({ app: { @@ -483,6 +485,7 @@ describe('ToolPresenter', () => { expect(getAgentToolExposure(TAPE_TOOL_NAMES.info)).toBe('diagnostic') expect(getAgentToolExposure(TAPE_TOOL_NAMES.anchors)).toBe('diagnostic') expect(getAgentToolExposure(TAPE_TOOL_NAMES.handoff)).toBe('runtime-only') + expect(getAgentToolExposure(SUBAGENT_ORCHESTRATOR_TOOL_NAME)).toBe('system-model') expect(getAgentToolExposure('read')).toBe('user-configurable') expect(getAgentToolExposure('__proto__')).toBe('user-configurable') expect(() => assertAgentToolExposure(TAPE_TOOL_NAMES.handoff, 'user-configurable')).toThrow( @@ -490,6 +493,68 @@ describe('ToolPresenter', () => { ) }) + it('reserves the Subagent orchestrator and ignores generic disabled-tool state', async () => { + const toolPresenter = new ToolPresenter({ + mcpPresenter: { + getAllToolDefinitions: vi + .fn() + .mockResolvedValue([ + buildToolDefinition(SUBAGENT_ORCHESTRATOR_TOOL_NAME, 'untrusted-mcp') + ]) + } as any, + configPresenter: { + getSkillsEnabled: vi.fn().mockReturnValue(false), + getSkillsPath: vi.fn().mockReturnValue('C:\\skills'), + getModelConfig: vi.fn() + } as any, + commandPermissionHandler: new CommandPermissionService(), + agentToolRuntime: buildAgentToolRuntimeMock() + }) + const subagentCapability = resolveDeepChatSubagentCapability({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: 'Review the result.' + } + ] + }) + + const defs = await toolPresenter.getAllToolDefinitions({ + disabledAgentTools: [SUBAGENT_ORCHESTRATOR_TOOL_NAME], + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: null, + conversationId: 'conv-1', + subagentCapability + }) + const orchestrators = defs.filter( + (definition) => definition.function.name === SUBAGENT_ORCHESTRATOR_TOOL_NAME + ) + + expect(orchestrators).toHaveLength(1) + expect(orchestrators[0]).toMatchObject({ + source: 'agent', + server: { name: 'agent-subagents' } + }) + + const configurable = await toolPresenter.getConfigurableAgentToolDefinitions({ + chatMode: 'agent', + supportsVision: false, + agentWorkspacePath: null, + conversationId: 'conv-1' + }) + expect( + configurable.some( + (definition) => definition.function.name === SUBAGENT_ORCHESTRATOR_TOOL_NAME + ) + ).toBe(false) + }) + it('keeps the recall pair in the runtime catalog despite stale disabled values', async () => { const toolPresenter = new ToolPresenter({ mcpPresenter: { diff --git a/test/main/routes/chatService.test.ts b/test/main/routes/chatService.test.ts index 8e0d11a39..db8afa6d3 100644 --- a/test/main/routes/chatService.test.ts +++ b/test/main/routes/chatService.test.ts @@ -10,7 +10,6 @@ const createSession = (): SessionWithState => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1, diff --git a/test/main/routes/contracts.test.ts b/test/main/routes/contracts.test.ts index 3776bc31f..37db961df 100644 --- a/test/main/routes/contracts.test.ts +++ b/test/main/routes/contracts.test.ts @@ -49,6 +49,20 @@ import { import { SessionGenerationSettingsPatchSchema } from '@shared/contracts/common' describe('main kernel contracts', () => { + it('accepts and ignores the retired Session-level Subagent input', () => { + expect( + sessionsCreateRoute.input.parse({ + agentId: 'deepchat', + message: 'hello', + subagentEnabled: true + }) + ).toEqual({ + agentId: 'deepchat', + message: 'hello' + }) + expect(DEEPCHAT_ROUTE_CATALOG).not.toHaveProperty('sessions.setSubagentEnabled') + }) + it('registers typed route catalog entries through phase4', () => { const routeKeys = Object.keys(DEEPCHAT_ROUTE_CATALOG).sort() @@ -195,7 +209,6 @@ describe('main kernel contracts', () => { 'sessions.setModel', 'sessions.setPermissionMode', 'sessions.setProjectDir', - 'sessions.setSubagentEnabled', 'sessions.steerPendingInput', 'sessions.togglePinned', 'sessions.translateText', diff --git a/test/main/routes/dispatcher.test.ts b/test/main/routes/dispatcher.test.ts index 1bb19d8cd..5f066873d 100644 --- a/test/main/routes/dispatcher.test.ts +++ b/test/main/routes/dispatcher.test.ts @@ -428,7 +428,6 @@ function createRuntime() { isDraft: false, sessionKind: 'regular' as const, parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2, @@ -534,7 +533,6 @@ function createRuntime() { setAcpSessionConfigOption: vi.fn().mockResolvedValue(null), getPermissionMode: vi.fn().mockResolvedValue('full_access'), setPermissionMode: vi.fn().mockResolvedValue(undefined), - setSessionSubagentEnabled: vi.fn().mockResolvedValue(sessionSnapshot), setSessionModel: vi.fn().mockResolvedValue(sessionSnapshot), setSessionProjectDir: vi.fn().mockResolvedValue(sessionSnapshot), getSessionGenerationSettings: vi.fn().mockResolvedValue({ @@ -4178,7 +4176,6 @@ describe('dispatchDeepchatRoute', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2, diff --git a/test/main/shared/deepchatSubagents.test.ts b/test/main/shared/deepchatSubagents.test.ts new file mode 100644 index 000000000..5d37315fd --- /dev/null +++ b/test/main/shared/deepchatSubagents.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { + resolveDeepChatSubagentCapability, + type ResolveDeepChatSubagentCapabilityInput +} from '@shared/lib/deepchatSubagents' +import type { DeepChatSubagentSlot } from '@shared/types/agent-interface' + +const reviewerSlot: DeepChatSubagentSlot = { + id: 'reviewer', + targetType: 'agent', + targetAgentId: 'review-agent', + displayName: 'Reviewer', + description: 'Review the result.' +} + +const explorerSlot: DeepChatSubagentSlot = { + id: 'explorer', + targetType: 'self', + displayName: 'Explorer', + description: 'Collect evidence.' +} + +const availableInput = ( + overrides: Partial = {} +): ResolveDeepChatSubagentCapabilityInput => ({ + agentType: 'deepchat', + sessionKind: 'regular', + agentPolicyEnabled: true, + slots: [reviewerSlot, explorerSlot], + ...overrides +}) + +describe('DeepChat Subagent capability', () => { + it('normalizes and canonicalizes every model-visible slot field in the cache key', () => { + const first = resolveDeepChatSubagentCapability(availableInput()) + const reordered = resolveDeepChatSubagentCapability( + availableInput({ slots: [explorerSlot, reviewerSlot] }) + ) + + expect(first).toMatchObject({ + available: true, + slots: [{ id: 'explorer' }, { id: 'reviewer' }] + }) + expect(reordered.cacheKey).toBe(first.cacheKey) + + const variants: DeepChatSubagentSlot[] = [ + { ...reviewerSlot, id: 'reviewer-2' }, + { ...reviewerSlot, targetAgentId: 'other-agent' }, + { ...reviewerSlot, displayName: 'Security Reviewer' }, + { ...reviewerSlot, description: 'Review security boundaries.' }, + { ...reviewerSlot, targetType: 'self', targetAgentId: undefined } + ] + for (const variant of variants) { + const changed = resolveDeepChatSubagentCapability( + availableInput({ slots: [variant, explorerSlot] }) + ) + expect(changed.cacheKey).not.toBe(first.cacheKey) + } + }) + + it.each([ + ['policy_disabled', { agentPolicyEnabled: false }, 'policy_disabled'], + ['child session', { sessionKind: 'subagent' as const }, 'unsupported_session'], + ['ACP session', { agentType: 'acp' as const }, 'unsupported_session'], + ['invalid slots', { slots: [] }, 'no_valid_slots'] + ])('fails closed for %s', (_case, overrides, expectedReason) => { + const capability = resolveDeepChatSubagentCapability(availableInput(overrides)) + + expect(capability).toMatchObject({ available: false, reason: expectedReason }) + }) +}) diff --git a/test/renderer/components/ChatStatusBar.test.ts b/test/renderer/components/ChatStatusBar.test.ts index b9fd38649..008ec03aa 100644 --- a/test/renderer/components/ChatStatusBar.test.ts +++ b/test/renderer/components/ChatStatusBar.test.ts @@ -45,8 +45,6 @@ type SetupOptions = { activeModelId?: string activeProjectDir?: string | null activePermissionMode?: PermissionMode - activeSessionSubagentEnabled?: boolean - draftSubagentEnabled?: boolean supportsEffort?: boolean setSessionModelError?: Error defaultModel?: { providerId: string; modelId: string } | null @@ -388,20 +386,12 @@ const setup = async (options: SetupOptions = {}) => { modelId: options.activeModelId ?? 'gpt-4', projectDir: options.activeProjectDir ?? options.projectPath ?? null, status: 'idle', - sessionKind: 'regular', - subagentEnabled: options.activeSessionSubagentEnabled === true + sessionKind: 'regular' } : null, setSessionModel: options.setSessionModelError ? vi.fn().mockRejectedValue(options.setSessionModelError) - : vi.fn().mockResolvedValue(undefined), - setSessionSubagentEnabled: vi - .fn() - .mockImplementation(async (_sessionId: string, enabled: boolean) => { - if (sessionStore.activeSession) { - sessionStore.activeSession.subagentEnabled = enabled - } - }) + : vi.fn().mockResolvedValue(undefined) }) const draftStore = reactive({ @@ -418,7 +408,6 @@ const setup = async (options: SetupOptions = {}) => { reasoningVisibility: undefined as 'omitted' | 'summarized' | undefined, verbosity: undefined as 'low' | 'medium' | 'high' | undefined, imageGeneration: undefined as ImageGenerationOptions | undefined, - subagentEnabled: options.draftSubagentEnabled === true, ...options.draftGenerationSettings, updateGenerationSettings: vi.fn((patch: Record) => Object.assign(draftStore, patch) @@ -674,14 +663,10 @@ const setup = async (options: SetupOptions = {}) => { default: defineComponent({ name: 'McpIndicator', props: { - showSystemPromptSection: { type: Boolean, default: false }, - showSubagentToggle: { type: Boolean, default: false }, - subagentEnabled: { type: Boolean, default: false }, - subagentTogglePending: { type: Boolean, default: false } + showSystemPromptSection: { type: Boolean, default: false } }, - emits: ['toggle-subagents'], template: - '
' + '
' }) })) @@ -816,48 +801,13 @@ describe('ChatStatusBar model and session panels', () => { expect(agentSessionPresenter.setPermissionMode).toHaveBeenCalledWith('s1', 'auto_approve') }) - it('routes the subagent toggle through the unified tools panel', async () => { - const active = await setup({ - agentId: 'deepchat', - hasActiveSession: true, - activeSessionSubagentEnabled: true - }) - - const activeIndicator = active.wrapper.find('.mcp-indicator-stub') - expect(activeIndicator.attributes('data-show-subagent-toggle')).toBe('true') - expect(activeIndicator.attributes('data-subagent-enabled')).toBe('true') - expect(active.wrapper.text()).not.toContain('chat.subagents.label') - - await active.wrapper.get('.mcp-subagents-toggle-stub').trigger('click') - await flushPromises() - - expect(active.sessionStore.setSessionSubagentEnabled).toHaveBeenCalledWith('s1', false) - - const draft = await setup({ - agentId: 'deepchat', - hasActiveSession: false, - draftSubagentEnabled: false - }) - - const draftIndicator = draft.wrapper.find('.mcp-indicator-stub') - expect(draftIndicator.attributes('data-show-subagent-toggle')).toBe('true') - expect(draftIndicator.attributes('data-subagent-enabled')).toBe('false') - - await draft.wrapper.get('.mcp-subagents-toggle-stub').trigger('click') - await flushPromises() - - expect(draft.draftStore.subagentEnabled).toBe(true) - }) - - it('hides the subagent toggle for active regular sessions that are not deepchat', async () => { - const active = await setup({ - agentId: 'acp-agent', - hasActiveSession: true, - activeProviderId: 'openai' - }) + it('does not expose Session-level Subagent controls through the unified tools panel', async () => { + const { wrapper } = await setup({ agentId: 'deepchat', hasActiveSession: true }) + const indicator = wrapper.get('.mcp-indicator-stub') - const activeIndicator = active.wrapper.find('.mcp-indicator-stub') - expect(activeIndicator.attributes('data-show-subagent-toggle')).toBe('false') + expect(indicator.attributes()).not.toHaveProperty('data-show-subagent-toggle') + expect(indicator.attributes()).not.toHaveProperty('data-subagent-enabled') + expect(wrapper.find('.mcp-subagents-toggle-stub').exists()).toBe(false) }) it('shows loading state and hides partial model groups before full initialization completes', async () => { diff --git a/test/renderer/components/DeepChatAgentsSettings.test.ts b/test/renderer/components/DeepChatAgentsSettings.test.ts index 6f42c3ca4..81dd41242 100644 --- a/test/renderer/components/DeepChatAgentsSettings.test.ts +++ b/test/renderer/components/DeepChatAgentsSettings.test.ts @@ -1692,6 +1692,101 @@ describe('DeepChatAgentsSettings', () => { expect(payload.config).not.toHaveProperty('assistantModel') }) + it('restores default Subagent slots when enabling an empty legacy policy', async () => { + const existingAgent = { + id: 'deepchat', + type: 'deepchat', + name: 'DeepChat', + enabled: true, + protected: true, + description: '', + avatar: null, + config: { subagentEnabled: false, subagents: [] } + } + const { wrapper, configPresenter } = await mountSettings({ agents: [existingAgent] }) + const subagentSwitch = wrapper.get('[aria-label="settings.deepchatAgents.subagentsEnabled"]') + + expect(subagentSwitch.attributes('data-model-value')).toBe('false') + expect(wrapper.findAll('select')).toHaveLength(0) + + await subagentSwitch.trigger('click') + + expect(subagentSwitch.attributes('data-model-value')).toBe('true') + expect(wrapper.findAll('select')).toHaveLength(3) + expect(wrapper.text()).toContain('explorer') + expect(wrapper.text()).toContain('implementer') + expect(wrapper.text()).toContain('reviewer') + + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().includes('common.save')) + await saveButton!.trigger('click') + await flushPromises() + + expect(configPresenter.updateDeepChatAgent).toHaveBeenCalledWith( + 'deepchat', + expect.objectContaining({ + config: { + subagentEnabled: true, + subagents: [ + expect.objectContaining({ id: 'explorer', targetType: 'self' }), + expect.objectContaining({ id: 'implementer', targetType: 'self' }), + expect.objectContaining({ id: 'reviewer', targetType: 'self' }) + ] + } + }) + ) + }) + + it('protects the final enabled Subagent slot and retains slots while disabled', async () => { + const existingAgent = { + id: 'deepchat', + type: 'deepchat', + name: 'DeepChat', + enabled: true, + protected: true, + description: '', + avatar: null, + config: { + subagentEnabled: true, + subagents: [ + { + id: 'reviewer', + targetType: 'self', + displayName: 'Reviewer', + description: '' + } + ] + } + } + const { wrapper, configPresenter } = await mountSettings({ agents: [existingAgent] }) + const deleteSlotButton = wrapper + .findAll('button') + .find((button) => button.text() === 'common.delete') + + expect(deleteSlotButton).toBeDefined() + expect(deleteSlotButton!.attributes('disabled')).toBeDefined() + await deleteSlotButton!.trigger('click') + expect(wrapper.findAll('select')).toHaveLength(1) + + const subagentSwitch = wrapper.get('[aria-label="settings.deepchatAgents.subagentsEnabled"]') + await subagentSwitch.trigger('click') + + expect(subagentSwitch.attributes('data-model-value')).toBe('false') + expect(wrapper.findAll('select')).toHaveLength(1) + + const saveButton = wrapper + .findAll('button') + .find((button) => button.text().includes('common.save')) + await saveButton!.trigger('click') + await flushPromises() + + expect(configPresenter.updateDeepChatAgent).toHaveBeenCalledWith( + 'deepchat', + expect.objectContaining({ config: { subagentEnabled: false } }) + ) + }) + it('uses a flat target agent select for subagent slots', async () => { vi.resetModules() diff --git a/test/renderer/components/McpIndicator.test.ts b/test/renderer/components/McpIndicator.test.ts index ee7e09e42..0e2b0c22a 100644 --- a/test/renderer/components/McpIndicator.test.ts +++ b/test/renderer/components/McpIndicator.test.ts @@ -53,8 +53,6 @@ const setup = async (options?: { activeAgentId?: string selectedAgentId?: string disabledAgentTools?: string[] - showSubagentToggle?: boolean - subagentEnabled?: boolean pluginEnabled?: boolean regularMcpEnabled?: boolean }) => { @@ -214,7 +212,6 @@ const setup = async (options?: { 'chat.advancedSettings.systemPrompt': 'System Prompt', 'chat.advancedSettings.systemPromptPlaceholder': 'Select preset', 'chat.advancedSettings.currentCustomPrompt': 'Current custom', - 'chat.subagents.label': 'subagent', 'chat.input.mcp.title': 'Enabled MCP', 'chat.input.mcp.empty': 'No enabled services', 'chat.input.mcp.openSettings': 'Open MCP settings', @@ -244,10 +241,6 @@ const setup = async (options?: { const McpIndicator = (await import('@/components/chat-input/McpIndicator.vue')).default const wrapper = mount(McpIndicator, { - props: { - showSubagentToggle: options?.showSubagentToggle ?? false, - subagentEnabled: options?.subagentEnabled ?? false - }, global: { stubs: { Button: ButtonStub, @@ -422,23 +415,16 @@ describe('McpIndicator', () => { expect(agentSessionPresenter.updateSessionDisabledAgentTools).not.toHaveBeenCalled() }) - it('renders subagent as a regular tool button inside Agent Core and emits updates', async () => { + it('does not synthesize a Session-level Subagent tool toggle', async () => { const { wrapper } = await setup({ hasActiveSession: true, - activeAgentId: 'deepchat', - showSubagentToggle: true, - subagentEnabled: true + activeAgentId: 'deepchat' }) expect(wrapper.text()).toContain('Agent Core') - const subagentButton = wrapper.findAll('button').find((node) => node.text() === 'subagent') - - expect(subagentButton).toBeTruthy() - - await subagentButton!.trigger('click') - - expect(wrapper.emitted('toggle-subagents')).toEqual([[false]]) + expect(subagentButton).toBeUndefined() + expect(wrapper.emitted('toggle-subagents')).toBeUndefined() }) it('reloads deepchat tools when the active session emits skill activation changes', async () => { diff --git a/test/renderer/components/NewThreadPage.onboarding.test.ts b/test/renderer/components/NewThreadPage.onboarding.test.ts index 5ba377012..588e5c345 100644 --- a/test/renderer/components/NewThreadPage.onboarding.test.ts +++ b/test/renderer/components/NewThreadPage.onboarding.test.ts @@ -118,7 +118,6 @@ const setup = async () => { modelId: 'gpt-4.1' as string | undefined, permissionMode: 'full_access' as PermissionMode, disabledAgentTools: [] as string[], - subagentEnabled: false, systemPrompt: undefined as string | undefined, temperature: undefined as number | undefined, contextLength: undefined as number | undefined, diff --git a/test/renderer/stores/memoryActivityStore.test.ts b/test/renderer/stores/memoryActivityStore.test.ts index 589e3d419..fd8a25bc4 100644 --- a/test/renderer/stores/memoryActivityStore.test.ts +++ b/test/renderer/stores/memoryActivityStore.test.ts @@ -38,7 +38,6 @@ const makeSession = () => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1 diff --git a/test/renderer/stores/sessionStore.test.ts b/test/renderer/stores/sessionStore.test.ts index e4090d795..739740f69 100644 --- a/test/renderer/stores/sessionStore.test.ts +++ b/test/renderer/stores/sessionStore.test.ts @@ -54,7 +54,6 @@ const createSession = (overrides: Record = {}) => ({ isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 1, @@ -980,7 +979,6 @@ describe('sessionStore streaming cleanup', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2 @@ -1010,7 +1008,6 @@ describe('sessionStore streaming cleanup', () => { isDraft: false, sessionKind: 'regular', parentSessionId: null, - subagentEnabled: false, subagentMeta: null, createdAt: 1, updatedAt: 2