Published on

Multi-Agent Systems — Orchestrating Specialized Agents for Complex Tasks

Authors

Introduction

Complex tasks often exceed what a single agent can do well. A multi-agent system divides work among specialized agents: one researches, one writes, one critiques, one executes. This post covers orchestrating multiple agents, managing shared state, parallel execution, and using LangGraph to build production-grade multi-agent workflows.

Supervisor-Worker Pattern

The supervisor-worker pattern delegates tasks to specialized agents. The supervisor routes tasks and aggregates results.

interface TaskRequest {
  id: string;
  description: string;
  type: 'research' | 'writing' | 'critique' | 'execution';
  context: Record<string, unknown>;
  deadline?: number;
}

interface TaskResult {
  taskId: string;
  status: 'success' | 'failed' | 'timeout';
  output?: string;
  error?: string;
  executionTimeMs: number;
}

interface WorkerAgent {
  name: string;
  specialization: string;
  execute: (task: TaskRequest) => Promise<TaskResult>;
}

class SupervisorAgent {
  private workers: Map<string, WorkerAgent> = new Map();
  private taskQueue: TaskRequest[] = [];
  private results: Map<string, TaskResult> = new Map();

  registerWorker(worker: WorkerAgent): void {
    this.workers.set(worker.name, worker);
    console.log(`Registered worker: ${worker.name} (${worker.specialization})`);
  }

  async orchestrate(mainTask: string): Promise<string> {
    // Step 1: Decompose main task into subtasks
    const subtasks = await this.decomposeTasks(mainTask);
    console.log(`Decomposed into ${subtasks.length} subtasks`);

    // Step 2: Route tasks to appropriate workers
    const routedTasks = this.routeTasks(subtasks);

    // Step 3: Execute tasks (can be parallel)
    const results = await this.executeTasks(routedTasks);

    // Step 4: Aggregate results
    const finalOutput = await this.aggregateResults(results, mainTask);

    return finalOutput;
  }

  private async decomposeTasks(mainTask: string): Promise<TaskRequest[]> {
    const prompt = `Break this task into subtasks. Each subtask should be assignable to a specialist agent.
Return JSON: { "tasks": [{"description": "...", "type": "research|writing|critique|execution"}] }

Main task: ${mainTask}`;

    const response = await this.llmCall(prompt);

    try {
      const parsed = JSON.parse(response);
      return parsed.tasks.map(
        (t: any, i: number) =>
          ({
            id: `task-${i}`,
            description: t.description,
            type: t.type,
            context: { mainTask },
          } as TaskRequest),
      );
    } catch {
      return [
        {
          id: 'task-0',
          description: mainTask,
          type: 'execution',
          context: {},
        },
      ];
    }
  }

  private routeTasks(tasks: TaskRequest[]): Map<string, TaskRequest[]> {
    const routed = new Map<string, TaskRequest[]>();

    for (const task of tasks) {
      // Route based on task type
      let workerName = '';

      switch (task.type) {
        case 'research':
          workerName = 'researcher';
          break;
        case 'writing':
          workerName = 'writer';
          break;
        case 'critique':
          workerName = 'critic';
          break;
        case 'execution':
          workerName = 'executor';
          break;
      }

      if (!routed.has(workerName)) {
        routed.set(workerName, []);
      }

      routed.get(workerName)!.push(task);
    }

    return routed;
  }

  private async executeTasks(routedTasks: Map<string, TaskRequest[]>): Promise<TaskResult[]> {
    const results: TaskResult[] = [];

    // Execute in parallel where possible
    const promises: Promise<void>[] = [];

    for (const [workerName, tasks] of routedTasks.entries()) {
      const worker = this.workers.get(workerName);

      if (!worker) {
        console.warn(`No worker found for: ${workerName}`);
        continue;
      }

      for (const task of tasks) {
        promises.push(
          worker.execute(task).then((result) => {
            results.push(result);
          }),
        );
      }
    }

    await Promise.all(promises);

    return results;
  }

  private async aggregateResults(results: TaskResult[], mainTask: string): Promise<string> {
    const successResults = results.filter((r) => r.status === 'success');

    const resultsSummary = successResults
      .map((r) => `${r.taskId}: ${r.output}`)
      .join('\n\n');

    const prompt = `Original task: ${mainTask}

Results from specialist agents:
${resultsSummary}

Synthesize these results into a cohesive final answer.`;

    return this.llmCall(prompt);
  }

  private async llmCall(prompt: string): Promise<string> {
    return '';
  }
}

// Example workers
const researcherAgent: WorkerAgent = {
  name: 'researcher',
  specialization: 'research',
  async execute(task: TaskRequest): Promise<TaskResult> {
    const startTime = Date.now();

    try {
      // Use web search tools to research
      const output = `Research findings for: ${task.description}`;

      return {
        taskId: task.id,
        status: 'success',
        output,
        executionTimeMs: Date.now() - startTime,
      };
    } catch (error) {
      return {
        taskId: task.id,
        status: 'failed',
        error: (error as Error).message,
        executionTimeMs: Date.now() - startTime,
      };
    }
  },
};

const writerAgent: WorkerAgent = {
  name: 'writer',
  specialization: 'writing',
  async execute(task: TaskRequest): Promise<TaskResult> {
    const startTime = Date.now();

    try {
      const output = `Written content for: ${task.description}`;

      return {
        taskId: task.id,
        status: 'success',
        output,
        executionTimeMs: Date.now() - startTime,
      };
    } catch (error) {
      return {
        taskId: task.id,
        status: 'failed',
        error: (error as Error).message,
        executionTimeMs: Date.now() - startTime,
      };
    }
  },
};

The supervisor-worker pattern scales to dozens of agents. Each agent specializes in one type of task.

Agent-to-Agent Communication

Agents need to pass information to each other, not just through the supervisor.

interface Message {
  from: string;
  to: string;
  subject: string;
  content: string;
  timestamp: number;
}

interface AgentInbox {
  agentName: string;
  messages: Message[];
}

class AgentCommunicationBus {
  private inboxes: Map<string, AgentInbox> = new Map();
  private messageLog: Message[] = [];

  registerAgent(agentName: string): void {
    this.inboxes.set(agentName, {
      agentName,
      messages: [],
    });
  }

  async sendMessage(from: string, to: string, subject: string, content: string): Promise<void> {
    const message: Message = {
      from,
      to,
      subject,
      content,
      timestamp: Date.now(),
    };

    const inbox = this.inboxes.get(to);
    if (inbox) {
      inbox.messages.push(message);
    }

    this.messageLog.push(message);

    console.log(`Message from ${from} to ${to}: ${subject}`);
  }

  async getMessages(agentName: string): Promise<Message[]> {
    const inbox = this.inboxes.get(agentName);
    return inbox?.messages || [];
  }

  async clearMessages(agentName: string): Promise<void> {
    const inbox = this.inboxes.get(agentName);
    if (inbox) {
      inbox.messages = [];
    }
  }
}

// Agents communicate through the bus
class CommunicatingAgent {
  constructor(
    private name: string,
    private communicationBus: AgentCommunicationBus,
  ) {}

  async work(): Promise<void> {
    // Get messages from other agents
    const messages = await this.communicationBus.getMessages(this.name);

    for (const msg of messages) {
      console.log(`${this.name} received: ${msg.subject}`);
      // Process message
    }

    // Send result to another agent
    await this.communicationBus.sendMessage(
      this.name,
      'next-agent',
      'Work completed',
      'Here are my results...',
    );

    // Clear processed messages
    await this.communicationBus.clearMessages(this.name);
  }
}

Communication buses enable asynchronous agent interaction without tight coupling.

Shared State Management

Multiple agents need access to shared state: current progress, intermediate results, constraints.

interface SharedState {
  sessionId: string;
  mainTask: string;
  status: 'pending' | 'in_progress' | 'completed' | 'failed';
  progress: {
    completed: number;
    total: number;
    currentAgent?: string;
  };
  results: Map<string, unknown>;
  constraints: {
    maxTokens?: number;
    maxTime?: number;
    budget?: number;
  };
  errors: Array<{ agent: string; error: string; timestamp: number }>;
}

class StateManager {
  private states: Map<string, SharedState> = new Map();

  createState(sessionId: string, mainTask: string): SharedState {
    const state: SharedState = {
      sessionId,
      mainTask,
      status: 'pending',
      progress: {
        completed: 0,
        total: 0,
        currentAgent: undefined,
      },
      results: new Map(),
      constraints: {
        maxTokens: 100000,
        maxTime: 300000, // 5 minutes
        budget: 10, // $10
      },
      errors: [],
    };

    this.states.set(sessionId, state);
    return state;
  }

  getState(sessionId: string): SharedState | undefined {
    return this.states.get(sessionId);
  }

  updateProgress(sessionId: string, completed: number, currentAgent: string): void {
    const state = this.states.get(sessionId);
    if (state) {
      state.progress.completed = completed;
      state.progress.currentAgent = currentAgent;
    }
  }

  storeResult(sessionId: string, key: string, value: unknown): void {
    const state = this.states.get(sessionId);
    if (state) {
      state.results.set(key, value);
    }
  }

  getResult(sessionId: string, key: string): unknown {
    const state = this.states.get(sessionId);
    return state?.results.get(key);
  }

  recordError(sessionId: string, agent: string, error: string): void {
    const state = this.states.get(sessionId);
    if (state) {
      state.errors.push({
        agent,
        error,
        timestamp: Date.now(),
      });
    }
  }

  canContinue(sessionId: string): boolean {
    const state = this.states.get(sessionId);
    if (!state) return false;

    // Check constraints
    if (state.progress.completed >= state.progress.total) {
      return false; // Completed
    }

    return true;
  }
}

Shared state prevents agents from duplicating work and enables early stopping when constraints are exceeded.

Agent Specialization

Each agent should focus on one type of task and be exceptionally good at it.

class SpecializedAgent {
  constructor(
    private name: string,
    private tools: string[],
    private systemPrompt: string,
  ) {}

  async execute(task: string): Promise<string> {
    const context = `You are ${this.name}. Your specialization: ${this.systemPrompt}
Your available tools: ${this.tools.join(', ')}

Task: ${task}`;

    return this.llmCall(context);
  }

  private async llmCall(prompt: string): Promise<string> {
    return '';
  }
}

// Specialized agents
const agents = {
  researcher: new SpecializedAgent(
    'ResearchAgent',
    ['web_search', 'academic_database', 'news_api'],
    'Research complex topics, find credible sources, evaluate information quality',
  ),

  writer: new SpecializedAgent(
    'WriterAgent',
    ['spell_check', 'grammar_check', 'plagiarism_check'],
    'Write clear, engaging content. Ensure coherence, proper structure, compelling narrative',
  ),

  critic: new SpecializedAgent(
    'CriticAgent',
    ['fact_check', 'consistency_check', 'quality_score'],
    'Critique work for factual accuracy, logical consistency, and quality. Provide constructive feedback',
  ),

  executor: new SpecializedAgent(
    'ExecutorAgent',
    ['code_runner', 'api_call', 'file_write'],
    'Execute technical tasks. Write and test code, call APIs, perform system operations',
  ),
};

Specialization improves quality: a researcher-agent is better at research than a generalist agent.

Parallel Agent Execution

Some agents can work simultaneously. Coordinate parallel work with task dependencies.

interface TaskDependency {
  taskId: string;
  dependsOn: string[]; // Task IDs this depends on
  worker: string;
  description: string;
}

class ParallelExecutor {
  async executeTasks(dependencies: TaskDependency[]): Promise<Map<string, unknown>> {
    const completed = new Map<string, unknown>();
    const inProgress = new Set<string>();

    while (completed.size < dependencies.length) {
      // Find tasks that can start (dependencies met)
      const readyTasks = dependencies.filter(
        (task) =>
          !completed.has(task.taskId) &&
          !inProgress.has(task.taskId) &&
          task.dependsOn.every((dep) => completed.has(dep)),
      );

      if (readyTasks.length === 0) {
        break; // Deadlock or all done
      }

      // Execute ready tasks in parallel
      const promises = readyTasks.map((task) => {
        inProgress.add(task.taskId);

        return this.executeTask(task)
          .then((result) => {
            completed.set(task.taskId, result);
            inProgress.delete(task.taskId);
          })
          .catch((error) => {
            console.error(`Task ${task.taskId} failed: ${error}`);
            inProgress.delete(task.taskId);
          });
      });

      await Promise.all(promises);
    }

    return completed;
  }

  private async executeTask(task: TaskDependency): Promise<unknown> {
    // Get dependency results
    const deps = new Map<string, unknown>();
    // ... load dependency results

    // Execute task with dependency context
    return `Result of ${task.taskId}`;
  }
}

// Example task DAG (directed acyclic graph)
const taskDag: TaskDependency[] = [
  {
    taskId: 'research',
    dependsOn: [],
    worker: 'researcher',
    description: 'Research the topic',
  },
  {
    taskId: 'outline',
    dependsOn: ['research'],
    worker: 'writer',
    description: 'Create outline based on research',
  },
  {
    taskId: 'draft',
    dependsOn: ['outline'],
    worker: 'writer',
    description: 'Write draft',
  },
  {
    taskId: 'fact_check',
    dependsOn: ['draft', 'research'],
    worker: 'critic',
    description: 'Fact-check draft against research',
  },
  {
    taskId: 'revise',
    dependsOn: ['fact_check'],
    worker: 'writer',
    description: 'Revise based on feedback',
  },
];

Parallel execution reduces total runtime. Map task dependencies to find parallelizable work.

Result Aggregation

Combining results from multiple agents is non-trivial.

interface AggregationStrategy {
  combine: (results: Map<string, unknown>) => Promise<unknown>;
  priority?: Map<string, number>; // Higher = more important
}

class ResultAggregator {
  async aggregateConcat(results: Map<string, unknown>): Promise<string> {
    // Simple: concatenate all results
    return Array.from(results.values()).join('\n\n');
  }

  async aggregateWeighted(
    results: Map<string, unknown>,
    weights: Map<string, number>,
  ): Promise<string> {
    // Weighted combination: prioritize certain results
    const weighted = Array.from(results.entries())
      .sort(([keyA], [keyB]) => (weights.get(keyB) || 1) - (weights.get(keyA) || 1))
      .map(([_, value]) => value);

    return weighted.join('\n\n');
  }

  async aggregateConsensus(results: Map<string, unknown>): Promise<unknown> {
    // For scoring/voting results, find consensus
    const votes = Array.from(results.values()) as number[];

    if (votes.length === 0) return 0;

    const avg = votes.reduce((a, b) => a + b, 0) / votes.length;
    return Math.round(avg * 10) / 10; // Round to 1 decimal
  }

  async aggregateSynthesis(
    results: Map<string, unknown>,
    mainTask: string,
  ): Promise<string> {
    // Use LLM to synthesize disparate results
    const resultsSummary = Array.from(results.entries())
      .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
      .join('\n');

    const prompt = `Original task: ${mainTask}

Results from specialist agents:
${resultsSummary}

Synthesize these results into a coherent final answer. Resolve conflicts, combine insights.`;

    return this.llmCall(prompt);
  }

  private async llmCall(prompt: string): Promise<string> {
    return '';
  }
}

Choose aggregation strategy based on task type: concat for summaries, consensus for scores, synthesis for complex results.

LangGraph for Multi-Agent Graphs

LangGraph provides a graph-based framework for multi-agent workflows.

import { StateGraph, START, END } from '@langchain/langgraph';

interface WorkflowState {
  task: string;
  researchResults?: string;
  outline?: string;
  draft?: string;
  critique?: string;
  finalOutput?: string;
}

const workflow = new StateGraph<WorkflowState>({
  channels: {
    task: { value: null, reducer: (x, y) => y || x },
    researchResults: { value: null, reducer: (x, y) => y || x },
    outline: { value: null, reducer: (x, y) => y || x },
    draft: { value: null, reducer: (x, y) => y || x },
    critique: { value: null, reducer: (x, y) => y || x },
    finalOutput: { value: null, reducer: (x, y) => y || x },
  },
});

// Define nodes (agents)
workflow.addNode('research', async (state: WorkflowState) => {
  const research = await researcherAgent.execute(state.task);
  return { ...state, researchResults: research };
});

workflow.addNode('outline', async (state: WorkflowState) => {
  const outline = await writerAgent.execute(
    `Create an outline based on this research: ${state.researchResults}`,
  );
  return { ...state, outline };
});

workflow.addNode('draft', async (state: WorkflowState) => {
  const draft = await writerAgent.execute(
    `Write a draft based on this outline: ${state.outline}`,
  );
  return { ...state, draft };
});

workflow.addNode('critique', async (state: WorkflowState) => {
  const critique = await criticAgent.execute(
    `Review this draft: ${state.draft}. Is it accurate and well-written?`,
  );
  return { ...state, critique };
});

workflow.addNode('revise', async (state: WorkflowState) => {
  const final = await writerAgent.execute(
    `Revise this draft based on feedback: ${state.critique}\n\nOriginal: ${state.draft}`,
  );
  return { ...state, finalOutput: final };
});

// Define edges (control flow)
workflow.addEdge(START, 'research');
workflow.addEdge('research', 'outline');
workflow.addEdge('outline', 'draft');
workflow.addEdge('draft', 'critique');

// Conditional edge: only revise if critique suggests issues
workflow.addConditionalEdges(
  'critique',
  async (state: WorkflowState) => {
    const hasIssues = (state.critique || '').toLowerCase().includes('issue');
    return hasIssues ? 'revise' : 'finalize';
  },
  {
    revise: 'revise',
    finalize: END,
  },
);

workflow.addEdge('revise', END);

const app = workflow.compile();

// Run workflow
async function runWorkflow(task: string) {
  const result = await app.invoke({ task });
  return result.finalOutput;
}

LangGraph handles state management, task ordering, and conditional logic automatically.

Checklist

  • Supervisor: decompose tasks, route to specialists, aggregate results
  • Communication: enable direct agent-to-agent messaging
  • Shared state: central repository for progress and constraints
  • Specialization: each agent excels at one type of task
  • Parallelization: execute independent tasks simultaneously
  • Aggregation: choose strategy based on result types
  • LangGraph: use for graph-based coordination

Conclusion

Multi-agent systems beat single agents on complex tasks. The supervisor-worker pattern provides clear task distribution, agents specialize in narrow domains, and shared state prevents redundant work. LangGraph makes building and managing multi-agent workflows straightforward. Start with a supervisor and two specialized agents, add more as complexity demands.