feat: enforce dependency-scoped agent context (default-deny) (#87)

Co-authored-by: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com>
This commit is contained in:
Ibrahim Kazimov 2026-04-09 22:09:58 +03:00 committed by GitHub
parent f1c7477a26
commit aa5fab59fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 151 additions and 12 deletions

View File

@ -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',
},
]

View File

@ -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.

View File

@ -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,

View File

@ -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.

View File

@ -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,

View File

@ -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

View File

@ -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', () => {

View File

@ -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
// -------------------------------------------------------------------------

View File

@ -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', () => {