GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-18-session-subagent-implementation.md
2026-05-20 21:39:12 +08:00

1217 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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<string, any>;
@Column({ type: 'longtext', nullable: true, comment: '错误信息' })
error: string;
@Column({ type: 'json', nullable: true, comment: 'token 用量' })
tokenUsage: Record<string, any>;
@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<NetaClawSubagentSessionEntity>;
async deleteSession(sessionId: string): Promise<void> {
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<string, unknown> }
): Promise<NetaClawMessageEntity> {
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<NetaClawMessageEntity>
): Promise<void> {
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<NetaClawSubagentSessionEntity>;
@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<string, any> };
}
export interface ServerSubagentDoneEvent {
type: 'subagent_done';
sessionId: string;
data: { parentMessageId: number; summary: Record<string, number> };
}
```
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
<subagent-batch-card
v-for="batch in msg.metadata?.subagentBatches || msg.metadata?.subagents || []"
:key="String(batch.parentMessageId)"
:batch="batch"
/>
```
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<AgentInfo[]>([]);
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`.