feat: enforce dependency-scoped agent context (default-deny) (#87)
Co-authored-by: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com>
This commit is contained in:
parent
f1c7477a26
commit
aa5fab59fa
|
|
@ -4,6 +4,8 @@
|
||||||
* Demonstrates how to define tasks with explicit dependency chains
|
* Demonstrates how to define tasks with explicit dependency chains
|
||||||
* (design → implement → test → review) using runTasks(). The TaskQueue
|
* (design → implement → test → review) using runTasks(). The TaskQueue
|
||||||
* automatically blocks downstream tasks until their dependencies complete.
|
* 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:
|
* Run:
|
||||||
* npx tsx examples/03-task-pipeline.ts
|
* npx tsx examples/03-task-pipeline.ts
|
||||||
|
|
@ -116,6 +118,7 @@ const tasks: Array<{
|
||||||
description: string
|
description: string
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dependsOn?: string[]
|
dependsOn?: string[]
|
||||||
|
memoryScope?: 'dependencies' | 'all'
|
||||||
}> = [
|
}> = [
|
||||||
{
|
{
|
||||||
title: 'Design: URL shortener data model',
|
title: 'Design: URL shortener data model',
|
||||||
|
|
@ -162,6 +165,9 @@ Produce a structured code review with sections:
|
||||||
- Verdict: SHIP or NEEDS WORK`,
|
- Verdict: SHIP or NEEDS WORK`,
|
||||||
assignee: 'reviewer',
|
assignee: 'reviewer',
|
||||||
dependsOn: ['Implement: URL shortener'], // runs in parallel with Test after Implement completes
|
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',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,18 @@ export class SharedMemory {
|
||||||
* - plan: Implement feature X using const type params
|
* - plan: Implement feature X using const type params
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
async getSummary(): Promise<string> {
|
async getSummary(filter?: { taskIds?: string[] }): Promise<string> {
|
||||||
const all = await this.store.list()
|
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 ''
|
if (all.length === 0) return ''
|
||||||
|
|
||||||
// Group entries by agent name.
|
// Group entries by agent name.
|
||||||
|
|
|
||||||
|
|
@ -324,6 +324,10 @@ interface ParsedTaskSpec {
|
||||||
description: string
|
description: string
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dependsOn?: 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'])
|
dependsOn: Array.isArray(obj['dependsOn'])
|
||||||
? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string')
|
? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string')
|
||||||
: undefined,
|
: 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,
|
data: task,
|
||||||
} satisfies OrchestratorEvent)
|
} satisfies OrchestratorEvent)
|
||||||
|
|
||||||
// Build the prompt: inject shared memory context + task description
|
// Build the prompt: task description + dependency-only context by default.
|
||||||
const prompt = await buildTaskPrompt(task, team)
|
const prompt = await buildTaskPrompt(task, team, queue)
|
||||||
|
|
||||||
// Build trace context for this task's agent run
|
// Build trace context for this task's agent run
|
||||||
const traceOptions: Partial<RunOptions> | undefined = config.onTrace
|
const traceOptions: Partial<RunOptions> | undefined = config.onTrace
|
||||||
|
|
@ -626,22 +634,37 @@ async function executeQueue(
|
||||||
*
|
*
|
||||||
* Injects:
|
* Injects:
|
||||||
* - Task title and description
|
* - 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
|
* - Any messages addressed to this agent from the team bus
|
||||||
*/
|
*/
|
||||||
async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
|
async function buildTaskPrompt(task: Task, team: Team, queue: TaskQueue): Promise<string> {
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`# Task: ${task.title}`,
|
`# Task: ${task.title}`,
|
||||||
'',
|
'',
|
||||||
task.description,
|
task.description,
|
||||||
]
|
]
|
||||||
|
|
||||||
// Inject shared memory summary so the agent sees its teammates' work
|
if (task.memoryScope === 'all') {
|
||||||
const sharedMem = team.getSharedMemoryInstance()
|
// Explicit opt-in for full visibility (legacy/shared-memory behavior).
|
||||||
if (sharedMem) {
|
const sharedMem = team.getSharedMemoryInstance()
|
||||||
const summary = await sharedMem.getSummary()
|
if (sharedMem) {
|
||||||
if (summary) {
|
const summary = await sharedMem.getSummary()
|
||||||
lines.push('', summary)
|
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
|
description: string
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dependsOn?: string[]
|
dependsOn?: string[]
|
||||||
|
memoryScope?: 'dependencies' | 'all'
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
retryDelayMs?: number
|
retryDelayMs?: number
|
||||||
retryBackoff?: number
|
retryBackoff?: number
|
||||||
|
|
@ -1087,6 +1111,7 @@ export class OpenMultiAgent {
|
||||||
description: t.description,
|
description: t.description,
|
||||||
assignee: t.assignee,
|
assignee: t.assignee,
|
||||||
dependsOn: t.dependsOn,
|
dependsOn: t.dependsOn,
|
||||||
|
memoryScope: t.memoryScope,
|
||||||
maxRetries: t.maxRetries,
|
maxRetries: t.maxRetries,
|
||||||
retryDelayMs: t.retryDelayMs,
|
retryDelayMs: t.retryDelayMs,
|
||||||
retryBackoff: t.retryBackoff,
|
retryBackoff: t.retryBackoff,
|
||||||
|
|
@ -1308,6 +1333,7 @@ export class OpenMultiAgent {
|
||||||
*/
|
*/
|
||||||
private loadSpecsIntoQueue(
|
private loadSpecsIntoQueue(
|
||||||
specs: ReadonlyArray<ParsedTaskSpec & {
|
specs: ReadonlyArray<ParsedTaskSpec & {
|
||||||
|
memoryScope?: 'dependencies' | 'all'
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
retryDelayMs?: number
|
retryDelayMs?: number
|
||||||
retryBackoff?: number
|
retryBackoff?: number
|
||||||
|
|
@ -1328,6 +1354,7 @@ export class OpenMultiAgent {
|
||||||
assignee: spec.assignee && agentNames.has(spec.assignee)
|
assignee: spec.assignee && agentNames.has(spec.assignee)
|
||||||
? spec.assignee
|
? spec.assignee
|
||||||
: undefined,
|
: undefined,
|
||||||
|
memoryScope: spec.memoryScope,
|
||||||
maxRetries: spec.maxRetries,
|
maxRetries: spec.maxRetries,
|
||||||
retryDelayMs: spec.retryDelayMs,
|
retryDelayMs: spec.retryDelayMs,
|
||||||
retryBackoff: spec.retryBackoff,
|
retryBackoff: spec.retryBackoff,
|
||||||
|
|
|
||||||
|
|
@ -289,6 +289,11 @@ export class TaskQueue {
|
||||||
return this.list().filter((t) => t.status === status)
|
return this.list().filter((t) => 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
|
* Returns `true` when every task in the queue has reached a terminal state
|
||||||
* (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.
|
* (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ export function createTask(input: {
|
||||||
description: string
|
description: string
|
||||||
assignee?: string
|
assignee?: string
|
||||||
dependsOn?: string[]
|
dependsOn?: string[]
|
||||||
|
memoryScope?: 'dependencies' | 'all'
|
||||||
maxRetries?: number
|
maxRetries?: number
|
||||||
retryDelayMs?: number
|
retryDelayMs?: number
|
||||||
retryBackoff?: number
|
retryBackoff?: number
|
||||||
|
|
@ -43,6 +44,7 @@ export function createTask(input: {
|
||||||
status: 'pending' as TaskStatus,
|
status: 'pending' as TaskStatus,
|
||||||
assignee: input.assignee,
|
assignee: input.assignee,
|
||||||
dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
|
dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
|
||||||
|
memoryScope: input.memoryScope,
|
||||||
result: undefined,
|
result: undefined,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|
|
||||||
|
|
@ -355,6 +355,12 @@ export interface Task {
|
||||||
assignee?: string
|
assignee?: string
|
||||||
/** IDs of tasks that must complete before this one can start. */
|
/** IDs of tasks that must complete before this one can start. */
|
||||||
dependsOn?: readonly string[]
|
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
|
result?: string
|
||||||
readonly createdAt: Date
|
readonly createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ function createMockAdapter(responses: string[]): LLMAdapter {
|
||||||
*/
|
*/
|
||||||
let mockAdapterResponses: string[] = []
|
let mockAdapterResponses: string[] = []
|
||||||
let capturedChatOptions: LLMChatOptions[] = []
|
let capturedChatOptions: LLMChatOptions[] = []
|
||||||
|
let capturedPrompts: string[] = []
|
||||||
|
|
||||||
vi.mock('../src/llm/adapter.js', () => ({
|
vi.mock('../src/llm/adapter.js', () => ({
|
||||||
createAdapter: async () => {
|
createAdapter: async () => {
|
||||||
|
|
@ -51,6 +52,12 @@ vi.mock('../src/llm/adapter.js', () => ({
|
||||||
name: 'mock',
|
name: 'mock',
|
||||||
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
||||||
capturedChatOptions.push(options)
|
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'
|
const text = mockAdapterResponses[callIndex] ?? 'default mock response'
|
||||||
callIndex++
|
callIndex++
|
||||||
return {
|
return {
|
||||||
|
|
@ -97,6 +104,7 @@ describe('OpenMultiAgent', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockAdapterResponses = []
|
mockAdapterResponses = []
|
||||||
capturedChatOptions = []
|
capturedChatOptions = []
|
||||||
|
capturedPrompts = []
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('createTeam', () => {
|
describe('createTeam', () => {
|
||||||
|
|
@ -198,6 +206,67 @@ describe('OpenMultiAgent', () => {
|
||||||
|
|
||||||
expect(result.success).toBe(true)
|
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', () => {
|
describe('runTeam', () => {
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,19 @@ describe('SharedMemory', () => {
|
||||||
expect(summary).toContain('…')
|
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
|
// listAll
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ describe('TaskQueue', () => {
|
||||||
q.add(task('a'))
|
q.add(task('a'))
|
||||||
expect(q.list()).toHaveLength(1)
|
expect(q.list()).toHaveLength(1)
|
||||||
expect(q.list()[0].id).toBe('a')
|
expect(q.list()[0].id).toBe('a')
|
||||||
|
expect(q.get('a')?.title).toBe('a')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('fires task:ready for a task with no dependencies', () => {
|
it('fires task:ready for a task with no dependencies', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue