# Session Subagent Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build session-scoped subagent delegation for normal `netaclaw` chat sessions with unified WS/HTTP behavior, stable assistant-message anchoring, dedicated persistence, and a dedicated chat card. **Architecture:** Introduce a shared `ChatOrchestratorService` so WebSocket chat and HTTP chat run through the same orchestration path. Pre-create an assistant placeholder message before the agent run and use that message id as the stable `parentMessageId` for all subagent records, events, metadata, and frontend inline rendering. Add a new `subagent_session` entity plus `SubagentService`, use scene-aware tool instantiation for supervisor vs subagent runtime, and persist `subagent` batch summaries into assistant message metadata keyed by the assistant message id. **Tech Stack:** MidwayJS, TypeORM, Socket.IO gateway, Vue 3, Pinia, Element Plus, Jest --- ## File Structure ### Backend files - Create: `packages/backend/src/modules/netaclaw/entity/subagent_session.ts` - Session-scoped subagent lifecycle records. - Create: `packages/backend/src/modules/netaclaw/service/subagent.ts` - Execute child agents, persist status, constrain tools, emit callbacks. - Create: `packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts` - Shared normal-chat orchestration used by both WebSocket and HTTP chat. - Modify: `packages/backend/src/modules/netaclaw/service/tool_resolver.ts` - Split tool visibility from runtime tool instantiation; support supervisor/subagent scenes. - Modify: `packages/backend/src/modules/netaclaw/runtime/agent.ts` - Accept scene-specific tool instances and subagent callbacks. - Modify: `packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts` - Support session-subagent context. - Modify: `packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts` - Support session-subagent context. - Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts` - Replace inline chat orchestration with `ChatOrchestratorService`. - Modify: `packages/backend/src/modules/netaclaw/service/agent_executor.ts` - Replace duplicate chat orchestration with `ChatOrchestratorService`. - Modify: `packages/backend/src/modules/netaclaw/gateway/session.ts` - Return saved message entities, support updating the pre-created assistant placeholder, and delete `subagent_session` with the session. - Modify: `packages/backend/src/modules/netaclaw/entity/agent.ts` - Add `subagentConfig`. - Modify: `packages/backend/src/modules/netaclaw/entity/message.ts` - Only if needed for an explicit batch key field; otherwise keep assistant id as anchor. - Modify: `packages/backend/src/entities.ts` - Register new entity. - Modify: `packages/backend/src/config/config.default.ts` - Add global subagent defaults. - Modify: `packages/backend/src/modules/netaclaw/gateway/protocol.ts` - Add subagent events. - Modify: `packages/backend/src/modules/netaclaw/controller/agent.ts` - Ensure `previewPrompt` receives and respects `subagentConfig`. - Test: `packages/backend/test/tool_resolver.test.ts` - Create: `packages/backend/test/chat_orchestrator.test.ts` - Create: `packages/backend/test/subagent_service.test.ts` ### Frontend files - Modify: `packages/frontend/src/modules/agent/types/index.d.ts` - Add subagent event and metadata types. - Modify: `packages/frontend/src/modules/agent/store/chat.ts` - Merge live subagent events and restore subagent batches onto the owning assistant messages. - Create: `packages/frontend/src/modules/agent/components/subagent-batch-card.vue` - Render structured subagent batches. - Modify: `packages/frontend/src/modules/agent/views/chat.vue` - Show subagent batches in the existing execution stack. - Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue` - Add subagent config UI and data loading. ## Task 1: Add Persistence And Cleanup Foundations **Files:** - Create: `packages/backend/src/modules/netaclaw/entity/subagent_session.ts` - Modify: `packages/backend/src/modules/netaclaw/entity/agent.ts` - Modify: `packages/backend/src/entities.ts` - Modify: `packages/backend/src/config/config.default.ts` - Modify: `packages/backend/src/modules/netaclaw/gateway/session.ts` - [ ] **Step 1: Write the failing backend entity export test** Create `packages/backend/test/entity_exports.test.ts`: ```ts import * as entities from '../src/entities.js'; describe('netaclaw entity exports', () => { it('exports the subagent session entity', () => { expect(Object.keys(entities).some(name => name.includes('SubagentSession'))).toBe(true); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/entity_exports.test.ts ``` Expected: FAIL because the entity is not registered yet. - [ ] **Step 3: Add the subagent entity with required indexes** Create `packages/backend/src/modules/netaclaw/entity/subagent_session.ts`: ```ts import { BaseEntity } from '../../base/entity/base.js'; import { Column, Entity, Index } from 'typeorm'; @Entity('netaclaw_subagent_session') @Index(['sessionId', 'parentMessageId', 'sortOrder']) @Index(['sessionId', 'status']) @Index(['parentAgentId', 'createTime']) export class NetaClawSubagentSessionEntity extends BaseEntity { @Column({ length: 64, comment: '所属会话 ID' }) sessionId: string; @Column({ comment: '父 assistant message ID' }) parentMessageId: number; @Column({ length: 64, nullable: true, comment: '父工具调用 ID' }) parentToolCallId: string; @Column({ nullable: true, comment: '父 Agent ID' }) parentAgentId: number; @Column({ length: 20, comment: '来源类型' }) sourceType: 'preset'; @Column({ nullable: true, comment: '预设 Agent ID' }) presetAgentId: number; @Column({ length: 200, comment: '展示名称' }) name: string; @Column({ type: 'text', comment: '委派目标' }) goal: string; @Column({ type: 'longtext', nullable: true, comment: '委派上下文' }) context: string; @Column({ length: 20, default: 'queued', comment: '状态' }) status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; @Column({ length: 255, nullable: true, comment: '模型标识' }) model: string; @Column({ type: 'json', nullable: true, comment: '工具名列表' }) toolNames: string[]; @Column({ type: 'longtext', nullable: true, comment: '结果摘要' }) summary: string; @Column({ type: 'json', nullable: true, comment: '结构化结果' }) resultPayload: Record; @Column({ type: 'longtext', nullable: true, comment: '错误信息' }) error: string; @Column({ type: 'json', nullable: true, comment: 'token 用量' }) tokenUsage: Record; @Column({ default: 0, comment: '同批次排序' }) sortOrder: number; @Column({ type: 'datetime', nullable: true, comment: '开始时间' }) startedAt: Date; @Column({ type: 'datetime', nullable: true, comment: '结束时间' }) endedAt: Date; } ``` - [ ] **Step 4: Add Agent config and global defaults** Modify `packages/backend/src/modules/netaclaw/entity/agent.ts`: ```ts @Column({ type: 'json', comment: '子代理配置', nullable: true }) subagentConfig: { enabled?: boolean; maxConcurrent?: number; allowedPresetAgentIds?: number[]; allowedToolNames?: string[]; }; ``` Modify `packages/backend/src/config/config.default.ts`: ```ts subagent: { enabled: true, maxConcurrent: 3, blockedToolNames: ['delegate_task', 'delegate_parallel', 'clarify', 'memory_save', 'memory_recall'], }, ``` - [ ] **Step 5: Register entity and extend session cleanup** Modify `packages/backend/src/entities.ts` to register `NetaClawSubagentSessionEntity`. Modify `packages/backend/src/modules/netaclaw/gateway/session.ts` so delete flows also remove `subagent_session` rows: ```ts @InjectEntityModel(NetaClawSubagentSessionEntity) subagentRepo: Repository; async deleteSession(sessionId: string): Promise { await this.subagentRepo.delete({ sessionId }); await this.messageRepo.delete({ sessionId }); await this.sessionRepo.delete({ sessionId }); } ``` Mirror this in `deleteAllSessions()`. - [ ] **Step 6: Run test to verify it passes** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/entity_exports.test.ts ``` Expected: PASS. - [ ] **Step 7: Commit** ```powershell git add packages/backend/src/modules/netaclaw/entity/subagent_session.ts packages/backend/src/modules/netaclaw/entity/agent.ts packages/backend/src/entities.ts packages/backend/src/config/config.default.ts packages/backend/src/modules/netaclaw/gateway/session.ts packages/backend/test/entity_exports.test.ts git commit -m "feat(netaclaw): add session subagent persistence foundation" ``` ## Task 2: Introduce Shared Chat Orchestrator With Assistant Placeholder Anchoring **Files:** - Create: `packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts` - Modify: `packages/backend/src/modules/netaclaw/gateway/server.ts` - Modify: `packages/backend/src/modules/netaclaw/service/agent_executor.ts` - Create: `packages/backend/test/chat_orchestrator.test.ts` - [ ] **Step 1: Write the failing orchestrator test** Create `packages/backend/test/chat_orchestrator.test.ts`: ```ts import { NetaClawChatOrchestratorService } from '../src/modules/netaclaw/service/chat_orchestrator.js'; describe('NetaClawChatOrchestratorService', () => { it('pre-creates an assistant placeholder and returns its id with execution result', async () => { const service = new NetaClawChatOrchestratorService(); service.sessionService = { getOrCreateSession: jest.fn(async () => 's1'), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 11, role: 'user' }) .mockResolvedValueOnce({ id: 12, role: 'assistant', content: '' }), updateMessage: jest.fn(async () => undefined), loadHistory: jest.fn(async () => [{ role: 'user', content: 'hi' }]), } as any; service.runAgent = jest.fn(async () => ({ finalContent: 'done', toolCallCount: 0, usage: { inputTokens: 1, outputTokens: 1 } })) as any; const result = await service.executeChat({ message: 'hi', requestedAgentName: 'default', } as any); expect(result.assistantMessageId).toBe(12); expect(result.finalContent).toBe('done'); expect(service.sessionService.updateMessage).toHaveBeenCalledWith( 12, expect.objectContaining({ content: 'done' }) ); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts ``` Expected: FAIL because the orchestrator service does not exist. - [ ] **Step 3: Implement the orchestrator skeleton** Create `packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts`: ```ts import { Provide, Inject } from '@midwayjs/core'; import { NetaClawSessionService } from '../gateway/session.js'; import { runAgent } from '../runtime/agent.js'; @Provide() export class NetaClawChatOrchestratorService { @Inject() sessionService: NetaClawSessionService; async executeChat(params: any) { const sessionId = await this.sessionService.getOrCreateSession( params.sessionId, params.requestedAgentName || 'default', params.agentEntity?.id, params.userId ); await this.sessionService.saveMessage(sessionId, { role: 'user', content: params.message }); const assistantPlaceholder = await this.sessionService.saveMessage(sessionId, { role: 'assistant', content: '', metadata: { inFlight: true }, } as any); const history = await this.sessionService.loadHistory(sessionId, { view: 'compacted' }); const result = await runAgent({ agentConfig: params.agentConfig, tools: params.runtimeTools || [], toolNames: params.resolvedToolNames, userMessage: params.message, history: history.slice(0, -1), onClarifyRequest: params.onClarifyRequest, }); await this.sessionService.updateMessage(assistantPlaceholder.id, { content: result.finalContent, metadata: { ...(params.assistantMetadata || {}), inFlight: false, }, }); return { sessionId, finalContent: result.finalContent, usage: result.usage, toolCallCount: result.toolCallCount, assistantMessageId: assistantPlaceholder.id, }; } } ``` - [ ] **Step 4: Route gateway and HTTP executor through the orchestrator** Modify `packages/backend/src/modules/netaclaw/gateway/server.ts`: ```ts @Inject() chatOrchestrator: NetaClawChatOrchestratorService; ``` Replace inline execution in `handleChat()` with a call to `chatOrchestrator.executeChat(...)`. Modify `packages/backend/src/modules/netaclaw/service/agent_executor.ts`: ```ts @Inject() chatOrchestrator: NetaClawChatOrchestratorService; async execute(params: any) { return this.chatOrchestrator.executeChat(params); } ``` - [ ] **Step 5: Run test to verify it passes** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts packages/backend/src/modules/netaclaw/gateway/server.ts packages/backend/src/modules/netaclaw/service/agent_executor.ts packages/backend/test/chat_orchestrator.test.ts git commit -m "refactor(netaclaw): unify normal chat execution with orchestrator" ``` ## Task 3: Return And Update Saved Message Entities For Stable Anchoring **Files:** - Modify: `packages/backend/src/modules/netaclaw/gateway/session.ts` - Modify: `packages/backend/test/chat_orchestrator.test.ts` - [ ] **Step 1: Update the failing test to depend on real saved message ids** Refine `test/chat_orchestrator.test.ts` so `saveMessage()` returning `void` would fail the assertion for `assistantMessageId`. - [ ] **Step 2: Change `saveMessage()` to return the saved entity and add `updateMessage()`** Modify `packages/backend/src/modules/netaclaw/gateway/session.ts`: ```ts async saveMessage( sessionId: string, msg: LLMMessage & { thinking?: string; skillName?: string; metadata?: Record } ): Promise { return this.messageRepo.save({ sessionId, role: msg.role, content: msg.content, thinking: msg.thinking, toolCalls: msg.toolCalls, toolCallId: msg.toolCallId, skillName: msg.skillName, metadata: msg.metadata, }); } async updateMessage( id: number, patch: Partial ): Promise { await this.messageRepo.update({ id }, patch); } ``` - [ ] **Step 3: Run test to verify it passes** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts ``` Expected: PASS and `assistantMessageId` is present before the agent run completes. - [ ] **Step 4: Commit** ```powershell git add packages/backend/src/modules/netaclaw/gateway/session.ts packages/backend/test/chat_orchestrator.test.ts git commit -m "refactor(netaclaw): return saved message entities for stable anchors" ``` ## Task 4: Add Scene-Aware Tool Resolution **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/tool_resolver.ts` - Modify: `packages/backend/test/tool_resolver.test.ts` - [ ] **Step 1: Write the failing resolver tests** Add to `packages/backend/test/tool_resolver.test.ts`: ```ts it('instantiates delegate tools for session supervisors', async () => { service.toolRegistry = { all: jest.fn().mockResolvedValue([ { name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' }, ]), } as any; const result = await service.resolve({ agent: { subagentConfig: { enabled: true }, tools: { inheritCoreTools: false, enabled: ['delegate_task'], disabled: [] }, } as any, modelCapability: 'text', delegationRole: 'supervisor', subagentContext: { kind: 'session-subagent' }, } as any); expect(result.toolNames).toContain('delegate_task'); expect(result.tools.some(t => t.name === 'delegate_task')).toBe(true); }); it('does not instantiate delegate tools for subagents', async () => { service.toolRegistry = { all: jest.fn().mockResolvedValue([ { name: 'delegate_task', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'crew' }, { name: 'bash', status: 1, capability: 'text', promptHint: null, isCore: 0, toolset: 'base' }, ]), } as any; const result = await service.resolve({ agent: { tools: { inheritCoreTools: false, enabled: ['delegate_task', 'bash'], disabled: [] }, } as any, modelCapability: 'text', delegationRole: 'subagent', } as any); expect(result.toolNames).toContain('bash'); expect(result.toolNames).not.toContain('delegate_task'); expect(result.tools.some(t => t.name === 'delegate_task')).toBe(false); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/tool_resolver.test.ts ``` Expected: FAIL because resolver only instantiates delegate tools for `crewRole === 'master'`. - [ ] **Step 3: Add scene params and split visibility from instantiation** Modify `tool_resolver.ts`: ```ts export interface ResolveToolParams { agent: NetaClawAgentEntity | null; modelCapability?: ModelCapability; memoryEnabled?: boolean; hasSkills?: boolean; crewRole?: 'master' | 'sub'; delegationRole?: 'supervisor' | 'subagent'; userId?: string; crewContext?: any; subagentContext?: any; } ``` Add helper: ```ts private shouldEnableSessionDelegation(params: ResolveToolParams): boolean { return params.delegationRole === 'supervisor' && !!params.subagentContext && !!params.agent?.subagentConfig?.enabled; } ``` - [ ] **Step 4: Add subagent restrictions and instantiate session delegate tools** Inside `resolve()`: ```ts const sceneNames = this.normalizeNames(getToolNamesByToolsets([ ...(params.memoryEnabled ? ['memory'] : []), ...(params.hasSkills ? ['skill'] : []), ...(params.crewRole === 'master' ? ['crew'] : []), ...(this.shouldEnableSessionDelegation(params) ? ['crew'] : []), ])); ``` Then keep blocked-name filtering for `delegationRole === 'subagent'`. Finally instantiate tools: ```ts if (params.crewRole === 'master' && params.crewContext) { if (constrainedNames.includes('delegate_task')) runtimeTools.push(createDelegateTaskTool(params.crewContext)); if (constrainedNames.includes('delegate_parallel')) runtimeTools.push(createDelegateParallelTool(params.crewContext)); } if (this.shouldEnableSessionDelegation(params)) { if (constrainedNames.includes('delegate_task')) runtimeTools.push(createDelegateTaskTool(params.subagentContext)); if (constrainedNames.includes('delegate_parallel')) runtimeTools.push(createDelegateParallelTool(params.subagentContext)); } ``` - [ ] **Step 5: Run test to verify it passes** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/tool_resolver.test.ts ``` Expected: PASS, including runtime tool instantiation assertions. - [ ] **Step 6: Commit** ```powershell git add packages/backend/src/modules/netaclaw/service/tool_resolver.ts packages/backend/test/tool_resolver.test.ts git commit -m "feat(netaclaw): add scene-aware session delegation tool resolution" ``` ## Task 5: Implement Subagent Service With Strong Prompt Boundaries **Files:** - Create: `packages/backend/src/modules/netaclaw/service/subagent.ts` - Create: `packages/backend/test/subagent_service.test.ts` - [ ] **Step 1: Write the failing service tests** Create `packages/backend/test/subagent_service.test.ts`: ```ts import { NetaClawSubagentService } from '../src/modules/netaclaw/service/subagent.js'; describe('NetaClawSubagentService', () => { it('creates a completed preset subagent using a restricted prompt and toolset', async () => { const service = new NetaClawSubagentService(); service.subagentRepo = { create: jest.fn(v => v), save: jest.fn(async v => ({ id: 1, ...v })), update: jest.fn(async () => undefined), } as any; service.toolResolver = { resolve: jest.fn(async () => ({ tools: [], toolNames: ['bash'], toolPromptHints: {} })) } as any; service.runAgent = jest.fn(async () => ({ finalContent: 'summary', toolCallCount: 1, usage: { inputTokens: 10, outputTokens: 5 }, })) as any; const result = await service.runSingle({ sessionId: 's1', parentMessageId: 12, parentAgent: { id: 3, name: 'main', systemPrompt: 'you are main' }, task: { goal: 'inspect repo', mode: 'preset', agentId: 7 }, } as any); expect(result.status).toBe('completed'); expect(result.summary).toBe('summary'); }); }); ``` - [ ] **Step 2: Run test to verify it fails** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/subagent_service.test.ts ``` Expected: FAIL because the service does not exist. - [ ] **Step 3: Implement the service** Create `packages/backend/src/modules/netaclaw/service/subagent.ts`: ```ts import { Provide, Inject } from '@midwayjs/core'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import { runAgent, AgentConfig } from '../runtime/agent.js'; import { NetaClawSubagentSessionEntity } from '../entity/subagent_session.js'; import { NetaClawToolResolverService } from './tool_resolver.js'; @Provide() export class NetaClawSubagentService { @InjectEntityModel(NetaClawSubagentSessionEntity) subagentRepo: Repository; @Inject() toolResolver: NetaClawToolResolverService; buildSubagentPrompt(parentPrompt: string, goal: string, context?: string) { return [ parentPrompt || '', '## 子代理运行约束', '你是会话内子代理,只负责当前委派目标。', '你不能向用户提问。', '你不能再次委派子代理。', '输出必须是给父 Agent 的执行摘要。', `## 当前目标\n${goal}`, context ? `## 上下文\n${context}` : '', ].filter(Boolean).join('\n\n'); } async runSingle(ctx: any) { const saved = await this.subagentRepo.save(this.subagentRepo.create({ sessionId: ctx.sessionId, parentMessageId: ctx.parentMessageId, parentToolCallId: ctx.parentToolCallId, parentAgentId: ctx.parentAgent?.id, sourceType: 'preset', presetAgentId: ctx.task.agentId, name: ctx.task.agentName || `${ctx.parentAgent?.name || 'agent'} / 子代理`, goal: ctx.task.goal, context: ctx.task.context, status: 'running', sortOrder: ctx.sortOrder || 0, startedAt: new Date(), })); const resolved = await this.toolResolver.resolve({ agent: ctx.parentAgent, modelCapability: 'text', hasSkills: !!ctx.parentAgent?.skills?.length, delegationRole: 'subagent', }); const agentConfig: AgentConfig = { name: saved.name, systemPrompt: this.buildSubagentPrompt(ctx.parentAgent?.systemPrompt || '', ctx.task.goal, ctx.task.context), model: ctx.parentModel, apiKey: ctx.parentApiKey, baseUrl: ctx.parentBaseUrl, maxToolRounds: ctx.task.maxToolRounds || 10, }; const result = await runAgent({ agentConfig, tools: resolved.tools, toolNames: resolved.toolNames, userMessage: ctx.task.goal, }); await this.subagentRepo.update(saved.id, { status: 'completed', model: agentConfig.model, toolNames: resolved.toolNames, summary: result.finalContent, resultPayload: { toolCallCount: result.toolCallCount }, tokenUsage: result.usage, endedAt: new Date(), }); return { id: saved.id, name: saved.name, sourceType: saved.sourceType, status: 'completed', goal: saved.goal, summary: result.finalContent, toolNames: resolved.toolNames, tokenUsage: result.usage, sortOrder: saved.sortOrder, }; } } ``` - [ ] **Step 4: Add bounded batch execution** Extend the service: ```ts async runBatch(ctx: any) { const maxConcurrent = Math.max(1, ctx.maxConcurrent || 3); const results: any[] = []; for (let i = 0; i < ctx.tasks.length; i += maxConcurrent) { const chunk = await Promise.all( ctx.tasks.slice(i, i + maxConcurrent).map((task, index) => this.runSingle({ ...ctx, task, sortOrder: i + index }) ) ); results.push(...chunk); } return results; } ``` - [ ] **Step 5: Run test to verify it passes** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/subagent_service.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add packages/backend/src/modules/netaclaw/service/subagent.ts packages/backend/test/subagent_service.test.ts git commit -m "feat(netaclaw): add restricted session subagent execution service" ``` ## Task 6: Wire Subagent Service Into Shared Chat Orchestration **Files:** - Modify: `packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts` - Modify: `packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts` - Modify: `packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts` - Modify: `packages/backend/src/modules/netaclaw/runtime/agent.ts` - Modify: `packages/backend/src/modules/netaclaw/gateway/protocol.ts` - [ ] **Step 1: Update orchestrator test with subagent batch expectations** Add to `test/chat_orchestrator.test.ts`: ```ts it('returns assistant metadata batches keyed by assistant message id', async () => { const service = new NetaClawChatOrchestratorService(); service.sessionService = { getOrCreateSession: jest.fn(async () => 's1'), saveMessage: jest .fn() .mockResolvedValueOnce({ id: 11, role: 'user' }) .mockResolvedValueOnce({ id: 12, role: 'assistant' }), loadHistory: jest.fn(async () => [{ role: 'user', content: 'hi' }]), } as any; service.runAgent = jest.fn(async () => ({ finalContent: 'done', toolCallCount: 0, usage: { inputTokens: 1, outputTokens: 1 } })) as any; const result = await service.executeChat({ message: 'hi', requestedAgentName: 'default', assistantMetadata: { subagents: [{ parentMessageId: 12, title: '子代理执行', items: [], summary: { total: 0, completed: 0, running: 0, failed: 0 } }] }, } as any); expect(result.assistantMessageId).toBe(12); expect(result.subagentBatches?.[0]?.parentMessageId ?? 12).toBe(12); }); ``` - [ ] **Step 2: Add protocol event types** Modify `gateway/protocol.ts`: ```ts export interface ServerSubagentBatchStartEvent { type: 'subagent_batch_start'; sessionId: string; data: { parentMessageId: number; title: string; total: number }; } export interface ServerSubagentUpdateEvent { type: 'subagent_update'; sessionId: string; data: { parentMessageId: number; subagent: Record }; } export interface ServerSubagentDoneEvent { type: 'subagent_done'; sessionId: string; data: { parentMessageId: number; summary: Record }; } ``` Append them to `ServerEvent`. - [ ] **Step 3: Modify delegation tools to use session-subagent context** Update `delegate_task.ts`: ```ts async execute(toolCallId: string, args: any) { if (context?.kind !== 'session-subagent') { return JSON.stringify({ error: 'delegate_task requires a session-subagent context' }); } const result = await context.subagentService.runSingle({ sessionId: context.sessionId, parentMessageId: context.parentMessageId, parentToolCallId: toolCallId, parentAgent: context.parentAgent, parentModel: context.parentModel, parentApiKey: context.parentApiKey, parentBaseUrl: context.parentBaseUrl, task: args, }); return JSON.stringify({ subagent: result }); }, ``` Mirror this in `delegate_parallel.ts`. - [ ] **Step 4: Build batch metadata inside the orchestrator using the placeholder id** Modify `chat_orchestrator.ts` so it constructs: ```ts const subagentBatches: any[] = []; const onSubagentUpdate = (payload: any) => { let batch = subagentBatches.find(b => b.parentMessageId === assistantPlaceholder.id); if (!batch) { batch = { parentMessageId: assistantPlaceholder.id, title: '子代理执行', items: [], summary: { total: 0, completed: 0, running: 0, failed: 0 }, }; subagentBatches.push(batch); } // upsert item + recompute summary }; ``` Pass `subagentContext` and callbacks into resolver / runtime tools. Before saving the assistant message: ```ts const assistantMetadata = { ...(params.assistantMetadata || {}), ...(subagentBatches.length ? { subagents: subagentBatches } : {}), }; ``` All `subagent_batch_start`, `subagent_update`, and `subagent_done` events emitted from the orchestrator must use `parentMessageId: assistantPlaceholder.id`. - [ ] **Step 5: Run backend tests** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts pnpm --filter @neta/backend test -- --runInBand test/subagent_service.test.ts ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add packages/backend/src/modules/netaclaw/service/chat_orchestrator.ts packages/backend/src/modules/netaclaw/tools/builtin/delegate_task.ts packages/backend/src/modules/netaclaw/tools/builtin/delegate_parallel.ts packages/backend/src/modules/netaclaw/runtime/agent.ts packages/backend/src/modules/netaclaw/gateway/protocol.ts packages/backend/test/chat_orchestrator.test.ts packages/backend/test/subagent_service.test.ts git commit -m "feat(netaclaw): wire session subagents into shared chat orchestration" ``` ## Task 7: Restore And Render Subagent Batches Inline With Assistant Messages **Files:** - Modify: `packages/frontend/src/modules/agent/types/index.d.ts` - Modify: `packages/frontend/src/modules/agent/store/chat.ts` - Create: `packages/frontend/src/modules/agent/components/subagent-batch-card.vue` - Modify: `packages/frontend/src/modules/agent/views/chat.vue` - [ ] **Step 1: Add frontend types** Modify `types/index.d.ts`: ```ts export interface SubagentItem { id: number | string; name: string; sourceType: 'preset'; status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; goal: string; summary?: string; error?: string; toolNames?: string[]; tokenUsage?: { inputTokens?: number; outputTokens?: number }; sortOrder: number; } export interface SubagentBatch { parentMessageId: number | string; title: string; items: SubagentItem[]; summary: { total: number; completed: number; running: number; failed: number }; } ``` Extend `WSServerEvent['type']` with `subagent_batch_start`, `subagent_update`, `subagent_done`. - [ ] **Step 2: Add explicit history recovery** Modify `store/chat.ts`: ```ts function restoreSubagentBatches(msgs: ChatMessage[]) { for (const msg of msgs) { const meta = typeof msg.metadata === 'string' ? (() => { try { return JSON.parse(msg.metadata); } catch { return null; } })() : msg.metadata; if (msg.role === 'assistant' && meta?.subagents?.length) { if (!msg.metadata || typeof msg.metadata === 'string') msg.metadata = meta; msg.metadata.subagentBatches = meta.subagents; } } } ``` Call `restoreSubagentBatches(messages.value)` inside `loadMessages()` after message history is loaded, alongside `restoreTodoFromHistory()` and `restoreSkillProgress()`. - [ ] **Step 3: Merge real-time events** Still in `store/chat.ts`, add: ```ts function upsertSubagentItem(parentMessageId: number | string, item: SubagentItem) { const assistant = messages.value.find(m => m.role === 'assistant' && String(m.id) === String(parentMessageId)); if (!assistant) return; if (!assistant.metadata) assistant.metadata = {}; if (!assistant.metadata.subagentBatches) assistant.metadata.subagentBatches = []; let batch = assistant.metadata.subagentBatches.find((b: SubagentBatch) => String(b.parentMessageId) === String(parentMessageId)); if (!batch) { batch = { parentMessageId, title: '子代理执行', items: [], summary: { total: 0, completed: 0, running: 0, failed: 0 }, }; assistant.metadata.subagentBatches.push(batch); } const idx = batch.items.findIndex(it => String(it.id) === String(item.id)); if (idx >= 0) batch.items.splice(idx, 1, { ...batch.items[idx], ...item }); else batch.items.push(item); batch.summary = { total: batch.items.length, completed: batch.items.filter(i => i.status === 'completed').length, running: batch.items.filter(i => i.status === 'running' || i.status === 'queued').length, failed: batch.items.filter(i => i.status === 'failed').length, }; } ``` Handle WS events: ```ts case 'subagent_batch_start': { const assistant = messages.value.find(m => m.role === 'assistant' && String(m.id) === String(event.data.parentMessageId)); if (!assistant) break; if (!assistant.metadata) assistant.metadata = {}; if (!assistant.metadata.subagentBatches) assistant.metadata.subagentBatches = []; if (!assistant.metadata.subagentBatches.find((b: SubagentBatch) => String(b.parentMessageId) === String(event.data.parentMessageId))) { assistant.metadata.subagentBatches.push({ parentMessageId: event.data.parentMessageId, title: event.data.title || '子代理执行', items: [], summary: { total: event.data.total || 0, completed: 0, running: event.data.total || 0, failed: 0 }, }); } } break; case 'subagent_update': upsertSubagentItem(event.data.parentMessageId, event.data.subagent); break; case 'subagent_done': break; ``` - [ ] **Step 4: Add the card component and render it** Create `components/subagent-batch-card.vue` using `todo-card` as style reference. Render inline with each assistant message in `views/chat.vue`, not as a global card above the streaming assistant: ```vue ``` Place this in the assistant-message rendering branch near that message's `message-item`. For the currently streaming assistant placeholder, the same `msg.id` receives live updates through `parentMessageId`. - [ ] **Step 5: Run frontend build** Run: ```powershell pnpm --filter @neta/frontend build ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add packages/frontend/src/modules/agent/types/index.d.ts packages/frontend/src/modules/agent/store/chat.ts packages/frontend/src/modules/agent/components/subagent-batch-card.vue packages/frontend/src/modules/agent/views/chat.vue git commit -m "feat(netaclaw): restore and render session subagent batches in chat" ``` ## Task 8: Add Agent Edit Config With Real Data Sources **Files:** - Modify: `packages/backend/src/modules/netaclaw/controller/agent.ts` - Modify: `packages/frontend/src/modules/agent/views/agent-edit.vue` - [ ] **Step 1: Expose preview prompt compatibility** Modify `controller/agent.ts` `previewPrompt` body type to include: ```ts subagentConfig?: { enabled?: boolean; maxConcurrent?: number; allowedPresetAgentIds?: number[]; allowedToolNames?: string[]; }; ``` And include it in the pseudo-agent passed to `toolResolver.resolve(...)`. - [ ] **Step 2: Load published agent options for whitelist selection** In `agent-edit.vue`, add a request for available agents: ```ts const availableAgents = ref([]); async function loadAvailableAgents() { const resp = await fetch(`${config.baseUrl}/admin/netaclaw/agent/page`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ page: 1, size: 1000 }), }); const data = await resp.json(); if (data.code === 1000) { availableAgents.value = data.data?.list || []; } } ``` - [ ] **Step 3: Add form defaults and UI** Add: ```ts subagentConfig: { enabled: true, maxConcurrent: 3, allowedPresetAgentIds: [], allowedToolNames: [], }, ``` Then add the UI block in the advanced tab using `availableAgents` and the already-loaded tools list. - [ ] **Step 4: Preserve the field through load/save** When hydrating the form from `agent.info`, map: ```ts form.subagentConfig = { enabled: agent.subagentConfig?.enabled ?? true, maxConcurrent: agent.subagentConfig?.maxConcurrent ?? 3, allowedPresetAgentIds: agent.subagentConfig?.allowedPresetAgentIds ?? [], allowedToolNames: agent.subagentConfig?.allowedToolNames ?? [], }; ``` When building the save payload, include `subagentConfig: form.subagentConfig`. - [ ] **Step 5: Run frontend build** Run: ```powershell pnpm --filter @neta/frontend build ``` Expected: PASS. - [ ] **Step 6: Commit** ```powershell git add packages/backend/src/modules/netaclaw/controller/agent.ts packages/frontend/src/modules/agent/views/agent-edit.vue git commit -m "feat(netaclaw): add agent subagent configuration UI" ``` ## Task 9: Final Verification **Files:** - Verify only. - [ ] **Step 1: Run backend tests** Run: ```powershell pnpm --filter @neta/backend test -- --runInBand test/entity_exports.test.ts pnpm --filter @neta/backend test -- --runInBand test/tool_resolver.test.ts pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts pnpm --filter @neta/backend test -- --runInBand test/subagent_service.test.ts ``` Expected: PASS for all commands. - [ ] **Step 2: Run builds** Run: ```powershell pnpm --filter @neta/backend build pnpm --filter @neta/frontend build ``` Expected: PASS. - [ ] **Step 3: Manual smoke test** Verify: ```text 1. 在 agent 编辑页开启子代理并保存。 2. 用 WebSocket 聊天入口发送可触发委派的请求。 3. 确认出现“子代理执行”卡片。 4. 刷新页面并恢复该会话。 5. 确认卡片可从 assistant metadata 恢复。 6. 用 HTTP /open/netaclaw/chat 调用同一 agent。 7. 确认行为与 WebSocket 路径一致,不出现工具缺失或结果分叉。 8. 删除该会话。 9. 确认数据库中对应 `subagent_session` 也被清理。 ``` - [ ] **Step 4: Commit verification-only follow-up if needed** ```powershell git add . git commit -m "test(netaclaw): verify session subagent unified chat flow" ``` ## Self-Review ### Spec coverage - 独立持久化与索引:Task 1 - 普通聊天统一入口:Task 2 - 稳定 assistant message 锚点:Task 3 - 场景化工具实例化:Task 4 - 受限 subagent prompt 与执行:Task 5 - metadata 写回与事件协议:Task 6 - 前端消息级内联实时与历史恢复:Task 7 - 管理页配置与预览一致性:Task 8 ### Placeholder scan - No `TODO` or `TBD` placeholders remain. - Each task names exact files and commands. - Each code-changing task includes concrete code blocks. ### Type consistency - Stable anchor is consistently named `assistantMessageId` / `parentMessageId`, and it is obtained before the agent run by pre-creating the assistant placeholder. - Scene names are consistently `delegationRole: 'supervisor' | 'subagent'`. - Frontend and backend event names are consistently `subagent_batch_start`, `subagent_update`, `subagent_done`.