GPU_GUARD_MONOREPO/docs/superpowers/plans/2026-04-18-session-subagent-implementation.md

1217 lines
39 KiB
Markdown
Raw Normal View History

2026-05-20 21:39:12 +08:00
# 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`.