feat(council): Phase 3 — Topic spawn + nested Council
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user