# 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`.
