From 179104245b9261d52d29a550da5bf0930fde7b44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Thu, 16 Apr 2026 11:35:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(council):=20Phase=203=20=E2=80=94=20Topic?= =?UTF-8?q?=20spawn=20+=20nested=20Council?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/pulse/src/council.test.ts | 93 ++++++++++++++ packages/pulse/src/council.ts | 24 +++- packages/pulse/src/index.ts | 10 ++ packages/pulse/src/moderator.ts | 8 +- .../pulse/src/moderators/llm-moderator.ts | 35 ++++++ packages/pulse/src/topic.test.ts | 113 ++++++++++++++++++ packages/pulse/src/topic.ts | 77 ++++++++++++ 7 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 packages/pulse/src/topic.test.ts create mode 100644 packages/pulse/src/topic.ts diff --git a/packages/pulse/src/council.test.ts b/packages/pulse/src/council.test.ts index fa792cb..7994c53 100644 --- a/packages/pulse/src/council.test.ts +++ b/packages/pulse/src/council.test.ts @@ -123,4 +123,97 @@ describe('runCouncil', () => { expect(result.history).toHaveLength(1); expect(result.history[0]!.personaId).toBe('solo'); }); + + test('spawn sub-council via onSpawn', async () => { + let callCount = 0; + const spawnMod: ModeratorFn = async () => { + callCount++; + if (callCount === 1) { + return { action: 'speak', personaId: 'a' }; + } + if (callCount === 2) { + return { + action: 'spawn', + topicId: 'sub-1', + title: 'Sub task', + participants: [makePersona('a'), makePersona('b')], + }; + } + return { action: 'close', summary: 'parent done' }; + }; + + const spawnCalls: { topicId: string; title: string }[] = []; + const result = await runCouncil({ + moderator: spawnMod, + participants: [makePersona('a'), makePersona('b')], + onSpeak: async (id) => `${id} speaks`, + onSpawn: async (topicId, title, _participants) => { + spawnCalls.push({ topicId, title }); + return { history: [], summary: 'sub-council resolved', rounds: 2 }; + }, + }); + + expect(spawnCalls).toEqual([{ topicId: 'sub-1', title: 'Sub task' }]); + // history: a speaks + sub-council message + (no more speak before close) + expect(result.history).toHaveLength(2); + expect(result.history[1]!.personaId).toBe('sub-council:sub-1'); + expect(result.history[1]!.content).toBe('sub-council resolved'); + expect(result.summary).toBe('parent done'); + }); + + test('nested spawn (sub-council spawns grandchild)', async () => { + let parentCall = 0; + const parentMod: ModeratorFn = async () => { + parentCall++; + if (parentCall === 1) { + return { + action: 'spawn', + topicId: 'child', + title: 'Child topic', + participants: [makePersona('a')], + }; + } + return { action: 'close', summary: 'parent done' }; + }; + + const result = await runCouncil({ + moderator: parentMod, + participants: [makePersona('a')], + onSpeak: async (id) => `${id} speaks`, + onSpawn: async (topicId, _title, participants) => { + // Child council that itself spawns a grandchild + let childCall = 0; + const childMod: ModeratorFn = async () => { + childCall++; + if (childCall === 1) { + return { + action: 'spawn', + topicId: 'grandchild', + title: 'Grandchild topic', + participants, + }; + } + return { action: 'close', summary: `child ${topicId} done` }; + }; + + return runCouncil({ + moderator: childMod, + participants, + onSpeak: async (id) => `${id} in child`, + onSpawn: async (gcTopicId) => { + return { + history: [], + summary: `grandchild ${gcTopicId} resolved`, + rounds: 1, + }; + }, + }); + }, + }); + + expect(result.history).toHaveLength(1); + expect(result.history[0]!.personaId).toBe('sub-council:child'); + expect(result.history[0]!.content).toBe('child child done'); + expect(result.summary).toBe('parent done'); + }); }); diff --git a/packages/pulse/src/council.ts b/packages/pulse/src/council.ts index ac88dce..79dcf69 100644 --- a/packages/pulse/src/council.ts +++ b/packages/pulse/src/council.ts @@ -10,6 +10,12 @@ export interface CouncilOptions { onSpeak: (personaId: string) => Promise; /** Callback when a new member is added */ onAddMember?: (persona: PersonaState) => Promise; + /** Callback when a sub-topic is spawned — returns the sub-council result */ + onSpawn?: ( + topicId: string, + title: string, + participants: PersonaState[], + ) => Promise; /** Maximum rounds to prevent infinite loops (default 10) */ maxRounds?: number; } @@ -31,7 +37,7 @@ export interface CouncilResult { * 3. Force close after maxRounds */ export async function runCouncil(opts: CouncilOptions): Promise { - const { moderator, onSpeak, onAddMember, maxRounds = 10 } = opts; + const { moderator, onSpeak, onAddMember, onSpawn, maxRounds = 10 } = opts; const participants = [...opts.participants]; const history: CouncilMessage[] = [...(opts.history ?? [])]; @@ -62,6 +68,22 @@ export async function runCouncil(opts: CouncilOptions): Promise { case 'close': { return { history, summary: decision.summary, rounds }; } + case 'spawn': { + if (onSpawn) { + const subResult = await onSpawn( + decision.topicId, + decision.title, + decision.participants, + ); + history.push({ + personaId: `sub-council:${decision.topicId}`, + content: + subResult.summary ?? 'Sub-council completed without summary', + timestamp: Date.now(), + }); + } + break; + } } } diff --git a/packages/pulse/src/index.ts b/packages/pulse/src/index.ts index 52541ae..310b8d1 100644 --- a/packages/pulse/src/index.ts +++ b/packages/pulse/src/index.ts @@ -964,6 +964,16 @@ export type { LlmModeratorOptions } from './moderators/llm-moderator.js'; export { createLlmModerator } from './moderators/llm-moderator.js'; export { createRoundRobinModerator } from './moderators/round-robin.js'; +// ── Topic ─────────────────────────────────────────────────────── + +export type { + Topic, + TopicClosedMeta, + TopicCreatedMeta, + TopicStatus, +} from './topic.js'; +export { buildTopicsFromEvents } from './topic.js'; + // ── Executors ───────────────────────────────────────────────── // ── Definition Layer ──────────────────────────────────────────── diff --git a/packages/pulse/src/moderator.ts b/packages/pulse/src/moderator.ts index e1b13d3..0610fcf 100644 --- a/packages/pulse/src/moderator.ts +++ b/packages/pulse/src/moderator.ts @@ -11,7 +11,13 @@ export interface CouncilMessage { export type ModeratorDecision = | { action: 'speak'; personaId: string } | { action: 'add'; persona: PersonaState } - | { action: 'close'; summary?: string }; + | { action: 'close'; summary?: string } + | { + action: 'spawn'; + topicId: string; + title: string; + participants: PersonaState[]; + }; /** Moderator 纯函数签名 */ export type ModeratorFn = ( diff --git a/packages/pulse/src/moderators/llm-moderator.ts b/packages/pulse/src/moderators/llm-moderator.ts index 73a5d09..74d3622 100644 --- a/packages/pulse/src/moderators/llm-moderator.ts +++ b/packages/pulse/src/moderators/llm-moderator.ts @@ -64,6 +64,30 @@ function buildModeratorTools(participantIds: string[]) { }, }, }, + { + type: 'function' as const, + function: { + name: 'spawn_sub_topic', + description: + 'Create a sub-topic and spawn a nested council to discuss it', + parameters: { + type: 'object', + properties: { + topicId: { + type: 'string', + description: 'Unique ID for the sub-topic', + }, + title: { type: 'string', description: 'Title of the sub-topic' }, + participantIds: { + type: 'array', + items: { type: 'string', enum: participantIds }, + description: 'IDs of participants to include in the sub-council', + }, + }, + required: ['topicId', 'title', 'participantIds'], + }, + }, + }, ]; } @@ -144,6 +168,17 @@ export function createLlmModerator(opts: LlmModeratorOptions): ModeratorFn { }; case 'close_council': return { action: 'close', summary: args.summary }; + case 'spawn_sub_topic': { + const subParticipants = participants.filter((p) => + (args.participantIds as string[]).includes(p.personaId), + ); + return { + action: 'spawn', + topicId: args.topicId, + title: args.title, + participants: subParticipants, + }; + } default: return { action: 'close', summary: 'Unknown tool call — closing' }; } diff --git a/packages/pulse/src/topic.test.ts b/packages/pulse/src/topic.test.ts new file mode 100644 index 0000000..da0291b --- /dev/null +++ b/packages/pulse/src/topic.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'bun:test'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createStore } from './store.js'; +import type { TopicClosedMeta, TopicCreatedMeta } from './topic.js'; +import { buildTopicsFromEvents } from './topic.js'; + +function createTestStore() { + const tmp = mkdtempSync(join(tmpdir(), 'topic-test-')); + return createStore({ + eventsDbPath: join(tmp, 'events.db'), + objectsDir: join(tmp, 'objects'), + }); +} + +describe('buildTopicsFromEvents', () => { + test('create topic', () => { + const store = createTestStore(); + const meta: TopicCreatedMeta = { + topicId: 't1', + title: 'Design review', + createdBy: 'alice', + }; + store.appendEvent({ + occurredAt: 1000, + kind: 'topic-created', + key: 't1', + meta: JSON.stringify(meta), + }); + + const topics = buildTopicsFromEvents(store); + expect(topics.size).toBe(1); + const t = topics.get('t1')!; + expect(t.topicId).toBe('t1'); + expect(t.title).toBe('Design review'); + expect(t.createdBy).toBe('alice'); + expect(t.status).toBe('open'); + expect(t.createdAt).toBe(1000); + expect(t.parentTopicId).toBeUndefined(); + store.close(); + }); + + test('create child topic with parentTopicId', () => { + const store = createTestStore(); + store.appendEvent({ + occurredAt: 1000, + kind: 'topic-created', + key: 't1', + meta: JSON.stringify({ + topicId: 't1', + title: 'Parent', + createdBy: 'alice', + } satisfies TopicCreatedMeta), + }); + store.appendEvent({ + occurredAt: 2000, + kind: 'topic-created', + key: 't2', + meta: JSON.stringify({ + topicId: 't2', + parentTopicId: 't1', + title: 'Child', + createdBy: 'bob', + } satisfies TopicCreatedMeta), + }); + + const topics = buildTopicsFromEvents(store); + expect(topics.size).toBe(2); + const child = topics.get('t2')!; + expect(child.parentTopicId).toBe('t1'); + expect(child.title).toBe('Child'); + expect(child.status).toBe('open'); + store.close(); + }); + + test('close topic with summary', () => { + const store = createTestStore(); + store.appendEvent({ + occurredAt: 1000, + kind: 'topic-created', + key: 't1', + meta: JSON.stringify({ + topicId: 't1', + title: 'Bug triage', + createdBy: 'alice', + } satisfies TopicCreatedMeta), + }); + store.appendEvent({ + occurredAt: 2000, + kind: 'topic-closed', + key: 't1', + meta: JSON.stringify({ + topicId: 't1', + summary: 'Resolved all P0 bugs', + } satisfies TopicClosedMeta), + }); + + const topics = buildTopicsFromEvents(store); + const t = topics.get('t1')!; + expect(t.status).toBe('closed'); + expect(t.closedAt).toBe(2000); + expect(t.summary).toBe('Resolved all P0 bugs'); + store.close(); + }); + + test('empty event stream', () => { + const store = createTestStore(); + const topics = buildTopicsFromEvents(store); + expect(topics.size).toBe(0); + store.close(); + }); +}); diff --git a/packages/pulse/src/topic.ts b/packages/pulse/src/topic.ts new file mode 100644 index 0000000..d0bf4dc --- /dev/null +++ b/packages/pulse/src/topic.ts @@ -0,0 +1,77 @@ +import type { PulseStore } from './store.js'; + +export type TopicStatus = 'open' | 'closed'; + +export interface Topic { + topicId: string; + parentTopicId?: string; + title: string; + status: TopicStatus; + createdBy: string; + createdAt: number; + closedAt?: number; + summary?: string; +} + +export interface TopicCreatedMeta { + topicId: string; + parentTopicId?: string; + title: string; + createdBy: string; +} + +export interface TopicClosedMeta { + topicId: string; + summary?: string; +} + +/** + * Build a Map of Topic from topic-created and topic-closed events. + * + * - topic-created → open topic + * - topic-closed → close + attach summary + * - Events are merged in occurredAt order. + */ +export function buildTopicsFromEvents(store: PulseStore): Map { + const created = store.queryByKind('topic-created'); + const closed = store.queryByKind('topic-closed'); + + const allEvents = [...created, ...closed]; + allEvents.sort((a, b) => a.occurredAt - b.occurredAt); + + const topics = new Map(); + + for (const ev of allEvents) { + if (!ev.meta) continue; + let meta: Record; + try { + meta = JSON.parse(ev.meta); + } catch { + continue; + } + + const topicId = meta.topicId as string; + if (!topicId) continue; + + if (ev.kind === 'topic-created') { + const m = meta as unknown as TopicCreatedMeta; + topics.set(topicId, { + topicId: m.topicId, + parentTopicId: m.parentTopicId, + title: m.title, + status: 'open', + createdBy: m.createdBy, + createdAt: ev.occurredAt, + }); + } else if (ev.kind === 'topic-closed') { + const existing = topics.get(topicId); + if (!existing) continue; + const m = meta as unknown as TopicClosedMeta; + existing.status = 'closed'; + existing.closedAt = ev.occurredAt; + if (m.summary !== undefined) existing.summary = m.summary; + } + } + + return topics; +}