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
|
||||
* (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',
|
||||
},
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -124,8 +124,18 @@ export class SharedMemory {
|
|||
* - plan: Implement feature X using const type params
|
||||
* ```
|
||||
*/
|
||||
async getSummary(): Promise<string> {
|
||||
const all = await this.store.list()
|
||||
async getSummary(filter?: { taskIds?: string[] }): Promise<string> {
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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<RunOptions> | 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<string> {
|
||||
async function buildTaskPrompt(task: Task, team: Team, queue: TaskQueue): Promise<string> {
|
||||
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<ParsedTaskSpec & {
|
||||
memoryScope?: 'dependencies' | 'all'
|
||||
maxRetries?: number
|
||||
retryDelayMs?: number
|
||||
retryBackoff?: number
|
||||
|
|
@ -1328,6 +1354,7 @@ export class OpenMultiAgent {
|
|||
assignee: spec.assignee && agentNames.has(spec.assignee)
|
||||
? spec.assignee
|
||||
: undefined,
|
||||
memoryScope: spec.memoryScope,
|
||||
maxRetries: spec.maxRetries,
|
||||
retryDelayMs: spec.retryDelayMs,
|
||||
retryBackoff: spec.retryBackoff,
|
||||
|
|
|
|||
|
|
@ -289,6 +289,11 @@ export class TaskQueue {
|
|||
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
|
||||
* (`'completed'`, `'failed'`, or `'skipped'`), **or** the queue is empty.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<LLMResponse> {
|
||||
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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue