feat(council): Phase 3 — Topic spawn + nested Council

This commit is contained in:
2026-04-16 11:35:13 +00:00
parent 131783144d
commit 179104245b
7 changed files with 358 additions and 2 deletions
+93
View File
@@ -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');
});
});
+23 -1
View File
@@ -10,6 +10,12 @@ export interface CouncilOptions {
onSpeak: (personaId: string) => Promise<string>;
/** Callback when a new member is added */
onAddMember?: (persona: PersonaState) => Promise<void>;
/** Callback when a sub-topic is spawned — returns the sub-council result */
onSpawn?: (
topicId: string,
title: string,
participants: PersonaState[],
) => Promise<CouncilResult>;
/** 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<CouncilResult> {
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<CouncilResult> {
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;
}
}
}
+10
View File
@@ -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 ────────────────────────────────────────────
+7 -1
View File
@@ -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 = (
@@ -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' };
}
+113
View File
@@ -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();
});
});
+77
View File
@@ -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<string, Topic> {
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<string, Topic>();
for (const ev of allEvents) {
if (!ev.meta) continue;
let meta: Record<string, unknown>;
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;
}