Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

State Management in Orchestrated Agents

Introduction

In our last lesson, we significantly upgraded our orchestrator to execute independent tasks in parallel, a crucial step for building a high-performance agentic system. We used Promise.all to run concurrent agents and a simple Map to collect and pass their results to dependent tasks. While effective, this approach treats the data flow as a simple handoff from one task to the next. As workflows become more complex, agents often need to collaborate in a shared "workspace" rather than just passing notes.

This lesson addresses that need by exploring how to maintain and propagate a shared state across all orchestrated agent calls. We will move beyond our basic results map to implement a more robust "blackboard" system. You will learn the conceptual patterns for managing shared information, the practical mechanisms for implementing them with the Vercel AI SDK, and, most importantly, the engineering techniques required to prevent data corruption when multiple agents interact with the state concurrently.

The Concept of a Shared State Blackboard

As our orchestrator dispatches multiple agents, they may need to access a common pool of information that evolves throughout the execution. This shared context can include:

  • The initial user query and any constraints.
  • Results from previously completed tasks.
  • A running log of decisions made.
  • Data fetched from external tools that multiple agents might need.
  • The cumulative conversation history.

This central, shared workspace is often referred to as a blackboard. In this model, agents don't just communicate directly with each other; they read from and write to the blackboard, allowing for a more decoupled and flexible collaboration.

The LangChain team provides an excellent conceptual overview of how agents can communicate. They highlight two main methods: passing information directly via tool calls (similar to our previous lesson) and using a shared state object (the blackboard pattern).

Conceptual Guide: Multi Agent Architectures

This video from the LangChain channel offers a concise explanation of agent communication patterns.

Please watch the segment from agent communication methods. Focus on the distinction between communicating solely through tool calls versus using a shared state object. The video illustrates how a shared object allows agents to contribute to and read from a common data structure, even if their internal states differ.

As the video explains, a shared state object allows for richer, more flexible interaction than simply passing the output of one agent as the input to another.

Implementing Shared State with the Vercel AI SDK

The Vercel AI SDK, which forms the core of our technical stack, provides several mechanisms to implement and propagate a shared state. While the message history itself is an implicit form of shared state, the SDK also offers more explicit methods.

A particularly useful feature is the ability to pass an arbitrary context object directly into the tool execution lifecycle. This allows our orchestrator to inject our shared blackboard into every tool call an agent makes.

AI SDK Core: Tool Calling

The Vercel AI SDK documentation details the lifecycle of tool execution, including hooks for passing in data.

Read the sections titled Messages and Context (experimental). The experimental_context setting is key here; it provides a direct channel for our orchestrator to pass a shared state object that becomes accessible inside any tool's execute function.

This experimental_context is the perfect hook for us. It allows the orchestrator to maintain a single SharedState object for the entire request and ensure every agent, no matter how deep in the hierarchy, has access to it.

Another powerful pattern for injecting context is using dynamic call options, which allow you to pass structured data to an agent when it's invoked.

Build an Agent with Vercel

This video from Vercel showcases advanced features of their AI SDK.

Watch the section on Dynamic Call Options. This demonstrates how an orchestrator can pass structured context, like a user ID or account type, to a sub-agent. This is a great pattern for propagating tenant-specific or user-specific information into the shared state at the beginning of a workflow.

The Critical Challenge: Race Conditions

When we run agents in parallel, as we did in the last lesson, we introduce a significant engineering risk: the race condition. This occurs when two or more concurrent processes attempt to read and then modify the same piece of data, leading to unpredictable and incorrect results because one process's changes can overwrite another's. This is often called a "last-write-wins" scenario, and it can silently corrupt your shared state.

Imagine two agents running in parallel:

  1. Agent A (Pricing): Reads the base price of a product, which is $100.
  2. Agent B (Discount): Also reads the base price, $100.
  3. Agent A: Calculates a 10% service fee and proposes a new price of $110.
  4. Agent B: Calculates a 20% discount and proposes a new price of $80.
  5. Agent A writes $110 to the shared state.
  6. Agent B writes $80 to the shared state immediately after.

The final price is $80. The service fee calculated by Agent A has been completely lost. This is a classic split-brain failure.

Production-grade multi-agent systems must prevent this. One of the most robust solutions is to enforce atomic operations on the shared state using a locking mechanism, often structured as a propose → validate → commit transaction. An agent proposes a change, the system may validate it, and only then is it committed to the state, all while other agents are locked out from writing to that specific piece of data.

A specialized multi-agent orchestrator library, Network-AI, is built around this very principle.

GitHub - Jovancoding/Network-AI: Traffic light for AI Agents and ...

This GitHub repository is for an open-source orchestrator that excels at coordination. While we are building our own system, its design principles are highly instructive.

First, review the introduction to understand its core features, particularly the shared blackboard concept. Then, look at the table under Why teams use Network-AI to see how it solves race conditions. Finally, and most importantly, study the code example under the heading Two agents, one shared state. This TypeScript snippet demonstrates exactly how a LockedBlackboard can be used to ensure two concurrent agents write to the state without corrupting it.

This propose -> validate -> commit pattern, backed by a mutex (a mutual exclusion lock), guarantees that writes are serialized, preventing data loss even with high parallelism.

Upgrading Our Orchestrator with a SharedState Class

Now, let's apply these principles to our own executePlan function. We will create a SharedState class that encapsulates our blackboard and includes a simple locking mechanism to ensure data integrity.

Here is the implementation of our new SharedState class. It's a simplified version of the industrial-strength approach but demonstrates the core principle of atomic updates.

// src/shared-state.ts

export class SharedState {
  private state: Map<string, any>;
  private locks: Set<string>;

  constructor(initialState?: Record<string, any>) {
    this.state = new Map(initialState ? Object.entries(initialState) : undefined);
    this.locks = new Set();
  }

  public read(key: string): any {
    return this.state.get(key);
  }

  /**
   * Performs a transactional update on a key.
   * It acquires a lock, executes the update function, and releases the lock.
   * This prevents race conditions when multiple agents try to update the same key concurrently.
   * @param key The key in the state to update.
   * @param updateFn A function that receives the current value and returns the new value.
   */
  public async transact(key: string, updateFn: (currentValue: any) => any): Promise<void> {
    // Wait until the lock for this key is released.
    while (this.locks.has(key)) {
      await new Promise(resolve => setTimeout(resolve, 50)); // Simple polling wait
    }

    try {
      // Acquire lock
      this.locks.add(key);

      const currentValue = this.state.get(key);
      const newValue = await Promise.resolve(updateFn(currentValue));
      this.state.set(key, newValue);

    } finally {
      // Release lock
      this.locks.delete(key);
    }
  }

  /**
   * A simpler, non-transactional write. Use with caution for keys that are not contested.
   */
  public write(key: string, value: any): void {
    if (this.locks.has(key)) {
        throw new Error(`Cannot perform a direct write on locked key: ${key}. Use transact() instead.`);
    }
    this.state.set(key, value);
  }

  public toObject(): Record<string, any> {
    return Object.fromEntries(this.state);
  }
}

Now, let's integrate this class into our executePlan function. The key changes are:

  1. We instantiate SharedState at the beginning of the execution.
  2. We pass the sharedState instance to each agent call using the experimental_context option we learned about.
  3. We use sharedState.write() to store the final result of a task, since each task ID is unique and won't have write conflicts.
// Assume agent definitions and 'Subtask' type are defined.
// Make sure to import the SharedState class.

async function executePlan(plan: { plan: Subtask[] }, initialContext: Record<string, any> = {}) {
  // 1. Instantiate the SharedState with any initial context.
  const sharedState = new SharedState({
    initialQuery: initialContext.query,
    plan: plan.plan,
  });

  const allTaskIds = new Set(plan.plan.map(t => t.id));
  const executedTaskIds = new Set<string>();
  const maxIterations = plan.plan.length;

  for (let i = 0; i < maxIterations && executedTaskIds.size < allTaskIds.size; i++) {
    const executableTasks = plan.plan.filter(task => 
      !executedTaskIds.has(task.id) &&
      task.dependencies.every(depId => executedTaskIds.has(depId))
    );

    if (executableTasks.length === 0) {
      // ... (deadlock detection logic as before)
      throw new Error("Deadlock detected.");
    }
    
    console.log(`Executing batch of ${executableTasks.length} parallel tasks...`);

    const taskPromises = executableTasks.map(async (task) => {
      const agentFunction = agentRegistry.get(task.agent);
      if (!agentFunction) {
        throw new Error(`Agent "${task.agent}" not found.`);
      }

      // Instead of manually creating context, the agent will read from SharedState.
      // We pass the SharedState object into the agent's execution context.
      const result = await agentFunction(
        task.task, 
        { experimental_context: { sharedState } } // Vercel AI SDK hook
      );
      
      return { id: task.id, result };
    });

    const results = await Promise.all(taskPromises);

    for (const { id, result } of results) {
      console.log(`Task ${id} completed.`);
      // 3. Write task result to the shared state. The key is the task ID.
      sharedState.write(`task_result_${id}`, result);
      executedTaskIds.add(id);
    }
  }

  return sharedState.toObject();
}

An agent function can now be written to use this injected state. For example, a summarizerAgent could read the results of two other tasks and use the transact method to safely update a summary field.

// Example agent function
async function summarizerAgent(prompt: string, options: { experimental_context: { sharedState: SharedState } }) {
  const { sharedState } = options.experimental_context;

  // Read results from dependency tasks
  const result1 = sharedState.read('task_result_news_analysis');
  const result2 = sharedState.read('task_result_financial_report');

  const combinedText = `News: ${result1.summary}. Finances: ${result2.highlights}.`;
  
  // Call LLM to summarize 'combinedText'...
  const summary = `...`; // Placeholder for actual LLM call result

  // Atomically update a shared 'final_summary' key
  await sharedState.transact('final_summary', (currentSummary) => {
    return (currentSummary ? currentSummary + "\n" : "") + summary;
  });

  return { status: "Summary appended" };
}
Our orchestrator acts as the "Main AI Agent," managing a central `SharedState` (Memory) that it propagates to all sub-agents, ensuring consistent and safe access to information across the entire workflow.

Conclusion

In this lesson, we have taken a massive leap forward in the robustness of our orchestrator. We have moved from a simple, fragile data-passing mechanism to a sophisticated shared state model that supports complex, concurrent agent collaboration.

Key Takeaways:

  • A Shared State Blackboard is a central workspace that allows decoupled agents to collaborate effectively.
  • Parallelism introduces the risk of race conditions, which can silently corrupt state. This is a critical failure mode in multi-agent systems.
  • Atomic operations, enforced by locks or a mutex (transact method), are the solution to prevent race conditions and ensure data integrity.
  • The Vercel AI SDK's experimental_context option provides an ideal mechanism for injecting our SharedState object into every tool and agent execution.

Our orchestrator is now not only fast and parallel but also safe. We have laid the groundwork for managing complex stateful interactions. In our next lesson, we will begin the "Conversation and State Management" module by focusing on how to implement short-term memory to maintain context within a single, unfolding request, building directly upon the SharedState foundation we've established today.

Can't find a good explanation? Sign up and we'll make it for you

Sign up