DEV Community

KristinZ
KristinZ

Posted on

Building a Multi-Agent System in TypeScript

Building a Multi-Agent System in TypeScript

Single agents hit real limits in production. Long tasks exceed context windows. Complex goals need different tools at different stages. Sequential reasoning is slow when subtasks are independent.

Multi-agent systems solve these problems by decomposing work across specialized agents that can run in parallel. This article walks through two patterns — Orchestrator/Subagent and Pipeline — implemented entirely in TypeScript.

Why Multiple Agents

The case for multi-agent systems isn't abstract. Consider a research task: "Analyze our competitors' pricing pages and summarize the key differences."

A single agent working sequentially has to: fetch page 1, process it, fetch page 2, process it, fetch page 3, process it, then write the analysis. Each step burns context window. Total time is the sum of all steps.

An orchestrator-based approach: spawn three agents in parallel, one per competitor. Each fetches and processes its page independently. Total time is roughly the time of the slowest agent, not the sum.

Single agent (serial):
  Task → [fetch A → fetch B → fetch C → analyze] → Result
  Time: T(A) + T(B) + T(C) + T(analyze)

Multi-agent (parallel):
                    ┌→ [Agent A: fetch + process] ─┐
  Task → [Orchestrator] → [Agent B: fetch + process] → [Synthesize] → Result
                    └→ [Agent C: fetch + process] ─┘
  Time: max(T(A), T(B), T(C)) + T(synthesize)
Enter fullscreen mode Exit fullscreen mode

The Orchestrator/Subagent Pattern

The orchestrator pattern has three phases: decompose the goal into subtasks, execute subtasks (in parallel where possible), then synthesize results.

Subtask Types

First, define the data structures that flow between orchestrator and subagents:

// src/lib/agent/multi/types.ts

export interface SubTask {
  id: string;
  title: string;
  description: string;
  toolSet: 'browser' | 'file' | 'code' | 'rag' | 'general';
  dependsOn?: string[];   // IDs of tasks that must complete first
  priority: 'high' | 'medium' | 'low';
  timeoutMs?: number;
}

export interface SubTaskResult {
  taskId: string;
  status: 'success' | 'failed' | 'timeout' | 'skipped';
  output: string;
  durationMs: number;
}

export interface OrchestratorPlan {
  goal: string;
  tasks: SubTask[];
  estimatedParallelGroups: string[][];  // Which tasks can run concurrently
}
Enter fullscreen mode Exit fullscreen mode

dependsOn is what makes the dependency graph work — a task that needs another task's output lists its ID here. estimatedParallelGroups is the LLM's suggestion for which tasks can run at the same time.

Phase 1: Decomposition

The orchestrator calls the LLM once to convert a natural-language goal into a structured plan:

async decompose(goal: string): Promise<OrchestratorPlan> {
  const { text } = await callLLM([{
    role: 'user',
    content: `Decompose this goal into subtasks. Output JSON only.

Goal: ${goal}

Format:
{
  "goal": "...",
  "tasks": [
    {
      "id": "task_1",
      "title": "...",
      "description": "detailed enough for another AI to complete independently",
      "toolSet": "browser|file|code|rag|general",
      "dependsOn": [],
      "priority": "high|medium|low",
      "timeoutMs": 60000
    }
  ],
  "estimatedParallelGroups": [["task_1", "task_2"], ["task_3"]]
}`,
  }], { system: ORCHESTRATOR_SYSTEM, temperature: 0 });

  return JSON.parse(text.replace(/```
{% endraw %}
json\n?|\n?
{% raw %}
```/g, '').trim());
}
Enter fullscreen mode Exit fullscreen mode

Using temperature: 0 for planning — you want deterministic, structured output here, not creativity.

Phase 2: Parallel Execution with Concurrency Control

The execution phase respects both the dependency graph and a concurrency limit:

async executeParallel(
  plan: OrchestratorPlan,
  maxConcurrency = 3,
): Promise<Map<string, SubTaskResult>> {
  const results = new Map<string, SubTaskResult>();
  const completed = new Set<string>();
  const failed = new Set<string>();

  for (const group of plan.estimatedParallelGroups) {
    // Only run tasks whose dependencies have completed successfully
    const executable = group.filter(taskId => {
      const task = plan.tasks.find(t => t.id === taskId);
      if (!task) return false;
      return (task.dependsOn ?? []).every(
        dep => completed.has(dep) && !failed.has(dep)
      );
    });

    if (executable.length === 0) continue;

    const batchResults = await this.executeBatch(
      executable.map(id => plan.tasks.find(t => t.id === id)!),
      results,
      maxConcurrency,
    );

    for (const [id, result] of batchResults) {
      results.set(id, result);
      result.status === 'success' ? completed.add(id) : failed.add(id);
    }
  }

  return results;
}

private async executeBatch(
  tasks: SubTask[],
  previousResults: Map<string, SubTaskResult>,
  maxConcurrency: number,
): Promise<Map<string, SubTaskResult>> {
  const results = new Map<string, SubTaskResult>();
  const active: Promise<void>[] = [];

  for (const task of tasks) {
    const promise: Promise<void> = this.executeTask(task, previousResults)
      .then(result => { results.set(task.id, result); })
      .catch(err => {
        results.set(task.id, {
          taskId: task.id,
          status: 'failed',
          output: err instanceof Error ? err.message : String(err),
          durationMs: 0,
        });
      })
      .finally(() => { active.splice(active.indexOf(promise), 1); });

    active.push(promise);

    if (active.length >= maxConcurrency) {
      await Promise.race(active);  // Wait for one slot to free up
    }
  }

  await Promise.allSettled(active);
  return results;
}
Enter fullscreen mode Exit fullscreen mode

The concurrency control pattern is worth understanding: Promise.race(active) waits for the first active task to finish, then removes it from the pool and allows the next task to start. The number of concurrently running tasks never exceeds maxConcurrency.

Executing Individual Subtasks

Each subtask runs in its own ReAct agent instance with tools appropriate for its toolSet. Upstream results are injected as context:

private async executeTask(
  task: SubTask,
  previousResults: Map<string, SubTaskResult>,
): Promise<SubTaskResult> {
  const start = Date.now();

  // Inject upstream results as context
  const context = (task.dependsOn ?? [])
    .map(depId => {
      const dep = previousResults.get(depId);
      return dep ? `[${depId}] ${dep.output.slice(0, 1000)}` : '';
    })
    .filter(Boolean)
    .join('\n\n');

  const prompt = context
    ? `Background (from upstream tasks):\n${context}\n\nCurrent task: ${task.description}`
    : task.description;

  const agent = new ReActAgent({
    tools: this.getToolsForTaskType(task.toolSet),
    maxSteps: 8,
  });

  try {
    const timeout = task.timeoutMs ?? 120_000;
    const result = await Promise.race([
      agent.run(prompt),
      new Promise<never>((_, reject) =>
        setTimeout(() => reject(new Error('TIMEOUT')), timeout)
      ),
    ]);

    return {
      taskId: task.id,
      status: result.stopped === 'error' ? 'failed' : 'success',
      output: result.answer,
      durationMs: Date.now() - start,
    };
  } catch (error) {
    const isTimeout = error instanceof Error && error.message === 'TIMEOUT';
    return {
      taskId: task.id,
      status: isTimeout ? 'timeout' : 'failed',
      output: isTimeout ? `Timed out after ${task.timeoutMs ?? 120_000}ms` : String(error),
      durationMs: Date.now() - start,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Note: the timeout is implemented with Promise.race. This stops waiting for the agent, but it doesn't kill the underlying work — the agent's API calls may still be running. For production, you'd want proper cancellation via AbortController.

Phase 3: Synthesis

After all subtasks complete, one final LLM call produces the answer:

async synthesize(
  goal: string,
  plan: OrchestratorPlan,
  results: Map<string, SubTaskResult>,
): Promise<string> {
  const summaries = plan.tasks
    .map(task => {
      const result = results.get(task.id);
      return `[${task.title}]\nStatus: ${result?.status ?? 'skipped'}\n${result?.output ?? 'No output'}`;
    })
    .join('\n\n---\n\n');

  const { text } = await callLLM([{
    role: 'user',
    content: `Based on all subtask results, answer the original goal.

Original goal: ${goal}

Subtask results:
${summaries}

Provide a complete, well-structured final answer. If some subtasks failed, explain the impact.`,
  }], { temperature: 0.5, maxTokens: 3000 });

  return text;
}
Enter fullscreen mode Exit fullscreen mode

The Pipeline Pattern

Not all multi-agent work fits the orchestrator model. Some tasks are inherently sequential — each stage transforms the previous stage's output. For these, a pipeline is cleaner.

// src/lib/agent/multi/pipeline.ts

export interface PipelineStage {
  name: string;
  description: "string;"
  tools: Tool[];
  maxSteps?: number;
  buildPrompt: (previousOutput: string, originalInput: string) => string;
  validate?: (output: string) => { valid: boolean; reason?: string };
}

export class AgentPipeline {
  constructor(
    private stages: PipelineStage[],
    private maxRetries = 2,
  ) {}

  async run(initialInput: string): Promise<{ finalOutput: string; success: boolean }> {
    let currentOutput = initialInput;

    for (const stage of this.stages) {
      let retries = 0;
      let success = false;

      while (retries <= this.maxRetries && !success) {
        const agent = new ReActAgent({
          tools: stage.tools,
          maxSteps: stage.maxSteps ?? 6,
          systemPrompt: stage.description,
        });

        const result = await agent.run(
          stage.buildPrompt(currentOutput, initialInput)
        );

        if (stage.validate) {
          const validation = stage.validate(result.answer);
          if (!validation.valid) {
            retries++;
            continue;
          }
        }

        currentOutput = result.answer;
        success = true;
      }

      if (!success) {
        throw new Error(`Pipeline stage "${stage.name}" failed after ${this.maxRetries} retries`);
      }
    }

    return { finalOutput: currentOutput, success: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

The validate function per stage is the key design decision here. Rather than hoping each agent produces usable output, you define what "valid" means and retry automatically when it isn't. This makes pipelines significantly more reliable than single-stage agents.

Example: Research Report Pipeline

Three stages: gather sources, extract insights, write the report.

const researchPipeline = new AgentPipeline([
  {
    name: 'Information Gathering',
    description: "'You are a researcher. Collect information from the web.',"
    tools: [fetchWebpageTool],
    maxSteps: 6,
    buildPrompt: (_, originalInput) =>
      `Gather key information on this topic, including recent developments and data:\n\n${originalInput}`,
    validate: output => ({
      valid: output.length > 200,
      reason: 'Insufficient information gathered',
    }),
  },
  {
    name: 'Analysis',
    description: "'You are an analyst. Extract insights from raw research.',"
    tools: [],
    buildPrompt: (previousOutput, originalInput) => `
Original topic: ${originalInput}

Raw research:
${previousOutput}

Extract: key findings, main trends, supporting data points, open questions.`,
    validate: output => ({
      valid: output.includes('finding') || output.includes('trend'),
      reason: 'Analysis must include findings or trends',
    }),
  },
  {
    name: 'Report Writing',
    description: "'You are a technical writer. Produce a structured report.',"
    tools: [writeFileTool],
    buildPrompt: (previousOutput, originalInput) => `
Topic: ${originalInput}

Analysis:
${previousOutput}

Write a structured report with: Executive Summary, Key Findings, Analysis, Conclusion.`,
  },
]);

const result = await researchPipeline.run(
  'Current state of TypeScript adoption in backend development'
);
Enter fullscreen mode Exit fullscreen mode

Observability

Multi-agent systems fail in opaque ways. An agent in the middle of a pipeline produces bad output; the next agent silently works with it; the final result is wrong with no obvious signal as to why.

Add progress callbacks to the orchestrator and structured logging to each stage:

const orchestrator = new Orchestrator({
  onProgress: (event) => {
    console.error(`[${event.type}] ${event.message}`);
    // In production: emit SSE event to frontend, write to tracing system
  },
});
Enter fullscreen mode Exit fullscreen mode

The onProgress callback fires at every key milestone: planning complete, group start, task start, task done (with status), synthesis start, done. With these events you can show real-time progress in a UI, and you have a complete audit trail when something fails.

What to Watch Out For

Token costs compound. Each subagent runs its own ReAct loop. A 6-task orchestration job with 5 steps per agent is 30 LLM calls before synthesis. Budget accordingly, and set maxSteps aggressively.

The plan is a suggestion, not a guarantee. The LLM might put tasks in estimatedParallelGroups that actually have implicit dependencies. Always validate that the dependency graph is consistent before execution.

Failure modes are different. In a single agent, failure is obvious — no output. In a multi-agent system, one task can fail silently while others succeed, and the synthesis step might paper over the gap with plausible-sounding output. Explicit status tracking and output validation per stage are not optional.

Promise.race for timeouts doesn't cancel. The agent's underlying API calls keep running after the timeout fires. For production use, pass AbortController signals through to each LLM call.


This article is adapted from Chapter 16 of AI Engineering with TypeScript — A Comprehensive Guide to Building AI Agents at Leanpub or Amazon

Top comments (2)

Collapse
 
kristinz profile image
KristinZ

There were Loop engineering and quickly dated and then Graph engineering, but the core concepts you should know

Collapse
 
merbayerp profile image
Mustafa ERBAY

Good breakdown of the orchestration and pipeline patterns. One distinction I’d add is that multi-agent architecture should be a consequence of the task graph, not the default starting point.

If subtasks are truly independent, parallel agents can reduce latency. But when they share context, require frequent coordination, or depend heavily on each other’s intermediate reasoning, a single agent with well-designed tools may be cheaper, easier to observe, and more reliable.

So for me, the key design question is not “How many agents do we need?” but “Where are the real boundaries of context, responsibility, and concurrency?”