diff --git a/docs/specs/subagent-orchestrator/plan.md b/docs/specs/subagent-orchestrator/plan.md new file mode 100644 index 000000000..439b34356 --- /dev/null +++ b/docs/specs/subagent-orchestrator/plan.md @@ -0,0 +1,200 @@ +# Subagent Orchestrator V1 实施计划 + +## 1. 基线 + +当前代码已经具备: + +1. `new_sessions` + `deepchat_sessions` 的新会话栈 +2. `NewAgentPresenter` 负责 session 生命周期与 renderer IPC +3. `ToolPresenter -> AgentToolManager` 的 agent tool 路由 +4. `DeepChatAgentPresenter` 的 tool call / permission / question / resume 流 +5. renderer 的 `MessageBlockToolCall`、`ChatToolInteractionOverlay`、`WorkspacePanel` + +缺的部分是: + +1. session 级 subagent 元数据 +2. orchestrator tool 和 child session runtime bridge +3. child 输出到 parent tool block 的 main-only progress 通道 +4. renderer 对 subagent cards / workspace child list / back link 的展示 + +## 2. 设计决策 + +### 2.1 数据层 + +在 `new_sessions` 保存 parent/child 关系,而不是新建额外表: + +1. child session 已经是完整真实 session +2. 删除、恢复、状态读取都能沿用现有 session 生命周期 +3. workspace 列表和 `getSessionList()` 只需增加过滤 + +### 2.2 Runtime Port 扩展 + +不让 `AgentToolManager` 直接依赖 presenter 实例,继续通过 `AgentToolRuntimePort` 间接访问: + +1. 查询当前会话是否允许 subagent tool +2. 创建 child session +3. 发送 child handoff message +4. 读取 parent/child session 信息 + +### 2.3 进度桥接 + +采用 main-only event: + +1. `dispatch.flushBlocksToRenderer()` 在 child assistant block 流式刷新时,同时发 main-only event +2. `DeepChatAgentPresenter.setSessionStatus()` 和 `emitMessageRefresh()` 也发 main-only event +3. orchestrator 订阅这些事件,维护自己的内存态 queue + +这样避免: + +1. renderer 反向回传 +2. 轮询数据库 +3. 额外的 child polling loop + +### 2.4 Tool Progress 写回 + +tool 执行时通过 `IToolPresenter.callTool(..., { onProgress })` 回调: + +1. `dispatch.executeTools()` 收到 progress 时直接更新当前 tool_call block 的 `extra.subagentProgress` +2. tool 完成时写 `extra.subagentFinal` +3. `tool_call.response` 始终保留最终 markdown 汇总文本 + +### 2.5 Renderer Session 状态 + +`sessionStore.sessions` 继续只存 sidebar 可见的 regular sessions;另增 `activeSessionRecord`: + +1. sidebar 默认不显示 child +2. 当当前激活的是 child 时,`activeSession` 仍能拿到完整会话记录 +3. workspace 和 top bar 都能正确读取 child 的 parent metadata + +### 2.6 Workspace 子会话列表 + +在 `WorkspacePanel` 左侧导航追加 `Subagents` section,而不是新建独立侧栏: + +1. 信息层级与 artifacts/files/git 一致 +2. 与 parent workspace 语义自然贴近 +3. 避免引入新的 sidepanel store 结构 + +## 3. 分层改造 + +### Phase 1:Shared & Storage + +1. 扩展 `DeepChatAgentConfig`、session record、tool progress、presenter interface +2. 更新 `new_sessions` schema / migration / table accessors +3. 扩展 `NewSessionManager` 与 `NewAgentPresenter` 的 create/list/delete API + +### Phase 2:Tool Runtime + +1. 扩展 `AgentToolRuntimePort` +2. 在 `AgentToolManager` 中新增 `subagent_orchestrator` schema、definition、gating +3. 实现 orchestrator 执行器: + - slot 校验 + - child session 创建 + - structured handoff + - parallel / chain 调度 + - progress snapshot 生成 + - final markdown 汇总 + +### Phase 3:DeepChat Progress Bridge + +1. 增加 main-only subagent runtime event 常量 +2. `dispatch` 在 child assistant block streaming 时发 event +3. `DeepChatAgentPresenter` 在 message refresh / session status change 时发 event +4. `dispatch.executeTools()` 与 `executeDeferredToolCall()` 透传 `onProgress` / `signal` + +### Phase 4:Renderer + +1. `DeepChatAgentsSettings` 增加 subagent settings UI +2. `draftStore` / `NewThreadPage` / `ChatStatusBar` 支持 session-level subagent toggle +3. `MessageBlockToolCall` 对 `subagent_orchestrator` 渲染 card 视图 +4. `ChatPage` 扩展 pending interaction 扫描到 child progress +5. `WorkspacePanel` 增加 `Subagents` section +6. `ChatTopBar` 在 child session 显示 `Back to Parent` + +## 4. 关键实现细节 + +### 4.1 child handoff 模板 + +模板字段固定: + +1. Parent summary +2. Slot role +3. Task title +4. Task prompt +5. Expected output +6. Workspace path + +模板输出必须英文,避免模型在 child 里混杂工具协议与 UI 本地化文案。 + +### 4.2 preview 行提取 + +从 child 最近一次 assistant blocks 中提取展示行: + +1. `content` +2. `reasoning_content` +3. `action.content` +4. `tool_call.response` + +做法: + +1. 按 block 顺序拼平为文本行 +2. 去空白行 +3. 仅保留最近 3 行 + +### 4.3 waiting 状态判断 + +优先级: + +1. 当前存在 pending `tool_call_permission` -> `waiting_permission` +2. 当前存在 pending `question_request` -> `waiting_question` +3. runtime status=`generating` -> `running` +4. aborted by signal -> `cancelled` +5. runtime status=`error` -> `error` +6. 否则 `completed` + +### 4.4 父删子级联 + +在 `NewAgentPresenter.deleteSession()` 递归查 child: + +1. 父删除时先深度删除所有 child +2. child 单独删除不反查父 +3. 删除 child 时不删除 parent + +## 5. 测试策略 + +### Main + +1. SQLite migration / default columns +2. `NewSessionManager` 读写新字段 +3. `NewAgentPresenter.getSessionList()` 过滤与级联删除 +4. `AgentToolManager` tool gating +5. `subagent_orchestrator` 的 parallel / chain 执行顺序 +6. progress snapshot 与 preview 裁剪 + +### Renderer + +1. `MessageBlockToolCall` subagent card 渲染与自动折叠 +2. `ChatPage` overlay 从 `subagentProgress` 提取 child interaction +3. `WorkspacePanel` 子会话列表点击切换 +4. `ChatTopBar` child session back-to-parent +5. `DeepChatAgentsSettings` slot 默认值、上限和保存载入 + +## 6. 风险与缓解 + +1. 风险:active child session 不在 sidebar sessions 中,导致顶部/状态栏读不到 session。 + 缓解:session store 维护独立 `activeSessionRecord`。 + +2. 风险:tool progress 写回过于频繁,造成 renderer 抖动。 + 缓解:只在 child blocks/status 真变化时发 progress;preview 只保留 3 行。 + +3. 风险:父会话取消时 child 仍继续跑。 + 缓解:`signal` 中断 orchestrator;对子 session 调 `cancelGeneration()` 并标记 `cancelled`。 + +4. 风险:slot 指向 ACP 或禁用 agent。 + 缓解:tool gating 与执行前双重过滤,无效 slot 不暴露、不执行。 + +## 7. 验证门槛 + +1. `pnpm run format` +2. `pnpm run i18n` +3. `pnpm run lint` +4. 关键 main / renderer 测试通过 diff --git a/docs/specs/subagent-orchestrator/spec.md b/docs/specs/subagent-orchestrator/spec.md new file mode 100644 index 000000000..d86aa2e4f --- /dev/null +++ b/docs/specs/subagent-orchestrator/spec.md @@ -0,0 +1,296 @@ +# Subagent Orchestrator V1 规格 + +## 背景 + +现有多工具式 subagent 方案把 child session 管理、等待、汇总拆散给模型,导致: + +1. 工具暴露面过大,模型需要自己编排 `run / wait / cancel / list` +2. parent loop 会过早接触中间结果,增加 prompt 噪音 +3. child session 与父会话的 UI 关联不够稳定,权限/提问桥接也不够直接 + +V1 目标是把能力收口为单一 agent tool `subagent_orchestrator`,由 tool 内部完成 child session 编排、监听和最终汇总。 + +## 用户故事 + +1. 作为 DeepChat regular session 用户,我希望模型只调用一个 subagent tool,就能并行或串行分派多个子任务。 +2. 作为父会话用户,我希望看到 child 的实时进度卡片,但模型只在全部 child 结束后收到一次最终汇总。 +3. 作为需要审批/回答问题的用户,我希望仍然只在父会话顶部 overlay 处理交互,不需要切到 child 才能继续。 +4. 作为查看上下文的用户,我希望 child 是真实独立 session,能在右侧 workspace 的 `Subagents` 区查看、切换、回到父会话。 + +## 范围 + +### In + +1. 单一公开 agent tool:`subagent_orchestrator` +2. 两种执行模式:`parallel`、`chain` +3. DeepChat agent 配置中的 subagent slot 管理 +4. parent -> child 单层关联 +5. main-process 内部 child 输出监听 +6. 父会话 tool block 进度卡片、顶部 overlay 桥接、workspace 子会话列表 + +### Out + +1. `expanded` 模式 +2. child 再派生 child +3. 多层树状工作流 +4. parent loop 消费 partial child tool result +5. card 内直接批准权限或直接回答问题 + +## 约束 + +1. 单次 `subagent_orchestrator` 最多 5 个 task +2. agent 配置 slot 最多 5 条 +3. 只允许引用当前父会话 agent 配置里已启用的 slot +4. 只有 `sessionKind='regular'` 且 `agentType='deepchat'` 且 `subagentEnabled=true` 的父会话暴露该工具 +5. ACP session 与 subagent child session 不暴露该工具 + +## 配置与数据模型 + +### Agent 配置 + +`DeepChatAgentConfig` 增加: + +1. `subagentEnabled: boolean` +2. `subagents: DeepChatSubagentSlot[]` + +`DeepChatSubagentSlot` 定义: + +1. `id: string` +2. `targetType: 'self' | 'agent'` +3. `targetAgentId?: string` +4. `displayName: string` +5. `description: string` + +规则: + +1. 默认预置 1 条 `self` slot +2. `self` 表示继承父会话 agent 逻辑,但 child 使用独立上下文 +3. 非法 `targetAgentId` 在读取配置和工具执行时都要过滤 + +### Session 输入 + +`CreateSessionInput` / `CreateDetachedSessionInput` 增加: + +1. `subagentEnabled?: boolean` + +### `new_sessions` + +新增列: + +1. `subagent_enabled INTEGER NOT NULL DEFAULT 0` +2. `session_kind TEXT NOT NULL DEFAULT 'regular'` +3. `parent_session_id TEXT` +4. `subagent_meta_json TEXT` + +### Session 读取模型 + +`SessionRecord` / `SessionWithState` 增加: + +1. `sessionKind: 'regular' | 'subagent'` +2. `parentSessionId?: string | null` +3. `subagentEnabled: boolean` +4. `subagentMeta?: { slotId: string; displayName: string; targetAgentId?: string | null } | null` + +## Tool 接口 + +`subagent_orchestrator` 参数固定为: + +```ts +{ + mode: 'parallel' | 'chain' + tasks: Array<{ + id?: string + slotId: string + title: string + prompt: string + expectedOutput?: string + }> +} +``` + +`IToolPresenter.callTool()` 增加可选参数: + +1. `onProgress?: (update: AgentToolProgressUpdate) => void` +2. `signal?: AbortSignal` + +`AgentToolProgressUpdate` V1 只定义: + +```ts +{ + kind: 'subagent_orchestrator' + toolCallId: string + responseMarkdown: string + progressJson: string +} +``` + +`AssistantMessageExtra` 增加: + +1. `subagentProgress?: string` +2. `subagentFinal?: string` + +## Child Session 生命周期 + +### 创建 + +tool 内部通过 runtime port 调 `NewAgentPresenter` 创建 child session: + +1. `self` slot 继承父 `agentId` +2. `agent` slot 使用 `slot.targetAgentId` +3. 继承父 `projectDir` +4. 继承父 session model / permission / generation settings +5. child session 写入 `session_kind='subagent'` +6. child session 写入 `parent_session_id=父 sessionId` +7. child session 写入 `subagent_meta_json` + +### 首条 handoff + +child 首条消息固定使用 structured handoff 模板,仅包含: + +1. 父任务摘要 +2. slot 描述 +3. 当前子任务 +4. 输出契约 +5. 工作区路径 + +不复制完整父 transcript。 + +### 结束态 + +child terminal status 归一为: + +1. `queued` +2. `running` +3. `waiting_permission` +4. `waiting_question` +5. `completed` +6. `error` +7. `cancelled` + +## 执行语义 + +### `parallel` + +1. 立即创建并启动全部 child +2. 并发等待全部 child 完成 +3. child 结果先缓存在 orchestrator 内部 queue +4. 全部结束后按原 `tasks` 顺序聚合最终结果 + +### `chain` + +1. 按 `tasks` 顺序逐个创建 child +2. 每个 child 结束后再启动下一个 +3. 每个结果进入内部 queue +4. 最终仍按原 `tasks` 顺序聚合 + +### 共同行为 + +1. parent loop 不消费 partial child output +2. tool 完成前,父会话只写 progress,不把中间结果作为 tool message 喂给模型 +3. 最终 `tool_call.response` 与 tool output 都使用同一份 markdown 汇总文本 + +## Progress Payload + +`subagentProgress` / `subagentFinal` 使用统一 JSON 结构: + +```ts +{ + runId: string + mode: 'parallel' | 'chain' + tasks: Array<{ + taskId: string + title: string + slotId: string + sessionId: string | null + targetAgentId: string | null + targetAgentName: string + status: 'queued' | 'running' | 'waiting_permission' | 'waiting_question' | 'completed' | 'error' | 'cancelled' + previewMarkdown: string + updatedAt: number + waitingInteraction?: { + messageId: string + toolCallId: string + actionType: 'tool_call_permission' | 'question_request' + toolName: string + toolArgs: string + } | null + resultSummary?: string + }> +} +``` + +规则: + +1. `previewMarkdown` 只保留最近 3 条非空展示行 +2. 进度事件来自 main-process 内部 child 输出观察通道,不依赖 renderer,不轮询 DB + +## 最终汇总 + +最终 markdown 文本按原 `tasks` 顺序输出,每项包含: + +1. 序号 +2. 标题 +3. 子 agent 名称 +4. child sessionId +5. 结果摘要 + +## UI 验收 + +### Agent Settings + +`DeepChat Agents` 扩展 `Subagents` 分区: + +1. 总开关 +2. slot 列表 +3. `+ Add Slot` +4. target agent 选择 +5. 描述编辑 +6. 5 条上限 + +### Session UI + +1. 会话层只保留一个 `Subagents` toggle +2. 只在 DeepChat regular session 可见 +3. ACP 与 child session 隐藏 + +### Tool Block + +`MessageBlockToolCall` 对 `subagent_orchestrator` 特判: + +1. 运行中自动展开 +2. 完成后自动折叠 +3. 详情区显示 subagent cards,不走普通 `pre` +4. summary 固定为 `parallel · N subagents` 或 `chain · N subagents` + +### Overlay + +父会话顶部 overlay 继续复用 `ChatToolInteractionOverlay`: + +1. 同时扫描普通 action block 与 `subagent_orchestrator` 的 `extra.subagentProgress` +2. child 进入等待态时显示待处理项 +3. 响应直接路由到 `respondToolInteraction(childSessionId, ...)` + +### Workspace + +父会话右侧 workspace 增加 `Subagents` section: + +1. 列出当前父会话全部 child +2. 展示 `displayName / target agent / status / updatedAt` +3. 点击切换 child session +4. child 顶部有 `Back to Parent` + +## 兼容与迁移 + +1. 旧库升级后,原有 session 默认 `session_kind='regular'` +2. 旧库升级后,原有 session 默认 `subagent_enabled=0` +3. 左侧 sidebar 默认仍只显示普通会话 +4. 父删子级联;子单删不影响父 + +## 验收标准 + +1. 只有 DeepChat regular session 且 subagentEnabled 打开时,模型能看到 `subagent_orchestrator` +2. `parallel` 并发启动全部 child,`chain` 串行启动 +3. parent tool block 能随 child 输出实时更新 card 预览 +4. child 的 permission/question 会在父会话 overlay 中处理 +5. child session 在 workspace `Subagents` 区可切换 +6. 应用重启后,父子关联与 workspace 子会话列表仍可恢复 diff --git a/docs/specs/subagent-orchestrator/tasks.md b/docs/specs/subagent-orchestrator/tasks.md new file mode 100644 index 000000000..e6b9e7511 --- /dev/null +++ b/docs/specs/subagent-orchestrator/tasks.md @@ -0,0 +1,50 @@ +# Subagent Orchestrator V1 任务拆分 + +## 1. Shared / Schema + +1. 扩展 `DeepChatAgentConfig`、`DeepChatSubagentSlot`、session record、tool progress 类型 +2. 扩展 `INewAgentPresenter` / `IToolPresenter` +3. 为 `new_sessions` 增加 subagent 相关列与迁移测试 + +## 2. Main Session Layer + +1. 更新 `NewSessionsTable` / `NewSessionManager` create-get-list-update +2. 更新 `NewAgentPresenter`: + - create session / detached session 支持 `subagentEnabled` + - `getSessionList()` 支持 `includeSubagents` / `parentSessionId` + - `setSessionSubagentEnabled()` + - 父删子级联 +3. 增加 runtime port 的 subagent session helper + +## 3. Tool Runtime + +1. 在 `AgentToolManager` 增加 `subagent_orchestrator` tool schema / definition / gating +2. 实现 slot 校验与 `self` slot 继承逻辑 +3. 实现 child session 创建、handoff、parallel / chain 调度 +4. 生成 progress payload 与 final markdown +5. 接入 abort signal + +## 4. DeepChat Bridge + +1. 定义 main-only subagent runtime events +2. `dispatch` 发 child block update event +3. `DeepChatAgentPresenter` 发 child status / refresh event +4. `dispatch.executeTools()` / `executeDeferredToolCall()` 接 `onProgress` +5. 把 `subagentProgress` / `subagentFinal` 写回 assistant block extra + +## 5. Renderer + +1. `draftStore` / `NewThreadPage` / `ChatStatusBar` 增加 session-level subagent toggle +2. `DeepChatAgentsSettings` 增加 subagent settings UI +3. `MessageBlockToolCall` 渲染 subagent cards +4. `ChatPage` overlay bridge 扫描 child waiting interaction +5. `WorkspacePanel` 增加 `Subagents` section +6. `ChatTopBar` 增加 `Back to Parent` + +## 6. Tests & Validation + +1. main:migration / presenter / tool execution / progress +2. renderer:tool cards / overlay / workspace / settings +3. 运行 `pnpm run format` +4. 运行 `pnpm run i18n` +5. 运行 `pnpm run lint` diff --git a/src/main/presenter/agentRepository/index.ts b/src/main/presenter/agentRepository/index.ts index ceb5ddc84..effaf9592 100644 --- a/src/main/presenter/agentRepository/index.ts +++ b/src/main/presenter/agentRepository/index.ts @@ -13,6 +13,7 @@ import type { CreateDeepChatAgentInput, UpdateDeepChatAgentInput } from '@shared/types/agent-interface' +import { normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' import type { SQLitePresenter } from '../sqlitePresenter' import type { AgentRow } from '../sqlitePresenter/tables/agents' @@ -63,25 +64,28 @@ const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T const mergeDeepChatConfig = ( baseConfig: DeepChatAgentConfig, overrideConfig: DeepChatAgentConfig -): DeepChatAgentConfig => ({ - defaultModelPreset: overrideConfig.defaultModelPreset ?? baseConfig.defaultModelPreset ?? null, - assistantModel: overrideConfig.assistantModel ?? baseConfig.assistantModel ?? null, - visionModel: overrideConfig.visionModel ?? baseConfig.visionModel ?? null, - defaultProjectPath: overrideConfig.defaultProjectPath ?? baseConfig.defaultProjectPath ?? null, - systemPrompt: overrideConfig.systemPrompt ?? baseConfig.systemPrompt ?? '', - permissionMode: overrideConfig.permissionMode ?? baseConfig.permissionMode ?? 'full_access', - disabledAgentTools: overrideConfig.disabledAgentTools ?? baseConfig.disabledAgentTools ?? [], - autoCompactionEnabled: - overrideConfig.autoCompactionEnabled ?? baseConfig.autoCompactionEnabled ?? true, - autoCompactionTriggerThreshold: - overrideConfig.autoCompactionTriggerThreshold ?? - baseConfig.autoCompactionTriggerThreshold ?? - 80, - autoCompactionRetainRecentPairs: - overrideConfig.autoCompactionRetainRecentPairs ?? - baseConfig.autoCompactionRetainRecentPairs ?? - 2 -}) +): DeepChatAgentConfig => + normalizeDeepChatSubagentConfig({ + defaultModelPreset: overrideConfig.defaultModelPreset ?? baseConfig.defaultModelPreset ?? null, + assistantModel: overrideConfig.assistantModel ?? baseConfig.assistantModel ?? null, + visionModel: overrideConfig.visionModel ?? baseConfig.visionModel ?? null, + defaultProjectPath: overrideConfig.defaultProjectPath ?? baseConfig.defaultProjectPath ?? null, + systemPrompt: overrideConfig.systemPrompt ?? baseConfig.systemPrompt ?? '', + permissionMode: overrideConfig.permissionMode ?? baseConfig.permissionMode ?? 'full_access', + disabledAgentTools: overrideConfig.disabledAgentTools ?? baseConfig.disabledAgentTools ?? [], + subagentEnabled: overrideConfig.subagentEnabled ?? baseConfig.subagentEnabled ?? false, + subagents: overrideConfig.subagents ?? baseConfig.subagents ?? [], + autoCompactionEnabled: + overrideConfig.autoCompactionEnabled ?? baseConfig.autoCompactionEnabled ?? true, + autoCompactionTriggerThreshold: + overrideConfig.autoCompactionTriggerThreshold ?? + baseConfig.autoCompactionTriggerThreshold ?? + 80, + autoCompactionRetainRecentPairs: + overrideConfig.autoCompactionRetainRecentPairs ?? + baseConfig.autoCompactionRetainRecentPairs ?? + 2 + }) export class AgentRepository { constructor(private readonly sqlitePresenter: SQLitePresenter) {} @@ -192,7 +196,8 @@ export class AgentRepository { if (!row || row.agent_type !== 'deepchat') { return null } - return parseJson(row.config_json) + const config = parseJson(row.config_json) + return config ? normalizeDeepChatSubagentConfig(config) : null } resolveDeepChatAgentConfig(agentId: string): DeepChatAgentConfig { diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 353f3ac99..6a2125ebf 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -54,6 +54,7 @@ import { AcpLaunchSpecService } from './acpLaunchSpecService' import { AcpProvider } from '../llmProviderPresenter/providers/acpProvider' import { resolveAcpAgentAlias } from './acpRegistryConstants' import { AgentRepository, BUILTIN_DEEPCHAT_AGENT_ID } from '../agentRepository' +import { normalizeDeepChatSubagentConfig } from '@shared/lib/deepchatSubagents' import type { HookEventName, HookTestResult, @@ -443,7 +444,7 @@ export class ConfigPresenter implements IConfigPresenter { const autoCompactionTriggerThreshold = this.store.get('autoCompactionTriggerThreshold') const autoCompactionRetainRecentPairs = this.store.get('autoCompactionRetainRecentPairs') - return { + return normalizeDeepChatSubagentConfig({ defaultModelPreset: defaultModel?.providerId && defaultModel?.modelId ? { @@ -474,7 +475,7 @@ export class ConfigPresenter implements IConfigPresenter { typeof autoCompactionTriggerThreshold === 'number' ? autoCompactionTriggerThreshold : 80, autoCompactionRetainRecentPairs: typeof autoCompactionRetainRecentPairs === 'number' ? autoCompactionRetainRecentPairs : 2 - } + }) } private syncRegistryAgentsToRepository( diff --git a/src/main/presenter/deepchatAgentPresenter/dispatch.ts b/src/main/presenter/deepchatAgentPresenter/dispatch.ts index d47af2f1c..7e893058c 100644 --- a/src/main/presenter/deepchatAgentPresenter/dispatch.ts +++ b/src/main/presenter/deepchatAgentPresenter/dispatch.ts @@ -1,10 +1,16 @@ import { eventBus, SendTarget } from '@/eventbus' import { STREAM_EVENTS } from '@/events' import { presenter } from '@/presenter' -import type { MCPToolCall, MCPContentItem, MCPResourceContent } from '@shared/types/core/mcp' +import type { + MCPToolCall, + MCPContentItem, + MCPResourceContent, + MCPToolResponse +} from '@shared/types/core/mcp' import type { MCPToolDefinition } from '@shared/types/core/mcp' import type { SearchResult } from '@shared/types/core/search' import type { IToolPresenter } from '@shared/types/presenters/tool.presenter' +import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' import type { AssistantMessageBlock, PermissionMode } from '@shared/types/agent-interface' import { parseQuestionToolArgs, QUESTION_TOOL_NAME } from '../../lib/agentRuntime/questionTool' import type { @@ -19,6 +25,12 @@ import { nanoid } from 'nanoid' import type { ToolBatchOutputFitItem, ToolOutputGuard } from './toolOutputGuard' import { buildTerminalErrorBlocks } from './messageStore' import { finalizeTrailingPendingNarrativeBlocks } from './accumulator' +import { + buildAssistantPreviewMarkdown, + buildAssistantResponseMarkdown, + emitDeepChatInternalSessionUpdate, + extractWaitingInteraction +} from './internalSessionEvents' type PermissionType = 'read' | 'write' | 'all' | 'command' @@ -206,6 +218,46 @@ function updateToolCallBlock( } } +function updateSubagentToolCallBlock( + blocks: AssistantMessageBlock[], + toolCallId: string, + responseMarkdown: string, + progressJson?: string, + finalJson?: string +): void { + const block = blocks.find( + (item) => item.type === 'tool_call' && item.tool_call?.id === toolCallId + ) + if (!block?.tool_call) { + return + } + + block.tool_call.response = responseMarkdown + block.status = typeof finalJson === 'string' ? 'success' : 'loading' + block.extra = { + ...block.extra, + ...(typeof progressJson === 'string' ? { subagentProgress: progressJson } : {}), + ...(typeof finalJson === 'string' ? { subagentFinal: finalJson } : {}) + } +} + +function extractSubagentToolState(rawData: MCPToolResponse): { + subagentProgress?: string + subagentFinal?: string +} { + const toolResult = + rawData.toolResult && typeof rawData.toolResult === 'object' + ? (rawData.toolResult as Record) + : null + + return { + subagentProgress: + typeof toolResult?.subagentProgress === 'string' ? toolResult.subagentProgress : undefined, + subagentFinal: + typeof toolResult?.subagentFinal === 'string' ? toolResult.subagentFinal : undefined + } +} + function persistToolExecutionState(io: IoParams, state: StreamState): void { if (!state.dirty) { return @@ -496,6 +548,16 @@ function flushBlocksToRenderer(io: IoParams, blocks: AssistantMessageBlock[]): v messageId: io.messageId, blocks: JSON.parse(JSON.stringify(blocks)) }) + + emitDeepChatInternalSessionUpdate({ + sessionId: io.sessionId, + kind: 'blocks', + updatedAt: Date.now(), + messageId: io.messageId, + previewMarkdown: buildAssistantPreviewMarkdown(blocks), + responseMarkdown: buildAssistantResponseMarkdown(blocks), + waitingInteraction: extractWaitingInteraction(blocks, io.messageId) + }) } export async function executeTools( @@ -661,7 +723,25 @@ export async function executeTools( params: tc.arguments }) - const toolCallResult = await toolPresenter.callTool(toolCall) + const applyProgressUpdate = (update: AgentToolProgressUpdate) => { + if (update.kind !== 'subagent_orchestrator' || update.toolCallId !== tc.id) { + return + } + + updateSubagentToolCallBlock( + state.blocks, + tc.id, + update.responseMarkdown, + update.progressJson + ) + flushBlocksToRenderer(io, state.blocks) + io.messageStore.updateAssistantContent(io.messageId, state.blocks) + } + + const toolCallResult = await toolPresenter.callTool(toolCall, { + onProgress: applyProgressUpdate, + signal: io.abortSignal + }) let toolRawData = toolCallResult.rawData if (toolRawData?.requiresPermission) { @@ -677,7 +757,10 @@ export async function executeTools( if (pendingPermission) { if (permissionMode === 'full_access') { await autoGrantPermission(io.sessionId, pendingPermission) - const retryCallResult = await toolPresenter.callTool(toolCall) + const retryCallResult = await toolPresenter.callTool(toolCall, { + onProgress: applyProgressUpdate, + signal: io.abortSignal + }) toolRawData = retryCallResult.rawData } else { hooks?.onPermissionRequest?.(pendingPermission, { @@ -698,6 +781,19 @@ export async function executeTools( } } + const subagentState = extractSubagentToolState(toolRawData) + if (subagentState.subagentProgress || subagentState.subagentFinal) { + updateSubagentToolCallBlock( + state.blocks, + tc.id, + typeof toolRawData.content === 'string' + ? toolRawData.content + : toolResponseToText(toolRawData.content), + subagentState.subagentProgress, + subagentState.subagentFinal + ) + } + if (hooks?.normalizeToolResult) { toolRawData = { ...toolRawData, diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index eaf527c24..06dead6fa 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -53,6 +53,12 @@ import type { ProviderRequestTracePayload } from '../llmProviderPresenter/reques import type { NewSessionHooksBridge } from '../hooksNotifications/newSessionBridge' import { providerDbLoader } from '../configPresenter/providerDbLoader' import { resolveSessionVisionTarget } from '../vision/sessionVisionResolver' +import { + buildAssistantPreviewMarkdown, + buildAssistantResponseMarkdown, + emitDeepChatInternalSessionUpdate, + extractWaitingInteraction +} from './internalSessionEvents' type PendingInteractionEntry = { interaction: PendingToolInteraction @@ -130,6 +136,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private readonly runtimeState: Map = new Map() private readonly sessionGenerationSettings: Map = new Map() private readonly abortControllers: Map = new Map() + private readonly deferredToolAbortControllers: Map = new Map() private readonly activeGenerations: Map = new Map() private readonly sessionAgentIds: Map = new Map() private readonly sessionProjectDirs: Map = new Map() @@ -236,6 +243,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { controller.abort() this.abortControllers.delete(sessionId) } + this.abortDeferredToolAbortControllers(sessionId) this.activeGenerations.delete(sessionId) this.pendingInputCoordinator.deleteBySession(sessionId) @@ -630,7 +638,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { params: toolCall.params } }) - const execution = await this.executeDeferredToolCall(sessionId, toolCall) + const execution = await this.executeDeferredToolCall(sessionId, messageId, toolCall) if (execution.terminalError) { this.dispatchHook('PostToolUseFailure', { sessionId, @@ -896,6 +904,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.abortControllers.delete(sessionId) } } + this.abortDeferredToolAbortControllers(sessionId) this.setSessionStatus(sessionId, 'idle') } @@ -1029,6 +1038,48 @@ export class DeepChatAgentPresenter implements IAgentImplementation { ) } + private buildDeferredToolAbortKey(sessionId: string, toolCallId: string): string { + return `${sessionId}:${toolCallId}` + } + + private registerDeferredToolAbortController( + sessionId: string, + toolCallId: string + ): AbortController { + const key = this.buildDeferredToolAbortKey(sessionId, toolCallId) + this.deferredToolAbortControllers.get(key)?.abort() + const controller = new AbortController() + this.deferredToolAbortControllers.set(key, controller) + return controller + } + + private clearDeferredToolAbortController( + sessionId: string, + toolCallId: string, + controller?: AbortController + ): void { + const key = this.buildDeferredToolAbortKey(sessionId, toolCallId) + const current = this.deferredToolAbortControllers.get(key) + if (!current) { + return + } + if (controller && current !== controller) { + return + } + this.deferredToolAbortControllers.delete(key) + } + + private abortDeferredToolAbortControllers(sessionId: string): void { + const prefix = `${sessionId}:` + for (const [key, controller] of this.deferredToolAbortControllers) { + if (!key.startsWith(prefix)) { + continue + } + controller.abort() + this.deferredToolAbortControllers.delete(key) + } + } + private throwIfAbortRequested(signal?: AbortSignal): void { if (signal?.aborted) { throw createAbortError() @@ -2793,6 +2844,47 @@ export class DeepChatAgentPresenter implements IAgentImplementation { toolBlock.status = isError ? 'error' : 'success' } + private updateSubagentToolCallProgress( + sessionId: string, + messageId: string, + toolCallId: string, + responseMarkdown: string, + progressJson?: string, + finalJson?: string + ): void { + try { + const message = this.messageStore.getMessage(messageId) + if (!message || message.role !== 'assistant') { + return + } + + const latestMessage = this.messageStore.getMessage(messageId) + if (!latestMessage || latestMessage.role !== 'assistant') { + return + } + + const blocks = JSON.parse(latestMessage.content) as AssistantMessageBlock[] + const toolBlock = blocks.find( + (block) => block.type === 'tool_call' && block.tool_call?.id === toolCallId + ) + if (!toolBlock?.tool_call) { + return + } + + toolBlock.tool_call.response = responseMarkdown + toolBlock.status = finalJson ? 'success' : 'loading' + toolBlock.extra = { + ...toolBlock.extra, + ...(typeof progressJson === 'string' ? { subagentProgress: progressJson } : {}), + ...(finalJson ? { subagentFinal: finalJson } : {}) + } + this.messageStore.updateAssistantContent(messageId, blocks) + this.emitMessageRefresh(sessionId, messageId) + } catch (error) { + console.warn('[DeepChatAgent] Failed to persist subagent tool progress:', error) + } + } + private async grantPermissionForPayload( sessionId: string, payload: PendingToolInteraction['permission'] | undefined, @@ -2836,6 +2928,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { private async executeDeferredToolCall( sessionId: string, + messageId: string, toolCall: NonNullable ): Promise { if (!this.toolPresenter) { @@ -2893,9 +2986,32 @@ export class DeepChatAgentPresenter implements IAgentImplementation { conversationId: sessionId, providerId: sessionState?.providerId?.trim() || undefined } + const deferredAbortController = toolCall.id + ? this.registerDeferredToolAbortController(sessionId, toolCall.id) + : null + const deferredAbortSignal = + deferredAbortController?.signal ?? this.getAbortSignalForSession(sessionId) try { - const result = await this.toolPresenter.callTool(request) + const result = await this.toolPresenter.callTool(request, { + onProgress: (update) => { + if ( + update.kind !== 'subagent_orchestrator' || + update.toolCallId !== (toolCall.id || '') + ) { + return + } + + this.updateSubagentToolCallProgress( + sessionId, + messageId, + toolCall.id || '', + update.responseMarkdown, + update.progressJson + ) + }, + signal: deferredAbortSignal + }) const rawData = result.rawData as MCPToolResponse if (rawData.requiresPermission) { return { @@ -2905,6 +3021,31 @@ export class DeepChatAgentPresenter implements IAgentImplementation { permissionRequest: rawData.permissionRequest as PendingToolInteraction['permission'] } } + const subagentToolResult = + rawData.toolResult && typeof rawData.toolResult === 'object' + ? (rawData.toolResult as Record) + : null + if (typeof subagentToolResult?.subagentProgress === 'string') { + this.updateSubagentToolCallProgress( + sessionId, + messageId, + toolCall.id || '', + this.toolContentToText(rawData.content), + subagentToolResult.subagentProgress, + typeof subagentToolResult.subagentFinal === 'string' + ? subagentToolResult.subagentFinal + : undefined + ) + } else if (typeof subagentToolResult?.subagentFinal === 'string') { + this.updateSubagentToolCallProgress( + sessionId, + messageId, + toolCall.id || '', + this.toolContentToText(rawData.content), + undefined, + subagentToolResult.subagentFinal + ) + } const normalizedContent = await this.normalizeToolResultContent({ sessionId, toolCallId: toolCall.id || '', @@ -2912,7 +3053,7 @@ export class DeepChatAgentPresenter implements IAgentImplementation { toolArgs: toolCall.params || '{}', content: rawData.content, isError: rawData.isError === true, - abortSignal: this.getAbortSignalForSession(sessionId) + abortSignal: deferredAbortSignal }) const responseText = this.toolContentToText(normalizedContent) const prepared = await this.toolOutputGuard.prepareToolOutput({ @@ -2941,6 +3082,14 @@ export class DeepChatAgentPresenter implements IAgentImplementation { responseText: `Error: ${errorText}`, isError: true } + } finally { + if (toolCall.id) { + this.clearDeferredToolAbortController( + sessionId, + toolCall.id, + deferredAbortController ?? undefined + ) + } } } @@ -3387,6 +3536,12 @@ export class DeepChatAgentPresenter implements IAgentImplementation { sessionId, status }) + emitDeepChatInternalSessionUpdate({ + sessionId, + kind: 'status', + updatedAt: Date.now(), + status + }) try { void presenter.floatingButtonPresenter.refreshWidgetState() @@ -3401,6 +3556,26 @@ export class DeepChatAgentPresenter implements IAgentImplementation { eventId: messageId, messageId }) + + const message = this.messageStore.getMessage(messageId) + if (!message || message.role !== 'assistant') { + return + } + + try { + const blocks = JSON.parse(message.content) as AssistantMessageBlock[] + emitDeepChatInternalSessionUpdate({ + sessionId, + kind: 'blocks', + updatedAt: Date.now(), + messageId, + previewMarkdown: buildAssistantPreviewMarkdown(blocks), + responseMarkdown: buildAssistantResponseMarkdown(blocks), + waitingInteraction: extractWaitingInteraction(blocks, messageId) + }) + } catch (error) { + console.warn('[DeepChatAgent] Failed to emit internal message refresh:', error) + } } private normalizeProjectDir(projectDir?: string | null): string | null { diff --git a/src/main/presenter/deepchatAgentPresenter/internalSessionEvents.ts b/src/main/presenter/deepchatAgentPresenter/internalSessionEvents.ts new file mode 100644 index 000000000..6e25ad526 --- /dev/null +++ b/src/main/presenter/deepchatAgentPresenter/internalSessionEvents.ts @@ -0,0 +1,118 @@ +import { EventEmitter } from 'events' +import type { AssistantMessageBlock } from '@shared/types/agent-interface' + +export type DeepChatInternalSessionRuntimeStatus = 'idle' | 'generating' | 'error' + +export interface DeepChatInternalSessionWaitingInteraction { + type: 'permission' | 'question' + messageId: string + toolCallId: string + actionBlock: AssistantMessageBlock +} + +export interface DeepChatInternalSessionUpdate { + sessionId: string + kind: 'blocks' | 'status' + updatedAt: number + messageId?: string + status?: DeepChatInternalSessionRuntimeStatus + previewMarkdown?: string + responseMarkdown?: string + waitingInteraction?: DeepChatInternalSessionWaitingInteraction | null +} + +const emitter = new EventEmitter() + +const extractBlockText = (block: AssistantMessageBlock): string[] => { + if (block.type === 'action') { + const questionText = + typeof block.extra?.questionText === 'string' ? block.extra.questionText : '' + const permissionText = + typeof block.content === 'string' + ? block.content + : typeof block.extra?.permissionRequest === 'string' + ? block.extra.permissionRequest + : '' + + return [questionText || permissionText] + } + + if (block.type === 'tool_call') { + return [typeof block.tool_call?.response === 'string' ? block.tool_call.response : ''] + } + + if (block.type === 'error') { + return [typeof block.content === 'string' ? block.content : ''] + } + + return [typeof block.content === 'string' ? block.content : ''] +} + +const toDisplayLines = (text: string): string[] => text.split(/\r?\n/) + +export const buildAssistantResponseMarkdown = (blocks: AssistantMessageBlock[]): string => + blocks + .flatMap((block) => extractBlockText(block)) + .flatMap((text) => toDisplayLines(text)) + .join('\n') + +export const buildAssistantPreviewMarkdown = (blocks: AssistantMessageBlock[]): string => { + const lines = buildAssistantResponseMarkdown(blocks) + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + return lines.slice(-3).join('\n') +} + +export const extractWaitingInteraction = ( + blocks: AssistantMessageBlock[], + messageId: string +): DeepChatInternalSessionWaitingInteraction | null => { + for (let index = 0; index < blocks.length; index += 1) { + const block = blocks[index] + if ( + block.type !== 'action' || + block.status !== 'pending' || + block.extra?.needsUserAction !== true || + !block.tool_call?.id + ) { + continue + } + + if (block.action_type === 'tool_call_permission') { + return { + type: 'permission', + messageId, + toolCallId: block.tool_call.id, + actionBlock: JSON.parse(JSON.stringify(block)) as AssistantMessageBlock + } + } + + if (block.action_type === 'question_request') { + return { + type: 'question', + messageId, + toolCallId: block.tool_call.id, + actionBlock: JSON.parse(JSON.stringify(block)) as AssistantMessageBlock + } + } + } + + return null +} + +export const emitDeepChatInternalSessionUpdate = (update: DeepChatInternalSessionUpdate): void => { + try { + emitter.emit('update', update) + } catch (error) { + console.error('[DeepChatInternalSessionEvents] Failed to emit session update:', error) + } +} + +export const subscribeDeepChatInternalSessionUpdates = ( + listener: (update: DeepChatInternalSessionUpdate) => void +): (() => void) => { + emitter.on('update', listener) + return () => emitter.off('update', listener) +} diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 185eb9ccb..8db9dee68 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -71,6 +71,8 @@ import { RemoteControlPresenter } from './remoteControlPresenter' import type { RemoteControlPresenterLike } from './remoteControlPresenter/interface' import { AgentRepository } from './agentRepository' import type { SQLitePresenter } from './sqlitePresenter' +import { normalizeDeepChatSubagentSlots } from '@shared/lib/deepchatSubagents' +import { subscribeDeepChatInternalSessionUpdates } from './deepchatAgentPresenter/internalSessionEvents' // IPC调用上下文接口 interface IPCCallContext { @@ -265,12 +267,68 @@ export class Presenter implements IPresenter { return null } + const agent = await this.configPresenter.getAgent(session.agentId) + const agentType = await this.configPresenter.getAgentType(session.agentId) + const permissionMode = + typeof this.newAgentPresenter?.getPermissionMode === 'function' + ? await this.newAgentPresenter.getPermissionMode(session.id) + : 'full_access' + const generationSettings = + typeof this.newAgentPresenter?.getSessionGenerationSettings === 'function' + ? await this.newAgentPresenter.getSessionGenerationSettings(session.id) + : null + const disabledAgentTools = + typeof this.newAgentPresenter?.getSessionDisabledAgentTools === 'function' + ? await this.newAgentPresenter.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 + ) + : [] + return { + sessionId: session.id, agentId: session.agentId, + agentName: agent?.name?.trim() || session.agentId, + agentType, providerId: session.providerId, - modelId: session.modelId + modelId: session.modelId, + projectDir: session.projectDir ?? null, + permissionMode, + generationSettings, + disabledAgentTools, + activeSkills, + sessionKind: session.sessionKind, + parentSessionId: session.parentSessionId ?? null, + subagentEnabled: session.subagentEnabled, + subagentMeta: session.subagentMeta ?? null, + availableSubagentSlots } }, + createSubagentSession: async (input) => { + const newAgentPresenter = this.newAgentPresenter as INewAgentPresenter & { + createSubagentSession?: (createInput: typeof input) => Promise<{ + id: string + } | null> + } + const created = await newAgentPresenter.createSubagentSession?.(input) + if (!created?.id) { + return null + } + + return await agentToolRuntime.resolveConversationSessionInfo(created.id) + }, + sendConversationMessage: async (conversationId, content) => { + await this.newAgentPresenter.sendMessage(conversationId, content) + }, + cancelConversation: async (conversationId) => { + await this.newAgentPresenter.cancelGeneration(conversationId) + }, + subscribeDeepChatSessionUpdates: (listener) => + subscribeDeepChatInternalSessionUpdates(listener), getSkillPresenter: () => this.skillPresenter, getYoBrowserToolHandler: () => this.yoBrowserPresenter.toolHandler, getFilePresenter: () => ({ diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 5eab1dd43..9b9456a21 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -15,6 +15,7 @@ import type { PermissionMode, SessionCompactionState, SessionGenerationSettings, + DeepChatSubagentMeta, ToolInteractionResponse, ToolInteractionResult, UsageDashboardData, @@ -122,6 +123,11 @@ export class NewAgentPresenter { input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools ) : [] + const subagentEnabled = this.resolveSessionSubagentEnabled( + agentType, + input.subagentEnabled, + deepChatAgentConfig?.subagentEnabled + ) const agent = await this.resolveAgentImplementation(agentId) @@ -160,7 +166,8 @@ export class NewAgentPresenter { const title = normalizedInput.text.slice(0, 50) || 'New Chat' const sessionId = this.sessionManager.create(agentId, title, projectDir, { isDraft: false, - disabledAgentTools + disabledAgentTools, + subagentEnabled }) console.log(`[NewAgentPresenter] session created id=${sessionId} title="${title}"`) @@ -211,6 +218,10 @@ export class NewAgentPresenter { projectDir, isPinned: false, isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled, + subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), status: state?.status ?? 'idle', @@ -255,6 +266,11 @@ export class NewAgentPresenter { input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools ) : [] + const subagentEnabled = this.resolveSessionSubagentEnabled( + agentType, + input.subagentEnabled, + deepChatAgentConfig?.subagentEnabled + ) const agent = await this.resolveAgentImplementation(agentId) const defaultModel = this.configPresenter.getDefaultModel() @@ -288,7 +304,8 @@ export class NewAgentPresenter { const sessionId = this.sessionManager.create(agentId, title, projectDir, { isDraft: false, - disabledAgentTools + disabledAgentTools, + subagentEnabled }) try { @@ -319,6 +336,10 @@ export class NewAgentPresenter { projectDir, isPinned: false, isDraft: false, + sessionKind: 'regular', + parentSessionId: null, + subagentEnabled, + subagentMeta: null, createdAt: Date.now(), updatedAt: Date.now(), status: state?.status ?? 'idle', @@ -327,6 +348,84 @@ export class NewAgentPresenter { } } + async createSubagentSession(input: { + parentSessionId: string + agentId: string + slotId: string + displayName: string + targetAgentId?: string | null + projectDir?: string | null + providerId: string + modelId: string + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools?: string[] + activeSkills?: string[] + }): Promise { + const parentSessionId = input.parentSessionId?.trim() + if (!parentSessionId) { + throw new Error('Subagent session requires a parentSessionId.') + } + + const slotId = input.slotId?.trim() + if (!slotId) { + throw new Error('Subagent session requires a slotId.') + } + + const displayName = input.displayName?.trim() || 'Subagent' + const agentId = input.agentId?.trim() + if (!agentId) { + throw new Error('Subagent session requires an agentId.') + } + + const projectDir = input.projectDir?.trim() || null + const disabledAgentTools = this.normalizeDisabledAgentTools(input.disabledAgentTools) + const subagentMeta: DeepChatSubagentMeta = { + slotId, + displayName, + targetAgentId: input.targetAgentId?.trim() || null + } + + this.assertAcpSessionHasWorkdir(input.providerId, projectDir) + + const agent = await this.resolveAgentImplementation(agentId) + const sessionId = this.sessionManager.create(agentId, displayName, projectDir, { + isDraft: false, + disabledAgentTools, + subagentEnabled: false, + sessionKind: 'subagent', + parentSessionId, + subagentMeta + }) + + try { + await this.initializeSessionRuntime(agent, sessionId, { + agentId, + providerId: input.providerId, + modelId: input.modelId, + projectDir, + permissionMode: input.permissionMode, + generationSettings: input.generationSettings + }) + } catch (error) { + await this.cleanupFailedSessionInitialization(agent, sessionId) + throw error + } + + if (input.activeSkills && input.activeSkills.length > 0 && this.skillPresenter) { + await this.skillPresenter.setActiveSkills(sessionId, input.activeSkills) + } + + this.emitSessionListUpdated() + + const record = this.sessionManager.get(sessionId) + if (!record) { + throw new Error(`Subagent session not found after creation: ${sessionId}`) + } + + return (await this.buildSessionWithState(record)) as SessionWithState + } + async ensureAcpDraftSession(input: { agentId: string projectDir: string @@ -350,7 +449,8 @@ export class NewAgentPresenter { let record = await this.findReusableDraftSession(agentId, projectDir, agent) if (!record) { const sessionId = this.sessionManager.create(agentId, 'New Chat', projectDir, { - isDraft: true + isDraft: true, + subagentEnabled: false }) try { await this.ensureSessionRuntimeInitialized(agent, sessionId, { @@ -657,6 +757,8 @@ export class NewAgentPresenter { async getSessionList(filters?: { agentId?: string projectDir?: string + includeSubagents?: boolean + parentSessionId?: string }): Promise { const records = this.sessionManager.list(filters) const enriched: SessionWithState[] = [] @@ -1031,25 +1133,7 @@ export class NewAgentPresenter { } async deleteSession(sessionId: string): Promise { - const session = this.sessionManager.get(sessionId) - if (!session) return - const agent = await this.resolveAgentImplementation(session.agentId) - const state = await agent.getSessionState(sessionId) - let providerId = state?.providerId ?? '' - if (!providerId) { - if ((await this.getAgentType(session.agentId)) === 'acp') { - providerId = 'acp' - } - } - if (providerId === 'acp') { - await this.llmProviderPresenter.clearAcpSession(sessionId) - } - await agent.destroySession(sessionId) - presenter.commandPermissionService.clearConversation(sessionId) - presenter.filePermissionService?.clearConversation(sessionId) - presenter.settingsPermissionService?.clearConversation(sessionId) - await this.skillPresenter?.clearNewAgentSessionSkills?.(sessionId) - this.sessionManager.delete(sessionId) + await this.deleteSessionInternal(sessionId) this.emitSessionListUpdated() } @@ -1142,6 +1226,35 @@ export class NewAgentPresenter { await agent.setPermissionMode(sessionId, mode) } + async setSessionSubagentEnabled(sessionId: string, enabled: boolean): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + + if (session.sessionKind !== 'regular') { + throw new Error('Only regular sessions can change subagent state.') + } + + if ((await this.getAgentType(session.agentId)) !== 'deepchat') { + throw new Error('Only DeepChat sessions can change subagent state.') + } + + this.sessionManager.update(sessionId, { subagentEnabled: enabled }) + const updated = this.sessionManager.get(sessionId) + if (!updated) { + throw new Error(`Session not found after update: ${sessionId}`) + } + + this.emitSessionListUpdated() + const sessionWithState = await this.tryBuildSessionWithState(updated) + if (!sessionWithState) { + throw new Error(`Failed to build session state for sessionId: ${sessionId}`) + } + + return sessionWithState + } + async setSessionModel( sessionId: string, providerId: string, @@ -1419,6 +1532,53 @@ export class NewAgentPresenter { return Object.keys(merged).length > 0 ? merged : undefined } + private resolveSessionSubagentEnabled( + agentType: 'deepchat' | 'acp' | null, + inputEnabled?: boolean, + configEnabled?: boolean + ): boolean { + if (agentType !== 'deepchat') { + return false + } + + if (typeof inputEnabled === 'boolean') { + return inputEnabled + } + + return configEnabled === true + } + + private async deleteSessionInternal(sessionId: string): Promise { + const session = this.sessionManager.get(sessionId) + if (!session) return + + if (session.sessionKind === 'regular') { + const children = this.sessionManager.list({ + includeSubagents: true, + parentSessionId: sessionId + }) + for (const child of children) { + await this.deleteSessionInternal(child.id) + } + } + + const agent = await this.resolveAgentImplementation(session.agentId) + const state = await agent.getSessionState(sessionId) + let providerId = state?.providerId ?? '' + if (!providerId && (await this.getAgentType(session.agentId)) === 'acp') { + providerId = 'acp' + } + if (providerId === 'acp') { + await this.llmProviderPresenter.clearAcpSession(sessionId) + } + await agent.destroySession(sessionId) + presenter.commandPermissionService.clearConversation(sessionId) + presenter.filePermissionService?.clearConversation(sessionId) + presenter.settingsPermissionService?.clearConversation(sessionId) + await this.skillPresenter?.clearNewAgentSessionSkills?.(sessionId) + this.sessionManager.delete(sessionId) + } + private async isAcpBackedSession(sessionId: string, agentId: string): Promise { const resolvedAgentId = resolveAcpAgentAlias(agentId) const agent = await this.resolveAgentImplementation(agentId) diff --git a/src/main/presenter/newAgentPresenter/sessionManager.ts b/src/main/presenter/newAgentPresenter/sessionManager.ts index 8db5feb11..4bc238d99 100644 --- a/src/main/presenter/newAgentPresenter/sessionManager.ts +++ b/src/main/presenter/newAgentPresenter/sessionManager.ts @@ -1,6 +1,34 @@ import { nanoid } from 'nanoid' import type { SQLitePresenter } from '../sqlitePresenter' -import type { SessionRecord } from '@shared/types/agent-interface' +import type { + DeepChatSubagentMeta, + SessionKind, + SessionRecord +} from '@shared/types/agent-interface' + +const parseSubagentMeta = (raw: string | null | undefined): DeepChatSubagentMeta | null => { + if (!raw) { + return null + } + + try { + const parsed = JSON.parse(raw) as Partial + if (!parsed || typeof parsed !== 'object' || typeof parsed.slotId !== 'string') { + return null + } + + return { + slotId: parsed.slotId, + displayName: typeof parsed.displayName === 'string' ? parsed.displayName : parsed.slotId, + targetAgentId: + parsed.targetAgentId === null || typeof parsed.targetAgentId === 'string' + ? parsed.targetAgentId + : undefined + } + } catch { + return null + } +} export class NewSessionManager { private sqlitePresenter: SQLitePresenter @@ -15,12 +43,23 @@ export class NewSessionManager { agentId: string, title: string, projectDir: string | null, - options?: { isDraft?: boolean; disabledAgentTools?: string[] } + options?: { + isDraft?: boolean + disabledAgentTools?: string[] + subagentEnabled?: boolean + sessionKind?: SessionKind + parentSessionId?: string | null + subagentMeta?: DeepChatSubagentMeta | null + } ): string { const id = nanoid() this.sqlitePresenter.newSessionsTable.create(id, agentId, title, projectDir, { isDraft: options?.isDraft, - disabledAgentTools: options?.disabledAgentTools + disabledAgentTools: options?.disabledAgentTools, + subagentEnabled: options?.subagentEnabled, + sessionKind: options?.sessionKind, + parentSessionId: options?.parentSessionId, + subagentMetaJson: options?.subagentMeta ? JSON.stringify(options.subagentMeta) : null }) this.sqlitePresenter.newEnvironmentsTable.syncPath(projectDir) return id @@ -36,12 +75,21 @@ export class NewSessionManager { projectDir: row.project_dir, isPinned: row.is_pinned === 1, 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 } } - list(filters?: { agentId?: string; projectDir?: string }): SessionRecord[] { + list(filters?: { + agentId?: string + projectDir?: string + includeSubagents?: boolean + parentSessionId?: string + }): SessionRecord[] { const rows = this.sqlitePresenter.newSessionsTable.list(filters) return rows.map((row) => ({ id: row.id, @@ -50,6 +98,10 @@ export class NewSessionManager { projectDir: row.project_dir, isPinned: row.is_pinned === 1, 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 })) @@ -57,7 +109,19 @@ export class NewSessionManager { update( id: string, - fields: Partial> + fields: Partial< + Pick< + SessionRecord, + | 'title' + | 'projectDir' + | 'isPinned' + | 'isDraft' + | 'sessionKind' + | 'parentSessionId' + | 'subagentEnabled' + | 'subagentMeta' + > + > ): void { const current = this.sqlitePresenter.newSessionsTable.get(id) if (!current) { @@ -71,11 +135,25 @@ export class NewSessionManager { 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 } = {} if (fields.title !== undefined) dbFields.title = fields.title 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 + } + if (fields.subagentMeta !== undefined) { + dbFields.subagent_meta_json = fields.subagentMeta ? JSON.stringify(fields.subagentMeta) : null + } this.sqlitePresenter.newSessionsTable.update(id, dbFields) for (const path of this.sqlitePresenter.newEnvironmentsTable.listPathsForSession(id)) { diff --git a/src/main/presenter/sqlitePresenter/tables/baseTable.ts b/src/main/presenter/sqlitePresenter/tables/baseTable.ts index 7727e7236..697f7fa5a 100644 --- a/src/main/presenter/sqlitePresenter/tables/baseTable.ts +++ b/src/main/presenter/sqlitePresenter/tables/baseTable.ts @@ -27,6 +27,17 @@ export abstract class BaseTable { return !!result } + protected hasColumn(columnName: string): boolean { + if (!this.tableExists()) { + return false + } + + const rows = this.db.prepare(`PRAGMA table_info(${this.tableName})`).all() as Array<{ + name: string + }> + return rows.some((row) => row.name === columnName) + } + protected getRecordedSchemaVersion(): number { const versionTable = this.db .prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='schema_versions'`) diff --git a/src/main/presenter/sqlitePresenter/tables/newSessions.ts b/src/main/presenter/sqlitePresenter/tables/newSessions.ts index b700dd35a..89f02ef95 100644 --- a/src/main/presenter/sqlitePresenter/tables/newSessions.ts +++ b/src/main/presenter/sqlitePresenter/tables/newSessions.ts @@ -10,6 +10,10 @@ export interface NewSessionRow { is_draft: number active_skills: string disabled_agent_tools: string + subagent_enabled: number + session_kind: 'regular' | 'subagent' + parent_session_id: string | null + subagent_meta_json: string | null created_at: number updated_at: number } @@ -49,6 +53,14 @@ export class NewSessionsTable extends BaseTable { if (version >= 16) { columns.push("disabled_agent_tools TEXT NOT NULL DEFAULT '[]'") } + if (version >= 20) { + columns.push( + 'subagent_enabled INTEGER NOT NULL DEFAULT 0', + "session_kind TEXT NOT NULL DEFAULT 'regular'", + 'parent_session_id TEXT', + 'subagent_meta_json TEXT' + ) + } columns.push('created_at INTEGER NOT NULL', 'updated_at INTEGER NOT NULL') @@ -71,11 +83,45 @@ export class NewSessionsTable extends BaseTable { if (version === 16) { return `ALTER TABLE new_sessions ADD COLUMN disabled_agent_tools TEXT NOT NULL DEFAULT '[]';` } + if (version === 20) { + return ` + ALTER TABLE new_sessions ADD COLUMN subagent_enabled INTEGER NOT NULL DEFAULT 0; + ALTER TABLE new_sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'regular'; + ALTER TABLE new_sessions ADD COLUMN parent_session_id TEXT; + ALTER TABLE new_sessions ADD COLUMN subagent_meta_json TEXT; + ` + } + if (version === 21) { + if (this.getRecordedSchemaVersion() < 20) { + return null + } + + const statements: string[] = [] + + if (!this.hasColumn('subagent_enabled')) { + statements.push( + 'ALTER TABLE new_sessions ADD COLUMN subagent_enabled INTEGER NOT NULL DEFAULT 0;' + ) + } + if (!this.hasColumn('session_kind')) { + statements.push( + "ALTER TABLE new_sessions ADD COLUMN session_kind TEXT NOT NULL DEFAULT 'regular';" + ) + } + if (!this.hasColumn('parent_session_id')) { + statements.push('ALTER TABLE new_sessions ADD COLUMN parent_session_id TEXT;') + } + if (!this.hasColumn('subagent_meta_json')) { + statements.push('ALTER TABLE new_sessions ADD COLUMN subagent_meta_json TEXT;') + } + + return statements.length > 0 ? statements.join('\n') : null + } return null } getLatestVersion(): number { - return 16 + return 21 } create( @@ -88,6 +134,10 @@ export class NewSessionsTable extends BaseTable { isPinned?: boolean activeSkills?: string[] disabledAgentTools?: string[] + subagentEnabled?: boolean + sessionKind?: 'regular' | 'subagent' + parentSessionId?: string | null + subagentMetaJson?: string | null createdAt?: number updatedAt?: number } @@ -106,9 +156,13 @@ 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, @@ -119,6 +173,10 @@ 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, createdAt, updatedAt ) @@ -130,7 +188,12 @@ export class NewSessionsTable extends BaseTable { | undefined } - list(filters?: { agentId?: string; projectDir?: string }): NewSessionRow[] { + list(filters?: { + agentId?: string + projectDir?: string + includeSubagents?: boolean + parentSessionId?: string + }): NewSessionRow[] { let sql = 'SELECT * FROM new_sessions' const conditions: string[] = [] const params: unknown[] = [] @@ -143,6 +206,13 @@ export class NewSessionsTable extends BaseTable { conditions.push('project_dir = ?') params.push(filters.projectDir) } + if (filters?.includeSubagents !== true && filters?.parentSessionId === undefined) { + conditions.push("session_kind = 'regular'") + } + if (filters?.parentSessionId !== undefined) { + conditions.push('parent_session_id = ?') + params.push(filters.parentSessionId) + } if (conditions.length > 0) { sql += ' WHERE ' + conditions.join(' AND ') @@ -163,6 +233,10 @@ export class NewSessionsTable extends BaseTable { | 'is_draft' | 'active_skills' | 'disabled_agent_tools' + | 'subagent_enabled' + | 'session_kind' + | 'parent_session_id' + | 'subagent_meta_json' > > ): void { @@ -193,6 +267,22 @@ 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) + } + if (fields.parent_session_id !== undefined) { + setClauses.push('parent_session_id = ?') + params.push(fields.parent_session_id) + } + if (fields.subagent_meta_json !== undefined) { + setClauses.push('subagent_meta_json = ?') + params.push(fields.subagent_meta_json) + } if (setClauses.length === 0) return diff --git a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts index 7a25294b8..0fecb7f09 100644 --- a/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts +++ b/src/main/presenter/toolPresenter/agentTools/agentToolManager.ts @@ -1,4 +1,5 @@ import type { IConfigPresenter, MCPToolDefinition } from '@shared/presenter' +import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' import { zodToJsonSchema } from 'zod-to-json-schema' import { z } from 'zod' import fs from 'fs' @@ -21,6 +22,10 @@ import { import type { AgentToolRuntimePort } from '../runtimePorts' import { YO_BROWSER_TOOL_NAMES } from '../../browser/YoBrowserToolDefinitions' import { resolveSessionVisionTarget } from '../../vision/sessionVisionResolver' +import { + SUBAGENT_ORCHESTRATOR_TOOL_NAME, + SubagentOrchestratorTool +} from './subagentOrchestratorTool' // Consider moving to a shared handlers location in future refactoring import { @@ -78,6 +83,7 @@ export class AgentToolManager { private skillTools: SkillTools | null = null private skillExecutionService: SkillExecutionService | null = null private chatSettingsHandler: ChatSettingsToolHandler | null = null + private subagentOrchestratorTool: SubagentOrchestratorTool | null = null private static readonly READ_FILE_AUTO_TRUNCATE_THRESHOLD = 4500 private readonly fileSystemSchemas = { @@ -246,6 +252,7 @@ export class AgentToolManager { this.configPresenter = options.configPresenter this.commandPermissionHandler = options.commandPermissionHandler this.runtimePort = options.runtimePort + this.subagentOrchestratorTool = new SubagentOrchestratorTool(this.runtimePort) if (this.agentWorkspacePath) { this.fileSystemHandler = new AgentFileSystemHandler([this.agentWorkspacePath]) this.bashHandler = new AgentBashHandler( @@ -296,6 +303,20 @@ export class AgentToolManager { // 2. Built-in question tool (all modes) defs.push(...this.getQuestionToolDefinitions()) + // 2.5. Subagent orchestration tool (deepchat regular sessions only) + if (isAgentMode && context.conversationId && this.subagentOrchestratorTool) { + try { + const subagentToolDefinition = await this.subagentOrchestratorTool.getToolDefinition( + context.conversationId + ) + if (subagentToolDefinition) { + defs.push(subagentToolDefinition) + } + } catch (error) { + logger.warn('[AgentToolManager] Failed to resolve subagent tool availability', { error }) + } + } + // 3. Skill tools (agent mode only) if (isAgentMode && this.isSkillsEnabled()) { const skillDefs = this.getSkillToolDefinitions() @@ -351,7 +372,12 @@ export class AgentToolManager { async callTool( toolName: string, args: Record, - conversationId?: string + conversationId?: string, + options?: { + toolCallId?: string + onProgress?: (update: AgentToolProgressUpdate) => void + signal?: AbortSignal + } ): Promise { if (toolName === QUESTION_TOOL_NAME) { const validationResult = questionToolSchema.safeParse(args) @@ -370,6 +396,14 @@ export class AgentToolManager { } } + if (toolName === SUBAGENT_ORCHESTRATOR_TOOL_NAME) { + if (!this.subagentOrchestratorTool) { + throw new Error('Subagent orchestrator is not available.') + } + + return await this.subagentOrchestratorTool.call(args, conversationId, options) + } + // Route to process tool if (this.isProcessTool(toolName)) { return await this.callProcessTool(toolName, args, conversationId) diff --git a/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts new file mode 100644 index 000000000..48a579630 --- /dev/null +++ b/src/main/presenter/toolPresenter/agentTools/subagentOrchestratorTool.ts @@ -0,0 +1,631 @@ +import { nanoid } from 'nanoid' +import { z } from 'zod' +import type { MCPToolDefinition } from '@shared/presenter' +import type { DeepChatSubagentSlot } from '@shared/types/agent-interface' +import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' +import type { AgentToolCallResult } from './agentToolManager' +import type { AgentToolRuntimePort, ConversationSessionInfo } from '../runtimePorts' + +export const SUBAGENT_ORCHESTRATOR_TOOL_NAME = 'subagent_orchestrator' +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.', + 'The child session uses the same working directory as the parent session.' +].join(' ') + +export const subagentOrchestratorTaskSchema = z.object({ + id: z.string().trim().min(1).optional(), + slotId: z.string().trim().min(1), + title: z.string().trim().min(1), + prompt: z.string().trim().min(1), + expectedOutput: z.string().trim().min(1).optional() +}) + +export const subagentOrchestratorSchema = z.object({ + mode: z.enum(['parallel', 'chain']), + tasks: z.array(subagentOrchestratorTaskSchema).min(1).max(5) +}) + +type SubagentOrchestratorArgs = z.infer +type SubagentTerminalStatus = + | 'completed' + | 'error' + | 'cancelled' + | 'waiting_permission' + | 'waiting_question' + | 'running' + | 'queued' + +type MutableTaskState = { + taskId: string + index: number + slotId: string + title: string + prompt: string + expectedOutput?: string + targetAgentId: string | null + targetAgentName: string + slotDescription: string + sessionId: string | null + status: SubagentTerminalStatus + previewMarkdown: string + responseMarkdown: string + updatedAt: number + waitingInteraction: { + type: 'permission' | 'question' + messageId: string + toolCallId: string + } | null + resultSummary?: string + runtimeStatus?: 'idle' | 'generating' | 'error' + started: boolean + cancelRequested: boolean + completion: { + promise: Promise + resolve: () => void + } +} + +const createDeferred = (): MutableTaskState['completion'] => { + let resolve = () => {} + const promise = new Promise((innerResolve) => { + resolve = innerResolve + }) + + return { + promise, + resolve + } +} + +const truncate = (value: string, maxLength: number): string => { + if (value.length <= maxLength) { + return value + } + + return `${value.slice(0, Math.max(0, maxLength - 3)).trimEnd()}...` +} + +const summarizeResult = (value: string): string | undefined => { + const normalized = value.trim() + if (!normalized) { + return undefined + } + + return truncate(normalized, 2000) +} + +const renderProgressMarkdown = ( + mode: SubagentOrchestratorArgs['mode'], + tasks: MutableTaskState[] +): string => { + const lines: string[] = [`${mode} · ${tasks.length} subagents`, ''] + + for (const task of tasks) { + lines.push(`### ${task.index + 1}. ${task.title}`) + lines.push(`- Agent: ${task.targetAgentName}`) + lines.push(`- Status: ${task.status}`) + if (task.sessionId) { + lines.push(`- Session: \`${task.sessionId}\``) + } + + const previewLines = task.previewMarkdown + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + + if (previewLines.length > 0) { + lines.push('') + for (const line of previewLines.slice(-3)) { + lines.push(`> ${line}`) + } + } + + lines.push('') + } + + return lines.join('\n').trim() +} + +const renderFinalMarkdown = ( + mode: SubagentOrchestratorArgs['mode'], + tasks: MutableTaskState[] +): string => { + const lines: string[] = [`${mode} · ${tasks.length} subagents`, ''] + + for (const task of tasks) { + lines.push(`## ${task.index + 1}. ${task.title}`) + lines.push(`Subagent: ${task.targetAgentName}`) + lines.push(`Child Session: \`${task.sessionId ?? 'unknown'}\``) + lines.push(`Status: ${task.status}`) + lines.push('') + lines.push(task.resultSummary?.trim() || '_No result produced._') + lines.push('') + } + + return lines.join('\n').trim() +} + +const buildHandoffMessage = (params: { + parent: ConversationSessionInfo + mode: SubagentOrchestratorArgs['mode'] + totalTasks: number + task: MutableTaskState + inheritedWorkspace: string | null +}): string => { + const contract = + params.task.expectedOutput?.trim() || + 'Return a concise markdown result with your answer, key findings, and any important file paths or commands.' + + return [ + '# Structured Handoff', + '', + 'Parent Task Summary:', + `- The parent session delegated this work through \`${SUBAGENT_ORCHESTRATOR_TOOL_NAME}\`.`, + `- Orchestration mode: ${params.mode}.`, + `- Total delegated tasks in this run: ${params.totalTasks}.`, + '', + 'Slot Description:', + params.task.slotDescription || 'No additional slot description provided.', + '', + 'Current Subtask:', + `Title: ${params.task.title}`, + params.task.prompt, + '', + 'Output Contract:', + contract, + '', + 'Current Agent Working Directory:', + params.inheritedWorkspace?.trim() || '(none)', + '', + 'Rules:', + '- You are a child session with an isolated context.', + '- Do not assume access to the full parent transcript.', + '- Ask for permission or clarification through the normal tool flow when needed.' + ].join('\n') +} + +const isTerminalStatus = (status: SubagentTerminalStatus): boolean => + status === 'completed' || status === 'error' || status === 'cancelled' + +export class SubagentOrchestratorTool { + constructor(private readonly runtimePort: AgentToolRuntimePort) {} + + 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) => ({ + ...slot, + id: slot.id.trim(), + displayName: slot.displayName.trim(), + description: slot.description.trim(), + targetAgentId: slot.targetAgentId?.trim() + })) + .filter((slot) => Boolean(slot.id)) + .sort((left, right) => { + return ( + left.id.localeCompare(right.id) || + left.displayName.localeCompare(right.displayName) || + (left.targetAgentId ?? '').localeCompare(right.targetAgentId ?? '') + ) + }) + + const slotIds = Array.from(new Set(normalizedSlots.map((slot) => slot.id))) + + const slotLines = normalizedSlots.map((slot) => { + const target = + slot.targetType === 'self' + ? 'current agent' + : (slot.targetAgentId?.trim() ?? 'configured agent') + const summaryParts = [`${slot.id}: ${slot.displayName || slot.id}`, `target=${target}`] + if (slot.description) { + const description = slot.description.trim() + summaryParts.push(description) + } + + return `- ${summaryParts.join(' | ')}` + }) + + const description = + slotLines.length > 0 + ? ['Use one of the configured subagent slot IDs for this session.', ...slotLines].join('\n') + : 'Use one of the configured subagent slot IDs for this session.' + + return slotIds.length > 0 + ? { + type: 'string', + enum: slotIds, + description + } + : { + type: 'string', + description + } + } + + async getToolDefinition(conversationId?: string): Promise { + const session = await this.getAvailableSession(conversationId) + if (!session) { + return null + } + + const slotIdParameter = this.buildSlotIdParameter(session.availableSubagentSlots) + + 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. ${SUBAGENT_WORKDIR_RULE}`, + parameters: { + type: 'object', + properties: { + mode: { + type: 'string', + enum: ['parallel', 'chain'], + description: 'Choose whether delegated tasks run concurrently or one by one.' + }, + tasks: { + type: 'array', + maxItems: 5, + description: `Ordered delegated subtasks. ${SUBAGENT_WORKDIR_RULE}`, + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'Optional stable task identifier for this orchestrator run.' + }, + slotId: slotIdParameter, + title: { + type: 'string', + description: + 'Short task label shown in progress cards and the final aggregate result.' + }, + prompt: { + type: 'string', + description: SUBAGENT_PROMPT_DESCRIPTION + }, + expectedOutput: { + type: 'string', + description: + 'Optional output contract for the child session, such as structure, scope, or formatting requirements.' + } + }, + required: ['slotId', 'title', 'prompt'] + } + } + }, + required: ['mode', 'tasks'] + } + }, + server: { + name: 'agent-subagents', + icons: '🧩', + description: 'DeepChat subagent orchestration' + } + } + } + + async call( + rawArgs: Record, + conversationId: string | undefined, + options?: { + toolCallId?: string + onProgress?: (update: AgentToolProgressUpdate) => void + signal?: AbortSignal + } + ): Promise { + const args = subagentOrchestratorSchema.parse(rawArgs) + if (!conversationId) { + throw new Error('subagent_orchestrator requires a conversationId.') + } + + const parent = await this.runtimePort.resolveConversationSessionInfo(conversationId) + if (!parent) { + throw new Error(`Conversation not found: ${conversationId}`) + } + + if ( + parent.agentType !== 'deepchat' || + parent.sessionKind !== 'regular' || + parent.subagentEnabled !== true + ) { + throw new Error( + 'subagent_orchestrator is only available in DeepChat regular sessions with subagents enabled.' + ) + } + + const inheritedWorkspace = + (await this.runtimePort.resolveConversationWorkdir(parent.sessionId))?.trim() || + parent.projectDir?.trim() || + null + + const slotMap = new Map(parent.availableSubagentSlots.map((slot) => [slot.id, slot])) + const now = Date.now() + const tasks = args.tasks.map((task, index): MutableTaskState => { + const slot = slotMap.get(task.slotId) + if (!slot) { + throw new Error(`Subagent slot not found or not enabled: ${task.slotId}`) + } + + const targetAgentId = + slot.targetType === 'self' ? parent.agentId : (slot.targetAgentId?.trim() ?? null) + if (!targetAgentId) { + throw new Error(`Subagent slot is missing a target agent: ${task.slotId}`) + } + + return { + taskId: task.id?.trim() || `task-${index + 1}`, + index, + slotId: task.slotId, + title: task.title, + prompt: task.prompt, + expectedOutput: task.expectedOutput, + targetAgentId, + targetAgentName: slot.displayName || targetAgentId, + slotDescription: slot.description || '', + sessionId: null, + status: 'queued', + previewMarkdown: '', + responseMarkdown: '', + updatedAt: now, + waitingInteraction: null, + started: false, + cancelRequested: false, + completion: createDeferred() + } + }) + + const runId = nanoid() + const toolCallId = options?.toolCallId || '' + const sessionTaskMap = new Map() + + const emitProgress = () => { + if (!options?.onProgress) { + return + } + + const progressPayload = { + runId, + mode: args.mode, + tasks: tasks.map((task) => ({ + taskId: task.taskId, + title: task.title, + slotId: task.slotId, + sessionId: task.sessionId, + targetAgentId: task.targetAgentId, + targetAgentName: task.targetAgentName, + status: task.status, + previewMarkdown: task.previewMarkdown, + updatedAt: task.updatedAt, + waitingInteraction: task.waitingInteraction, + resultSummary: task.resultSummary + })) + } + + options.onProgress({ + kind: 'subagent_orchestrator', + toolCallId, + responseMarkdown: renderProgressMarkdown(args.mode, tasks), + progressJson: JSON.stringify(progressPayload) + }) + } + + const maybeResolveTask = (task: MutableTaskState) => { + if (isTerminalStatus(task.status)) { + task.completion.resolve() + } + } + + const updateTaskStatusFromRuntime = (task: MutableTaskState) => { + if (task.cancelRequested) { + task.status = 'cancelled' + task.resultSummary = task.resultSummary || 'Cancelled by parent session.' + maybeResolveTask(task) + return + } + + if (task.waitingInteraction?.type === 'permission') { + task.status = 'waiting_permission' + return + } + + if (task.waitingInteraction?.type === 'question') { + task.status = 'waiting_question' + return + } + + if (task.runtimeStatus === 'error') { + task.status = 'error' + task.resultSummary = + task.resultSummary || summarizeResult(task.responseMarkdown) || 'Child session failed.' + maybeResolveTask(task) + return + } + + if (task.runtimeStatus === 'idle' && task.started) { + task.status = 'completed' + task.resultSummary = + summarizeResult(task.responseMarkdown) || task.resultSummary || 'Completed.' + maybeResolveTask(task) + return + } + + if (task.started) { + task.status = 'running' + } + } + + const unsubscribe = this.runtimePort.subscribeDeepChatSessionUpdates((update) => { + const task = sessionTaskMap.get(update.sessionId) + if (!task) { + return + } + + task.updatedAt = update.updatedAt + + if (update.kind === 'blocks') { + task.previewMarkdown = truncate(update.previewMarkdown?.trim() || '', 600) + task.responseMarkdown = truncate(update.responseMarkdown?.trim() || '', 12000) + task.waitingInteraction = update.waitingInteraction ?? null + } else if (update.kind === 'status' && update.status) { + task.runtimeStatus = update.status + } + + updateTaskStatusFromRuntime(task) + emitProgress() + }) + + const abortListener = () => { + for (const task of tasks) { + if (isTerminalStatus(task.status)) { + continue + } + + task.cancelRequested = true + task.updatedAt = Date.now() + updateTaskStatusFromRuntime(task) + + if (task.sessionId) { + void this.runtimePort.cancelConversation(task.sessionId).catch(() => undefined) + } + } + + emitProgress() + } + + options?.signal?.addEventListener('abort', abortListener) + + const runTask = async (task: MutableTaskState): Promise => { + if (options?.signal?.aborted) { + abortListener() + return + } + + try { + const child = await this.runtimePort.createSubagentSession({ + parentSessionId: parent.sessionId, + agentId: task.targetAgentId || parent.agentId, + slotId: task.slotId, + displayName: task.targetAgentName, + targetAgentId: task.targetAgentId, + projectDir: inheritedWorkspace, + providerId: parent.providerId, + modelId: parent.modelId, + permissionMode: parent.permissionMode, + generationSettings: parent.generationSettings ?? undefined, + disabledAgentTools: parent.disabledAgentTools, + activeSkills: parent.activeSkills + }) + + if (!child) { + throw new Error(`Failed to create subagent session for slot ${task.slotId}.`) + } + + task.sessionId = child.sessionId + task.targetAgentName = child.agentName || task.targetAgentName + task.updatedAt = Date.now() + sessionTaskMap.set(child.sessionId, task) + emitProgress() + + const handoff = buildHandoffMessage({ + parent, + mode: args.mode, + totalTasks: tasks.length, + task, + inheritedWorkspace + }) + await this.runtimePort.sendConversationMessage(child.sessionId, handoff) + task.started = true + task.updatedAt = Date.now() + if (task.status === 'queued') { + task.status = 'running' + } + emitProgress() + + await task.completion.promise + } catch (error) { + task.updatedAt = Date.now() + task.status = task.cancelRequested ? 'cancelled' : 'error' + task.resultSummary = + error instanceof Error ? error.message : 'Subagent session failed unexpectedly.' + maybeResolveTask(task) + emitProgress() + } + } + + emitProgress() + + try { + if (args.mode === 'parallel') { + await Promise.all(tasks.map((task) => runTask(task))) + } else { + for (const task of tasks) { + await runTask(task) + } + } + } finally { + unsubscribe() + options?.signal?.removeEventListener('abort', abortListener) + } + + if (options?.signal?.aborted) { + throw new Error('subagent_orchestrator cancelled.') + } + + const finalProgress = { + runId, + mode: args.mode, + tasks: tasks.map((task) => ({ + taskId: task.taskId, + title: task.title, + slotId: task.slotId, + sessionId: task.sessionId, + targetAgentId: task.targetAgentId, + targetAgentName: task.targetAgentName, + status: task.status, + previewMarkdown: task.previewMarkdown, + updatedAt: task.updatedAt, + waitingInteraction: task.waitingInteraction, + resultSummary: task.resultSummary + })) + } + const finalMarkdown = renderFinalMarkdown(args.mode, tasks) + + return { + content: finalMarkdown, + rawData: { + content: finalMarkdown, + isError: tasks.some((task) => task.status === 'error'), + toolResult: { + subagentFinal: JSON.stringify(finalProgress), + subagentProgress: JSON.stringify(finalProgress) + } + } + } + } +} diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 3ae6fa778..a3fdb69cc 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -5,6 +5,7 @@ import type { MCPToolCall, MCPToolResponse } from '@shared/presenter' +import type { AgentToolProgressUpdate } from '@shared/types/presenters/tool.presenter' import { resolveToolOffloadTemplatePath } from '@/lib/agentRuntime/sessionPaths' import { QUESTION_TOOL_NAME } from '@/lib/agentRuntime/questionTool' import { ToolMapper } from './toolMapper' @@ -49,7 +50,13 @@ export interface IToolPresenter { agentWorkspacePath?: string | null conversationId?: string }): Promise - callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> + callTool( + request: MCPToolCall, + options?: { + onProgress?: (update: AgentToolProgressUpdate) => void + signal?: AbortSignal + } + ): Promise<{ content: unknown; rawData: MCPToolResponse }> preCheckToolPermission?(request: MCPToolCall): Promise buildToolSystemPrompt(context: { conversationId?: string @@ -176,7 +183,13 @@ export class ToolPresenter implements IToolPresenter { /** * Call a tool, routing to the appropriate source based on mapping */ - async callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> { + async callTool( + request: MCPToolCall, + options?: { + onProgress?: (update: AgentToolProgressUpdate) => void + signal?: AbortSignal + } + ): Promise<{ content: unknown; rawData: MCPToolResponse }> { const toolName = request.function.name const source = this.mapper.getToolSource(toolName) @@ -207,7 +220,16 @@ export class ToolPresenter implements IToolPresenter { } } } - const response = await this.agentToolManager.callTool(toolName, args, request.conversationId) + const response = await this.agentToolManager.callTool( + toolName, + args, + request.conversationId, + { + toolCallId: request.id, + onProgress: options?.onProgress, + signal: options?.signal + } + ) const resolvedResponse = this.resolveAgentToolResponse(response) const rawData = resolvedResponse.rawData ?? {} return { diff --git a/src/main/presenter/toolPresenter/runtimePorts.ts b/src/main/presenter/toolPresenter/runtimePorts.ts index 806b436c2..78646ab38 100644 --- a/src/main/presenter/toolPresenter/runtimePorts.ts +++ b/src/main/presenter/toolPresenter/runtimePorts.ts @@ -4,17 +4,60 @@ import type { IWindowPresenter, IYoBrowserPresenter } from '@shared/presenter' +import type { + DeepChatSubagentMeta, + DeepChatSubagentSlot, + PermissionMode, + SendMessageInput, + SessionGenerationSettings, + SessionKind +} from '@shared/types/agent-interface' import type { ISkillPresenter } from '@shared/types/skill' +import type { DeepChatInternalSessionUpdate } from '../deepchatAgentPresenter/internalSessionEvents' export interface ConversationSessionInfo { + sessionId: string + agentId: string + agentName: string + agentType: 'deepchat' | 'acp' | null + providerId: string + modelId: string + projectDir: string | null + permissionMode: PermissionMode + generationSettings: SessionGenerationSettings | null + disabledAgentTools: string[] + activeSkills: string[] + sessionKind: SessionKind + parentSessionId: string | null + subagentEnabled: boolean + subagentMeta: DeepChatSubagentMeta | null + availableSubagentSlots: DeepChatSubagentSlot[] +} + +export interface CreateSubagentSessionInput { + parentSessionId: string agentId: string + slotId: string + displayName: string + targetAgentId?: string | null + projectDir?: string | null providerId: string modelId: string + permissionMode: PermissionMode + generationSettings?: Partial + disabledAgentTools?: string[] + activeSkills?: string[] } export interface AgentToolRuntimePort { resolveConversationWorkdir(conversationId: string): Promise resolveConversationSessionInfo(conversationId: string): Promise + createSubagentSession(input: CreateSubagentSessionInput): Promise + sendConversationMessage(conversationId: string, content: string | SendMessageInput): Promise + cancelConversation(conversationId: string): Promise + subscribeDeepChatSessionUpdates( + listener: (update: DeepChatInternalSessionUpdate) => void + ): () => void getSkillPresenter(): ISkillPresenter getYoBrowserToolHandler(): IYoBrowserPresenter['toolHandler'] getFilePresenter(): Pick diff --git a/src/renderer/settings/components/DeepChatAgentsSettings.vue b/src/renderer/settings/components/DeepChatAgentsSettings.vue index 9df6ca57a..5df70abeb 100644 --- a/src/renderer/settings/components/DeepChatAgentsSettings.vue +++ b/src/renderer/settings/components/DeepChatAgentsSettings.vue @@ -393,6 +393,102 @@ +
+
+
+
+ {{ t('settings.deepchatAgents.subagentsTitle') }} +
+
+ {{ t('settings.deepchatAgents.subagentsDescription') }} +
+
+ +
+ +
+
+
+
+ {{ slot.id }} +
+ +
+ +
+ + + + +