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

39 KiB
Raw Blame History

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:

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:

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:

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:

  @Column({ type: 'json', comment: '子代理配置', nullable: true })
  subagentConfig: {
    enabled?: boolean;
    maxConcurrent?: number;
    allowedPresetAgentIds?: number[];
    allowedToolNames?: string[];
  };

Modify packages/backend/src/config/config.default.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:

  @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:

pnpm --filter @neta/backend test -- --runInBand test/entity_exports.test.ts

Expected: PASS.

  • Step 7: Commit
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:

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:

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:

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:

  @Inject()
  chatOrchestrator: NetaClawChatOrchestratorService;

Replace inline execution in handleChat() with a call to chatOrchestrator.executeChat(...).

Modify packages/backend/src/modules/netaclaw/service/agent_executor.ts:

  @Inject()
  chatOrchestrator: NetaClawChatOrchestratorService;

  async execute(params: any) {
    return this.chatOrchestrator.executeChat(params);
  }
  • Step 5: Run test to verify it passes

Run:

pnpm --filter @neta/backend test -- --runInBand test/chat_orchestrator.test.ts

Expected: PASS.

  • Step 6: Commit
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:

  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:

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
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:

  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:

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:

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:

  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():

    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:

    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:

pnpm --filter @neta/backend test -- --runInBand test/tool_resolver.test.ts

Expected: PASS, including runtime tool instantiation assertions.

  • Step 6: Commit
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:

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:

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:

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:

  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:

pnpm --filter @neta/backend test -- --runInBand test/subagent_service.test.ts

Expected: PASS.

  • Step 6: Commit
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:

  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:

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:

    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:

    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:

    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:

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
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:

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:

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:

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:

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:

<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:

pnpm --filter @neta/frontend build

Expected: PASS.

  • Step 6: Commit
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:

    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:

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:

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:

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:

pnpm --filter @neta/frontend build

Expected: PASS.

  • Step 6: Commit
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:

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:

pnpm --filter @neta/backend build
pnpm --filter @neta/frontend build

Expected: PASS.

  • Step 3: Manual smoke test

Verify:

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