1217 lines
39 KiB
Markdown
1217 lines
39 KiB
Markdown
|
|
# 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`.
|