Skip to content

[P1] SharedMemory Context Isolation -- scope reads by task dependency graph #73

@JackChen-me

Description

@JackChen-me

Context

Raised in #59 comments: the current SharedMemory is a global namespace -- every agent sees every other agent's outputs, regardless of task dependencies. This causes inter-agent context pollution: Agent B's judgment gets influenced by Agent A's intermediate results even when the two tasks are completely independent.

This is a different problem from #59 (intra-agent context growth). #59 is about a single agent's conversation getting too long. This issue is about what context an agent sees from its teammates.

The root cause is an architectural one: our framework is default-allow -- agents see everything via SharedMemory unless we filter it. The correct model is default-deny: agents see nothing by default, the orchestrator explicitly passes only what's needed. With default-deny, context leakage is impossible by design, not by policy.

Where the leak happens today

buildTaskPrompt() in orchestrator.ts:474 calls sharedMem.getSummary() which dumps all entries from all agents into every task prompt:

// orchestrator.ts:484-488
const sharedMem = team.getSharedMemoryInstance()
if (sharedMem) {
  const summary = await sharedMem.getSummary()  // Returns EVERYTHING from ALL agents
  if (summary) {
    lines.push('', summary)
  }
}

A team with 5 agents running 10 tasks means every agent prompt contains outputs from all prior tasks -- including unrelated ones. This:

  1. Pollutes judgment -- irrelevant context from unrelated tasks can bias an agent's output
  2. Wastes tokens -- agent pays for context it shouldn't need
  3. Makes debugging painful -- when an agent produces bad output, hard to tell which piece of injected context caused it

Design: Pure-function agents with dependency-only context

Core principle

Agents are pure functions: (task description + dependency results) -> result string. No shared mutable state. The orchestrator (not the agent) decides what context to inject.

Change 1: buildTaskPrompt() -- dependency results from TaskQueue, not SharedMemory

Before (current, leaky):

async function buildTaskPrompt(task: Task, team: Team): Promise<string> {
  const lines = [`# Task: ${task.title}`, '', task.description]

  // PROBLEM: injects ALL agents' outputs
  const sharedMem = team.getSharedMemoryInstance()
  if (sharedMem) {
    const summary = await sharedMem.getSummary()
    if (summary) lines.push('', summary)
  }
  // ...
}

After (pure-function model):

async function buildTaskPrompt(
  task: Task,
  team: Team,
  queue: TaskQueue,  // NEW: pass queue to read dependency results
): Promise<string> {
  const lines = [`# Task: ${task.title}`, '', task.description]

  // Only inject results from tasks this task EXPLICITLY depends on.
  // No dependency = clean slate: zero prior context.
  if (task.dependsOn && task.dependsOn.length > 0) {
    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)
    }
  }

  // Messages from team members -- keep as-is (explicit point-to-point)
  if (task.assignee) {
    const messages = team.getMessages(task.assignee)
    if (messages.length > 0) {
      lines.push('', '## Messages from team members')
      for (const msg of messages) {
        lines.push(`- **${msg.from}**: ${msg.content}`)
      }
    }
  }

  // NOTE: SharedMemory is NOT read here. Agent runs with a clean context.
  return lines.join('\n')
}

Key behaviors:

  • No dependencies -> agent gets ONLY its task description. Clean slate.
  • Has dependencies -> agent gets task description + completed dependency task results (read from queue.get(depId).result, not from SharedMemory).
  • SharedMemory is never read during agent execution.

Change 2: executeQueue() -- pass TaskQueue to buildTaskPrompt

// orchestrator.ts, inside executeQueue(), around line 359
-      const prompt = await buildTaskPrompt(task, team)
+      const prompt = await buildTaskPrompt(task, team, queue)

Change 3: SharedMemory write path -- keep for coordinator synthesis only

Currently orchestrator.ts:404-407 writes task results to SharedMemory. Keep this write, but document its changed role. SharedMemory is now orchestrator-internal storage used only by buildSynthesisPrompt() to give the coordinator a full view of all results. Agents never read it.

Change 4: buildSynthesisPrompt() -- unchanged (coordinator needs full view)

The coordinator's synthesis step already reads from the task queue AND SharedMemory. This is correct -- the coordinator needs full visibility to synthesize. No change needed here.

Change 5: TaskQueue.get() -- add method if missing

// task/queue.ts
get(taskId: string): Task | undefined {
  return this.tasks.get(taskId)
}

Change 6: Optional memoryScope override on Task

interface Task {
  // ...existing fields...
  readonly memoryScope?: 'dependencies' | 'all'
}

Change 7: getSummary() -- add optional filter

async getSummary(filter?: { taskIds?: string[] }): Promise<string>

Change 8: runTasks() -- thread TaskQueue through

Ensure TaskQueue is threaded to executeQueue() -> buildTaskPrompt() correctly.

What this does NOT change

  • SharedMemory class, MessageBus, Team public API, runTeam() / runTasks() public API, task result persistence

Acceptance criteria

  • buildTaskPrompt() signature updated to accept TaskQueue
  • buildTaskPrompt() default: reads dependency task results from TaskQueue, NOT SharedMemory
  • Tasks with no dependencies get zero prior context (clean slate)
  • TaskQueue.get(taskId) method available (add if missing)
  • memoryScope: 'all' opt-in on Task interface for full-visibility use cases
  • memoryScope threaded through runTasks() task spec input
  • SharedMemory write path unchanged (results still written for coordinator synthesis)
  • buildSynthesisPrompt() unchanged (coordinator retains full visibility)
  • getSummary() accepts optional { taskIds?: string[] } filter
  • Tests for all isolation behaviors
  • Example updated to demonstrate dependency-based context injection

Non-goals

  • Removing SharedMemory entirely (still needed for coordinator synthesis)
  • Per-field read/write ACLs on SharedMemory (overkill)
  • Process-level agent isolation (our agents run in-process, which is fine as long as context injection is controlled)
  • Breaking changes to the public API

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Implement later — still lightweightenhancementNew feature or requestsource:competitiveSource: competitive analysissource:feedbackSource: external user feedback (GitHub/Twitter/Reddit/Discord/forks)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions