diff --git a/examples/03-task-pipeline.ts b/examples/03-task-pipeline.ts index 840848d..74c12cc 100644 --- a/examples/03-task-pipeline.ts +++ b/examples/03-task-pipeline.ts @@ -4,6 +4,8 @@ * Demonstrates how to define tasks with explicit dependency chains * (design → implement → test → review) using runTasks(). The TaskQueue * automatically blocks downstream tasks until their dependencies complete. + * Prompt context is dependency-scoped by default: each task sees only its own + * description plus direct dependency results (not unrelated team outputs). * * Run: * npx tsx examples/03-task-pipeline.ts @@ -116,6 +118,7 @@ const tasks: Array<{ description: string assignee?: string dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' }> = [ { title: 'Design: URL shortener data model', @@ -162,6 +165,9 @@ Produce a structured code review with sections: - Verdict: SHIP or NEEDS WORK`, assignee: 'reviewer', dependsOn: ['Implement: URL shortener'], // runs in parallel with Test after Implement completes + // Optional override: reviewers can opt into full shared memory when needed. + // Remove this line to keep strict dependency-only context. + memoryScope: 'all', }, ] diff --git a/src/memory/shared.ts b/src/memory/shared.ts index 2cdcf57..00649c2 100644 --- a/src/memory/shared.ts +++ b/src/memory/shared.ts @@ -124,8 +124,18 @@ export class SharedMemory { * - plan: Implement feature X using const type params * ``` */ - async getSummary(): Promise { - const all = await this.store.list() + async getSummary(filter?: { taskIds?: string[] }): Promise { + let all = await this.store.list() + if (filter?.taskIds && filter.taskIds.length > 0) { + const taskIds = new Set(filter.taskIds) + all = all.filter((entry) => { + const slashIdx = entry.key.indexOf('/') + const localKey = slashIdx === -1 ? entry.key : entry.key.slice(slashIdx + 1) + if (!localKey.startsWith('task:') || !localKey.endsWith(':result')) return false + const taskId = localKey.slice('task:'.length, localKey.length - ':result'.length) + return taskIds.has(taskId) + }) + } if (all.length === 0) return '' // Group entries by agent name. diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index ef2d07a..20cc123 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -324,6 +324,10 @@ interface ParsedTaskSpec { description: string assignee?: string dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' + maxRetries?: number + retryDelayMs?: number + retryBackoff?: number } /** @@ -362,6 +366,10 @@ function parseTaskSpecs(raw: string): ParsedTaskSpec[] | null { dependsOn: Array.isArray(obj['dependsOn']) ? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string') : undefined, + memoryScope: obj['memoryScope'] === 'all' ? 'all' : undefined, + maxRetries: typeof obj['maxRetries'] === 'number' ? obj['maxRetries'] : undefined, + retryDelayMs: typeof obj['retryDelayMs'] === 'number' ? obj['retryDelayMs'] : undefined, + retryBackoff: typeof obj['retryBackoff'] === 'number' ? obj['retryBackoff'] : undefined, }) } @@ -492,8 +500,8 @@ async function executeQueue( data: task, } satisfies OrchestratorEvent) - // Build the prompt: inject shared memory context + task description - const prompt = await buildTaskPrompt(task, team) + // Build the prompt: task description + dependency-only context by default. + const prompt = await buildTaskPrompt(task, team, queue) // Build trace context for this task's agent run const traceOptions: Partial | undefined = config.onTrace @@ -626,22 +634,37 @@ async function executeQueue( * * Injects: * - Task title and description - * - Dependency results from shared memory (if available) + * - Direct dependency task results by default (clean slate when none) + * - Optional full shared-memory context when `task.memoryScope === 'all'` * - Any messages addressed to this agent from the team bus */ -async function buildTaskPrompt(task: Task, team: Team): Promise { +async function buildTaskPrompt(task: Task, team: Team, queue: TaskQueue): Promise { const lines: string[] = [ `# Task: ${task.title}`, '', task.description, ] - // Inject shared memory summary so the agent sees its teammates' work - const sharedMem = team.getSharedMemoryInstance() - if (sharedMem) { - const summary = await sharedMem.getSummary() - if (summary) { - lines.push('', summary) + if (task.memoryScope === 'all') { + // Explicit opt-in for full visibility (legacy/shared-memory behavior). + const sharedMem = team.getSharedMemoryInstance() + if (sharedMem) { + const summary = await sharedMem.getSummary() + if (summary) { + lines.push('', summary) + } + } + } else if (task.dependsOn && task.dependsOn.length > 0) { + // Default-deny: inject only explicit prerequisite outputs. + const depResults: string[] = [] + for (const depId of task.dependsOn) { + const depTask = queue.get(depId) + if (depTask?.status === 'completed' && depTask.result) { + depResults.push(`### ${depTask.title} (by ${depTask.assignee ?? 'unknown'})\n${depTask.result}`) + } + } + if (depResults.length > 0) { + lines.push('', '## Context from prerequisite tasks', '', ...depResults) } } @@ -1071,6 +1094,7 @@ export class OpenMultiAgent { description: string assignee?: string dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' maxRetries?: number retryDelayMs?: number retryBackoff?: number @@ -1087,6 +1111,7 @@ export class OpenMultiAgent { description: t.description, assignee: t.assignee, dependsOn: t.dependsOn, + memoryScope: t.memoryScope, maxRetries: t.maxRetries, retryDelayMs: t.retryDelayMs, retryBackoff: t.retryBackoff, @@ -1308,6 +1333,7 @@ export class OpenMultiAgent { */ private loadSpecsIntoQueue( specs: ReadonlyArray t.status === status) } + /** Returns a task by ID, if present. */ + get(taskId: string): Task | undefined { + return this.tasks.get(taskId) + } + /** * Returns `true` when every task in the queue has reached a terminal state * (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty. diff --git a/src/task/task.ts b/src/task/task.ts index d74e70b..9085352 100644 --- a/src/task/task.ts +++ b/src/task/task.ts @@ -31,6 +31,7 @@ export function createTask(input: { description: string assignee?: string dependsOn?: string[] + memoryScope?: 'dependencies' | 'all' maxRetries?: number retryDelayMs?: number retryBackoff?: number @@ -43,6 +44,7 @@ export function createTask(input: { status: 'pending' as TaskStatus, assignee: input.assignee, dependsOn: input.dependsOn ? [...input.dependsOn] : undefined, + memoryScope: input.memoryScope, result: undefined, createdAt: now, updatedAt: now, diff --git a/src/types.ts b/src/types.ts index 98f0397..730b315 100644 --- a/src/types.ts +++ b/src/types.ts @@ -355,6 +355,12 @@ export interface Task { assignee?: string /** IDs of tasks that must complete before this one can start. */ dependsOn?: readonly string[] + /** + * Controls what prior team context is injected into this task's prompt. + * - `dependencies` (default): only direct dependency task results + * - `all`: full shared-memory summary + */ + readonly memoryScope?: 'dependencies' | 'all' result?: string readonly createdAt: Date updatedAt: Date diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index 8f4094b..5ef0052 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -43,6 +43,7 @@ function createMockAdapter(responses: string[]): LLMAdapter { */ let mockAdapterResponses: string[] = [] let capturedChatOptions: LLMChatOptions[] = [] +let capturedPrompts: string[] = [] vi.mock('../src/llm/adapter.js', () => ({ createAdapter: async () => { @@ -51,6 +52,12 @@ vi.mock('../src/llm/adapter.js', () => ({ name: 'mock', async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise { capturedChatOptions.push(options) + const lastUser = [..._msgs].reverse().find((m) => m.role === 'user') + const prompt = (lastUser?.content ?? []) + .filter((b): b is { type: 'text'; text: string } => b.type === 'text') + .map((b) => b.text) + .join('\n') + capturedPrompts.push(prompt) const text = mockAdapterResponses[callIndex] ?? 'default mock response' callIndex++ return { @@ -97,6 +104,7 @@ describe('OpenMultiAgent', () => { beforeEach(() => { mockAdapterResponses = [] capturedChatOptions = [] + capturedPrompts = [] }) describe('createTeam', () => { @@ -198,6 +206,67 @@ describe('OpenMultiAgent', () => { expect(result.success).toBe(true) }) + + it('uses a clean slate for tasks without dependencies', async () => { + mockAdapterResponses = ['alpha done', 'beta done'] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + await oma.runTasks(team, [ + { title: 'Independent A', description: 'Do independent A', assignee: 'worker-a' }, + { title: 'Independent B', description: 'Do independent B', assignee: 'worker-b' }, + ]) + + const workerPrompts = capturedPrompts.slice(0, 2) + expect(workerPrompts[0]).toContain('# Task: Independent A') + expect(workerPrompts[1]).toContain('# Task: Independent B') + expect(workerPrompts[0]).not.toContain('## Shared Team Memory') + expect(workerPrompts[1]).not.toContain('## Shared Team Memory') + expect(workerPrompts[0]).not.toContain('## Context from prerequisite tasks') + expect(workerPrompts[1]).not.toContain('## Context from prerequisite tasks') + }) + + it('injects only dependency results into dependent task prompts', async () => { + mockAdapterResponses = ['first output', 'second output'] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + await oma.runTasks(team, [ + { title: 'First', description: 'Produce first', assignee: 'worker-a' }, + { title: 'Second', description: 'Use first', assignee: 'worker-b', dependsOn: ['First'] }, + ]) + + const secondPrompt = capturedPrompts[1] ?? '' + expect(secondPrompt).toContain('## Context from prerequisite tasks') + expect(secondPrompt).toContain('### First (by worker-a)') + expect(secondPrompt).toContain('first output') + expect(secondPrompt).not.toContain('## Shared Team Memory') + }) + + it('supports memoryScope all opt-in for full shared memory visibility', async () => { + mockAdapterResponses = ['writer output', 'reader output'] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + await oma.runTasks(team, [ + { title: 'Write', description: 'Write something', assignee: 'worker-a' }, + { + title: 'Read all', + description: 'Read everything', + assignee: 'worker-b', + memoryScope: 'all', + dependsOn: ['Write'], + }, + ]) + + const secondPrompt = capturedPrompts[1] ?? '' + expect(secondPrompt).toContain('## Shared Team Memory') + expect(secondPrompt).toContain('task:') + expect(secondPrompt).not.toContain('## Context from prerequisite tasks') + }) }) describe('runTeam', () => { diff --git a/tests/shared-memory.test.ts b/tests/shared-memory.test.ts index 1467c95..87f28d8 100644 --- a/tests/shared-memory.test.ts +++ b/tests/shared-memory.test.ts @@ -107,6 +107,19 @@ describe('SharedMemory', () => { expect(summary).toContain('…') }) + it('filters summary to only requested task IDs', async () => { + const mem = new SharedMemory() + await mem.write('alice', 'task:t1:result', 'output 1') + await mem.write('bob', 'task:t2:result', 'output 2') + await mem.write('alice', 'notes', 'not a task result') + + const summary = await mem.getSummary({ taskIds: ['t2'] }) + expect(summary).toContain('### bob') + expect(summary).toContain('task:t2:result: output 2') + expect(summary).not.toContain('task:t1:result: output 1') + expect(summary).not.toContain('notes: not a task result') + }) + // ------------------------------------------------------------------------- // listAll // ------------------------------------------------------------------------- diff --git a/tests/task-queue.test.ts b/tests/task-queue.test.ts index 87a2500..20d04fa 100644 --- a/tests/task-queue.test.ts +++ b/tests/task-queue.test.ts @@ -27,6 +27,7 @@ describe('TaskQueue', () => { q.add(task('a')) expect(q.list()).toHaveLength(1) expect(q.list()[0].id).toBe('a') + expect(q.get('a')?.title).toBe('a') }) it('fires task:ready for a task with no dependencies', () => {