Agents

Multi-Agent Systems: Coordination and Communication

Build multi-agent systems with message buses, role-based design, debate patterns, and supervisor coordination in Node.js.

Multi-Agent Systems: Coordination and Communication

Multi-agent systems decompose complex AI tasks into specialized roles, where each agent handles a focused piece of work and communicates results to others through structured protocols. This architecture mirrors how effective engineering teams operate: specialized individuals with clear responsibilities, coordinating through well-defined interfaces. In this article, we build a full multi-agent system in Node.js with a message bus, role-based agents, debate patterns, and supervisor coordination.

Prerequisites

  • Node.js 18+ installed
  • Working knowledge of async/await and event-driven programming in Node.js
  • An OpenAI or Anthropic API key
  • Familiarity with basic LLM API calls (chat completions)
  • Understanding of pub/sub and event emitter patterns

Why Multi-Agent Systems

Single-agent architectures hit a wall fast. When you ask one LLM call to research a topic, write a draft, review its own work, and then refine it, you get mediocre results across the board. The model tries to do everything at once and does nothing particularly well.

Multi-agent systems solve three fundamental problems:

Specialization. Each agent gets a focused system prompt and a narrow task. A researcher agent with instructions to find and verify facts produces far better research than a general-purpose agent that also needs to write prose. I have seen quality improvements of 40-60% just by splitting research and writing into separate agents.

Parallelism. Independent tasks run concurrently. While one agent researches section three, another writes section one, and a third reviews the introduction. A four-agent pipeline can cut wall-clock time by 60-70% on content-heavy workflows.

Separation of concerns. When the reviewer agent finds problems, you know exactly which agent produced the flawed output and can re-run just that step. Debugging a monolithic agent that went off the rails at step seven of a twelve-step process is miserable. Debugging a single-purpose agent that returned bad research is straightforward.

Agent Communication Patterns

There are three dominant patterns for how agents exchange information. Each has clear trade-offs.

Direct Messaging

Agents send messages directly to each other. Agent A calls Agent B, waits for a response, then calls Agent C.

var EventEmitter = require("events");

function DirectMessenger() {
  this.agents = {};
}

DirectMessenger.prototype.register = function(name, handler) {
  this.agents[name] = handler;
};

DirectMessenger.prototype.send = function(from, to, message) {
  if (!this.agents[to]) {
    throw new Error("Agent not found: " + to);
  }
  console.log("[" + from + " -> " + to + "] " + message.type);
  return this.agents[to](message, from);
};

Direct messaging is simple and easy to trace, but it creates tight coupling. If you rename or replace an agent, every caller needs updating.

Shared Blackboard

All agents read from and write to a shared state object. Each agent checks the blackboard, does its work, and posts results back.

function Blackboard() {
  this.state = {};
  this.subscribers = [];
}

Blackboard.prototype.write = function(key, value, author) {
  this.state[key] = {
    value: value,
    author: author,
    timestamp: Date.now()
  };
  this.subscribers.forEach(function(sub) {
    sub({ key: key, value: value, author: author });
  });
};

Blackboard.prototype.read = function(key) {
  var entry = this.state[key];
  return entry ? entry.value : null;
};

Blackboard.prototype.subscribe = function(callback) {
  this.subscribers.push(callback);
};

The blackboard pattern works well when agents need to react to each other's outputs without knowing who produced them. The downside is that state management gets complex fast and you need conflict resolution.

Publish/Subscribe Message Bus

Agents publish messages to topics and subscribe to topics they care about. This is my preferred pattern for most multi-agent systems because it decouples producers from consumers completely.

function MessageBus() {
  this.topics = {};
  this.history = [];
}

MessageBus.prototype.subscribe = function(topic, agentName, handler) {
  if (!this.topics[topic]) {
    this.topics[topic] = [];
  }
  this.topics[topic].push({ agent: agentName, handler: handler });
};

MessageBus.prototype.publish = function(topic, message) {
  var envelope = {
    id: "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6),
    topic: topic,
    payload: message,
    timestamp: Date.now()
  };
  this.history.push(envelope);

  var subscribers = this.topics[topic] || [];
  var results = [];
  for (var i = 0; i < subscribers.length; i++) {
    results.push(subscribers[i].handler(envelope));
  }
  return Promise.all(results);
};

MessageBus.prototype.getHistory = function(topic) {
  if (!topic) return this.history;
  return this.history.filter(function(msg) {
    return msg.topic === topic;
  });
};

Implementing an Agent Registry

Before agents can communicate, they need to find each other. An agent registry tracks available agents, their capabilities, and their status.

function AgentRegistry() {
  this.agents = {};
}

AgentRegistry.prototype.register = function(config) {
  var name = config.name;
  if (this.agents[name]) {
    throw new Error("Agent already registered: " + name);
  }
  this.agents[name] = {
    name: name,
    role: config.role,
    capabilities: config.capabilities || [],
    status: "idle",
    handler: config.handler,
    systemPrompt: config.systemPrompt,
    registeredAt: Date.now()
  };
  console.log("[Registry] Registered agent: " + name + " (role: " + config.role + ")");
  return this.agents[name];
};

AgentRegistry.prototype.find = function(query) {
  var self = this;
  return Object.keys(self.agents)
    .map(function(key) { return self.agents[key]; })
    .filter(function(agent) {
      if (query.role && agent.role !== query.role) return false;
      if (query.capability) {
        return agent.capabilities.indexOf(query.capability) !== -1;
      }
      return true;
    });
};

AgentRegistry.prototype.setStatus = function(name, status) {
  if (!this.agents[name]) {
    throw new Error("Unknown agent: " + name);
  }
  this.agents[name].status = status;
};

AgentRegistry.prototype.getAvailable = function(role) {
  var self = this;
  return Object.keys(self.agents)
    .map(function(key) { return self.agents[key]; })
    .filter(function(agent) {
      return agent.status === "idle" && (!role || agent.role === role);
    });
};

The registry gives you a single place to inspect the state of your agent system, which becomes critical for debugging.

Role-Based Agent Design

Each agent in a multi-agent system should have a single, well-defined role with a focused system prompt. Here are the four roles I use most often:

Researcher. Gathers information, verifies facts, produces structured data. System prompt emphasizes accuracy, source citation, and output formatting.

Writer. Takes structured inputs and produces prose. System prompt emphasizes clarity, tone, and audience awareness.

Reviewer. Reads completed work and produces specific, actionable feedback. System prompt emphasizes critical analysis and constructive suggestions.

Critic. Actively looks for flaws, inconsistencies, and weak arguments. The critic is adversarial by design, which is different from a reviewer.

var ROLE_PROMPTS = {
  researcher: "You are a senior technical researcher. Your job is to gather accurate, "
    + "specific information on the given topic. Return structured JSON with facts, "
    + "statistics, code patterns, and source references. Never fabricate data. "
    + "If you are uncertain about something, say so explicitly.",

  writer: "You are a senior technical writer. You receive research notes and an outline, "
    + "then produce clear, engaging technical prose. Write in an authoritative, "
    + "practitioner voice. Include working code examples. Target the specified word count.",

  reviewer: "You are a senior technical editor. Review the provided content for: "
    + "technical accuracy, clarity, completeness, code correctness, and logical flow. "
    + "Return a JSON object with scores (1-10) and specific, actionable feedback items.",

  critic: "You are a devil's advocate. Your job is to find flaws, weak arguments, "
    + "missing edge cases, and potential failures in the provided content. "
    + "Be thorough and adversarial. Every criticism must include a specific suggestion."
};

Conversation Protocols Between Agents

Agents need structured message formats to communicate reliably. Free-form text between agents leads to parsing failures and misinterpretation. I enforce a strict envelope format:

function createMessage(from, to, type, content, metadata) {
  return {
    id: "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 9),
    from: from,
    to: to,
    type: type,
    content: content,
    metadata: metadata || {},
    timestamp: new Date().toISOString()
  };
}

// Message types used in the protocol
var MESSAGE_TYPES = {
  TASK_ASSIGNMENT: "task_assignment",
  TASK_RESULT: "task_result",
  REVIEW_REQUEST: "review_request",
  REVIEW_RESULT: "review_result",
  REVISION_REQUEST: "revision_request",
  ESCALATION: "escalation",
  STATUS_UPDATE: "status_update",
  ABORT: "abort"
};

A typical conversation protocol between a writer and reviewer looks like this:

  1. Supervisor sends TASK_ASSIGNMENT to Writer
  2. Writer sends TASK_RESULT back with draft content
  3. Supervisor sends REVIEW_REQUEST to Reviewer with the draft
  4. Reviewer sends REVIEW_RESULT with scores and feedback
  5. If score is below threshold, Supervisor sends REVISION_REQUEST to Writer with feedback
  6. Steps 2-5 repeat up to a maximum iteration count

Shared State Management with Conflict Resolution

When multiple agents write to shared state concurrently, you need conflict resolution. I use a simple last-write-wins strategy with version tracking and an audit log:

function SharedState() {
  this.data = {};
  this.versions = {};
  this.auditLog = [];
}

SharedState.prototype.get = function(key) {
  return {
    value: this.data[key],
    version: this.versions[key] || 0
  };
};

SharedState.prototype.set = function(key, value, agentName, expectedVersion) {
  var currentVersion = this.versions[key] || 0;

  if (expectedVersion !== undefined && expectedVersion !== currentVersion) {
    var conflict = {
      key: key,
      agent: agentName,
      expectedVersion: expectedVersion,
      actualVersion: currentVersion,
      resolution: "rejected",
      timestamp: Date.now()
    };
    this.auditLog.push(conflict);
    throw new Error(
      "Version conflict on key '" + key + "': expected v" + expectedVersion
      + " but found v" + currentVersion
    );
  }

  this.data[key] = value;
  this.versions[key] = currentVersion + 1;

  this.auditLog.push({
    key: key,
    agent: agentName,
    version: currentVersion + 1,
    action: "write",
    timestamp: Date.now()
  });

  return this.versions[key];
};

SharedState.prototype.getAuditLog = function(key) {
  if (!key) return this.auditLog;
  return this.auditLog.filter(function(entry) {
    return entry.key === key;
  });
};

The version-check approach catches conflicts early instead of silently overwriting data. The audit log is invaluable for debugging; when output quality drops, you can trace exactly which agent wrote what and when.

Implementing a Debate Pattern

The debate pattern is one of the most powerful multi-agent techniques. Two agents argue opposing positions, and a judge agent decides the winner. This is incredibly effective for decisions like architecture choices, content angles, or code review disputes.

var OpenAI = require("openai");
var client = new OpenAI();

function DebateEngine(options) {
  this.maxRounds = options.maxRounds || 3;
  this.model = options.model || "gpt-4o";
}

DebateEngine.prototype.runDebate = function(topic, positionA, positionB, callback) {
  var self = this;
  var transcript = [];

  var agentA = {
    name: "Advocate_A",
    systemPrompt: "You are arguing FOR this position: " + positionA
      + ". Be persuasive, cite evidence, and directly counter your opponent's arguments."
  };

  var agentB = {
    name: "Advocate_B",
    systemPrompt: "You are arguing FOR this position: " + positionB
      + ". Be persuasive, cite evidence, and directly counter your opponent's arguments."
  };

  var judge = {
    name: "Judge",
    systemPrompt: "You are an impartial judge evaluating a debate. After reading "
      + "all arguments, declare a winner based on: strength of evidence, logical "
      + "coherence, and practical applicability. Provide a detailed rationale."
  };

  var round = 0;

  function runRound() {
    if (round >= self.maxRounds) {
      return renderVerdict();
    }

    var contextA = transcript.map(function(t) {
      return t.speaker + ": " + t.argument;
    }).join("\n\n");

    var promptA = round === 0
      ? "Present your opening argument on: " + topic
      : "Respond to your opponent's latest argument:\n\n" + contextA;

    client.chat.completions.create({
      model: self.model,
      messages: [
        { role: "system", content: agentA.systemPrompt },
        { role: "user", content: promptA }
      ],
      max_tokens: 500
    }).then(function(responseA) {
      var argA = responseA.choices[0].message.content;
      transcript.push({ speaker: agentA.name, argument: argA, round: round });

      var contextB = transcript.map(function(t) {
        return t.speaker + ": " + t.argument;
      }).join("\n\n");

      return client.chat.completions.create({
        model: self.model,
        messages: [
          { role: "system", content: agentB.systemPrompt },
          { role: "user", content: "Respond to the debate so far:\n\n" + contextB }
        ],
        max_tokens: 500
      });
    }).then(function(responseB) {
      var argB = responseB.choices[0].message.content;
      transcript.push({ speaker: agentB.name, argument: argB, round: round });
      round++;
      runRound();
    }).catch(function(err) {
      callback(err);
    });
  }

  function renderVerdict() {
    var fullTranscript = transcript.map(function(t) {
      return "[Round " + t.round + "] " + t.speaker + ":\n" + t.argument;
    }).join("\n\n---\n\n");

    client.chat.completions.create({
      model: self.model,
      messages: [
        { role: "system", content: judge.systemPrompt },
        { role: "user", content: "Here is the full debate transcript:\n\n"
          + fullTranscript + "\n\nDeliver your verdict." }
      ],
      max_tokens: 800
    }).then(function(verdict) {
      callback(null, {
        transcript: transcript,
        verdict: verdict.choices[0].message.content,
        rounds: round
      });
    }).catch(function(err) {
      callback(err);
    });
  }

  runRound();
};

I use the debate pattern before making irreversible decisions in production pipelines. Should the article take a tutorial approach or a reference approach? Let two agents argue about it with actual evidence, then let a judge decide. The output quality improvement is measurable.

Supervisor Patterns

A supervisor agent monitors worker agents, validates their output, and triggers corrections when quality drops below a threshold. This is the most production-critical pattern.

function Supervisor(options) {
  this.bus = options.bus;
  this.registry = options.registry;
  this.maxRetries = options.maxRetries || 3;
  this.qualityThreshold = options.qualityThreshold || 7;
  this.retryCount = {};
}

Supervisor.prototype.assignTask = function(task, callback) {
  var self = this;
  var available = this.registry.getAvailable(task.requiredRole);

  if (available.length === 0) {
    return callback(new Error("No available agents for role: " + task.requiredRole));
  }

  var agent = available[0];
  var taskKey = task.id || task.type;
  self.retryCount[taskKey] = self.retryCount[taskKey] || 0;

  self.registry.setStatus(agent.name, "busy");
  console.log("[Supervisor] Assigning task '" + taskKey + "' to " + agent.name);

  agent.handler(task).then(function(result) {
    self.registry.setStatus(agent.name, "idle");
    return self.validateResult(result, task);
  }).then(function(validation) {
    if (validation.score >= self.qualityThreshold) {
      console.log("[Supervisor] Task '" + taskKey + "' passed validation (score: "
        + validation.score + ")");
      callback(null, validation);
    } else if (self.retryCount[taskKey] < self.maxRetries) {
      self.retryCount[taskKey]++;
      console.log("[Supervisor] Task '" + taskKey + "' failed validation (score: "
        + validation.score + "), retry " + self.retryCount[taskKey]);
      task.feedback = validation.feedback;
      task.previousAttempt = validation.result;
      self.assignTask(task, callback);
    } else {
      console.log("[Supervisor] Task '" + taskKey + "' exceeded max retries, escalating");
      callback(new Error("Max retries exceeded for task: " + taskKey));
    }
  }).catch(function(err) {
    self.registry.setStatus(agent.name, "idle");
    callback(err);
  });
};

Supervisor.prototype.validateResult = function(result, task) {
  return client.chat.completions.create({
    model: "gpt-4o",
    messages: [
      {
        role: "system",
        content: "Evaluate this output against the task requirements. "
          + "Return JSON: { \"score\": 1-10, \"feedback\": [\"issue1\", ...], "
          + "\"passed\": true/false }"
      },
      {
        role: "user",
        content: "Task: " + JSON.stringify(task) + "\n\nOutput:\n" + result
      }
    ],
    response_format: { type: "json_object" }
  }).then(function(response) {
    var evaluation = JSON.parse(response.choices[0].message.content);
    evaluation.result = result;
    return evaluation;
  });
};

The supervisor pattern catches a surprising number of failures. In my production pipelines, about 15-20% of initial agent outputs need at least one retry. Without the supervisor, those failures would propagate silently.

Agent Handoff Protocols

When one agent finishes and needs to pass work to the next agent, the handoff must include full context. Lost context is the number one cause of quality degradation in multi-agent pipelines.

function HandoffProtocol() {
  this.handoffs = [];
}

HandoffProtocol.prototype.createHandoff = function(fromAgent, toAgent, options) {
  var handoff = {
    id: "handoff_" + Date.now(),
    from: fromAgent,
    to: toAgent,
    artifact: options.artifact,
    context: options.context || {},
    instructions: options.instructions,
    constraints: options.constraints || [],
    previousFeedback: options.previousFeedback || null,
    timestamp: Date.now()
  };

  this.handoffs.push(handoff);
  return handoff;
};

HandoffProtocol.prototype.formatForAgent = function(handoff) {
  var sections = [];
  sections.push("## Handoff from " + handoff.from);
  sections.push("### Instructions\n" + handoff.instructions);

  if (handoff.context.originalTask) {
    sections.push("### Original Task\n" + handoff.context.originalTask);
  }

  if (handoff.previousFeedback) {
    sections.push("### Feedback from Previous Review\n"
      + handoff.previousFeedback.join("\n- "));
  }

  if (handoff.constraints.length > 0) {
    sections.push("### Constraints\n- " + handoff.constraints.join("\n- "));
  }

  sections.push("### Artifact\n" + handoff.artifact);

  return sections.join("\n\n");
};

The formatForAgent method is key. It takes structured handoff data and formats it into a prompt section that gives the receiving agent everything it needs. Every handoff should include the original task, any accumulated feedback, and explicit constraints.

Scaling Multi-Agent Systems

Scaling from two agents to ten introduces real coordination challenges. Here is what I have learned:

Concurrency control. Use a semaphore to limit parallel LLM calls. Most APIs rate-limit you, and firing ten agents simultaneously just generates 429 errors.

function Semaphore(max) {
  this.max = max;
  this.current = 0;
  this.queue = [];
}

Semaphore.prototype.acquire = function() {
  var self = this;
  return new Promise(function(resolve) {
    if (self.current < self.max) {
      self.current++;
      resolve();
    } else {
      self.queue.push(resolve);
    }
  });
};

Semaphore.prototype.release = function() {
  this.current--;
  if (this.queue.length > 0) {
    this.current++;
    var next = this.queue.shift();
    next();
  }
};

// Usage: limit to 3 concurrent agent calls
var sem = new Semaphore(3);

function runAgentWithLimit(agentFn, input) {
  return sem.acquire().then(function() {
    return agentFn(input);
  }).finally(function() {
    sem.release();
  });
}

Pipeline stages. Group agents into stages. All agents in a stage can run in parallel, but stages execute sequentially. This mirrors how CI/CD pipelines work and is easy to reason about.

Dead letter handling. When an agent fails after max retries, do not crash the pipeline. Log the failure, store the partial result, and let the supervisor decide whether to skip that step or abort.

Cost Control in Multi-Agent Workflows

Multi-agent systems can burn through API credits fast if you are not careful. A four-agent pipeline with three review rounds per output can easily generate 20+ LLM calls per task.

function CostTracker() {
  this.usage = [];
  this.budgetCents = 0;
  this.spentCents = 0;
}

CostTracker.prototype.setBudget = function(cents) {
  this.budgetCents = cents;
};

CostTracker.prototype.record = function(agentName, model, inputTokens, outputTokens) {
  var costs = {
    "gpt-4o": { input: 0.25, output: 1.0 },         // per 100k tokens
    "gpt-4o-mini": { input: 0.015, output: 0.06 },
    "claude-3-5-sonnet": { input: 0.3, output: 1.5 }
  };

  var rate = costs[model] || { input: 0.1, output: 0.5 };
  var costCents = ((inputTokens * rate.input) + (outputTokens * rate.output)) / 1000;

  this.spentCents += costCents;
  this.usage.push({
    agent: agentName,
    model: model,
    inputTokens: inputTokens,
    outputTokens: outputTokens,
    costCents: costCents,
    timestamp: Date.now()
  });

  if (this.budgetCents > 0 && this.spentCents > this.budgetCents) {
    throw new Error("Budget exceeded: spent $" + (this.spentCents / 100).toFixed(4)
      + " of $" + (this.budgetCents / 100).toFixed(4) + " budget");
  }

  return costCents;
};

CostTracker.prototype.summary = function() {
  var byAgent = {};
  this.usage.forEach(function(u) {
    if (!byAgent[u.agent]) {
      byAgent[u.agent] = { calls: 0, costCents: 0, totalTokens: 0 };
    }
    byAgent[u.agent].calls++;
    byAgent[u.agent].costCents += u.costCents;
    byAgent[u.agent].totalTokens += u.inputTokens + u.outputTokens;
  });
  return {
    totalCostCents: this.spentCents,
    totalCalls: this.usage.length,
    byAgent: byAgent
  };
};

In practice, I use cheaper models (GPT-4o-mini, Claude Haiku) for reviewer and critic agents where the task is evaluative, and reserve expensive models (GPT-4o, Claude Sonnet) for the writer agent where output quality matters most. This typically cuts costs by 50-60% with minimal quality impact.

Debugging Multi-Agent Interactions with Structured Logging

Debugging a five-agent pipeline without proper logging is like debugging a distributed system without traces. You need structured logs that capture every message, every LLM call, and every decision.

function AgentLogger(options) {
  this.logs = [];
  this.sessionId = "session_" + Date.now();
  this.verbose = options ? options.verbose : false;
}

AgentLogger.prototype.log = function(level, agent, event, data) {
  var entry = {
    sessionId: this.sessionId,
    timestamp: new Date().toISOString(),
    level: level,
    agent: agent,
    event: event,
    data: data || {}
  };

  this.logs.push(entry);

  if (this.verbose) {
    console.log(JSON.stringify(entry));
  }
};

AgentLogger.prototype.traceCall = function(agent, model, messages, response, durationMs) {
  this.log("trace", agent, "llm_call", {
    model: model,
    inputMessages: messages.length,
    inputChars: JSON.stringify(messages).length,
    outputChars: response.length,
    durationMs: durationMs
  });
};

AgentLogger.prototype.traceHandoff = function(from, to, artifactSize) {
  this.log("info", from, "handoff", {
    to: to,
    artifactSizeChars: artifactSize
  });
};

AgentLogger.prototype.getTimeline = function(agent) {
  var filtered = agent
    ? this.logs.filter(function(l) { return l.agent === agent; })
    : this.logs;

  return filtered.map(function(l) {
    return l.timestamp + " [" + l.level + "] " + l.agent + ": " + l.event;
  });
};

AgentLogger.prototype.export = function() {
  return JSON.stringify(this.logs, null, 2);
};

When something goes wrong, dump the full log and look for anomalies: unexpectedly long LLM calls, handoffs with tiny artifact sizes (usually means the agent returned an error instead of content), or review scores that oscillate without converging (the writer is ignoring feedback).

Complete Working Example: Multi-Agent Content Creation System

Here is a full working system where four agents collaborate to produce a polished article. A Planner agent outlines the structure, a Researcher agent gathers supporting information, a Writer agent produces the draft, and a Reviewer agent evaluates quality and triggers revisions.

var OpenAI = require("openai");

var client = new OpenAI();

// ============================================================
// Message Bus
// ============================================================
function MessageBus() {
  this.topics = {};
  this.history = [];
}

MessageBus.prototype.subscribe = function(topic, agentName, handler) {
  if (!this.topics[topic]) {
    this.topics[topic] = [];
  }
  this.topics[topic].push({ agent: agentName, handler: handler });
};

MessageBus.prototype.publish = function(topic, message) {
  var envelope = {
    id: "msg_" + Date.now() + "_" + Math.random().toString(36).substr(2, 6),
    topic: topic,
    payload: message,
    timestamp: Date.now()
  };
  this.history.push(envelope);
  console.log("[Bus] " + topic + " from " + (message.from || "system"));

  var subscribers = this.topics[topic] || [];
  var results = [];
  for (var i = 0; i < subscribers.length; i++) {
    results.push(subscribers[i].handler(envelope));
  }
  return Promise.all(results);
};

// ============================================================
// Cost Tracker
// ============================================================
function CostTracker() {
  this.usage = [];
  this.spentCents = 0;
}

CostTracker.prototype.record = function(agent, model, usage) {
  var rates = {
    "gpt-4o": { input: 0.25, output: 1.0 },
    "gpt-4o-mini": { input: 0.015, output: 0.06 }
  };
  var rate = rates[model] || { input: 0.1, output: 0.5 };
  var cost = ((usage.prompt_tokens * rate.input)
    + (usage.completion_tokens * rate.output)) / 1000;
  this.spentCents += cost;
  this.usage.push({ agent: agent, model: model, cost: cost });
};

// ============================================================
// Agent Base
// ============================================================
function Agent(name, role, systemPrompt, model) {
  this.name = name;
  this.role = role;
  this.systemPrompt = systemPrompt;
  this.model = model || "gpt-4o";
}

Agent.prototype.call = function(userPrompt, costTracker) {
  var self = this;
  var start = Date.now();

  return client.chat.completions.create({
    model: self.model,
    messages: [
      { role: "system", content: self.systemPrompt },
      { role: "user", content: userPrompt }
    ],
    max_tokens: 2000
  }).then(function(response) {
    var duration = Date.now() - start;
    var content = response.choices[0].message.content;

    if (costTracker && response.usage) {
      costTracker.record(self.name, self.model, response.usage);
    }

    console.log("[" + self.name + "] Completed in " + duration + "ms ("
      + content.length + " chars)");
    return content;
  });
};

// ============================================================
// Create Agents
// ============================================================
var planner = new Agent("Planner", "planner",
  "You are a content strategist. Given a topic, produce a detailed article outline "
  + "in JSON format with: { \"title\": \"...\", \"sections\": [{ \"heading\": \"...\", "
  + "\"points\": [\"...\"], \"targetWords\": 300 }], \"targetAudience\": \"...\", "
  + "\"keyTakeaways\": [\"...\"] }. Return ONLY valid JSON.",
  "gpt-4o-mini"
);

var researcher = new Agent("Researcher", "researcher",
  "You are a senior technical researcher. Given a section outline, gather relevant "
  + "facts, code patterns, best practices, and statistics. Return JSON: "
  + "{ \"findings\": [{ \"fact\": \"...\", \"source\": \"...\", \"confidence\": 0.9 }], "
  + "\"codeExamples\": [{ \"description\": \"...\", \"code\": \"...\" }] }. "
  + "Return ONLY valid JSON.",
  "gpt-4o-mini"
);

var writer = new Agent("Writer", "writer",
  "You are a senior technical writer. You receive an outline and research notes, "
  + "then produce polished, engaging technical content in markdown. Write in a "
  + "practical, authoritative voice. Include code examples with correct syntax "
  + "highlighting. Use var instead of const/let and function() instead of arrow functions.",
  "gpt-4o"
);

var reviewer = new Agent("Reviewer", "reviewer",
  "You are a technical editor. Review the content and return JSON: "
  + "{ \"overallScore\": 1-10, \"technicalAccuracy\": 1-10, \"clarity\": 1-10, "
  + "\"completeness\": 1-10, \"feedback\": [\"specific issue 1\", ...], "
  + "\"approved\": true/false }. Be strict. Score 7+ means publishable. "
  + "Return ONLY valid JSON.",
  "gpt-4o-mini"
);

// ============================================================
// Pipeline Orchestrator
// ============================================================
function ContentPipeline(options) {
  this.bus = options.bus;
  this.costTracker = options.costTracker;
  this.maxRevisions = options.maxRevisions || 2;
}

ContentPipeline.prototype.run = function(topic, callback) {
  var self = this;
  var revisionCount = 0;

  console.log("\n========================================");
  console.log("Starting content pipeline: " + topic);
  console.log("========================================\n");

  // Step 1: Plan
  self.bus.publish("pipeline.status", { from: "Orchestrator", phase: "planning" });

  planner.call("Create a detailed article outline for: " + topic, self.costTracker)
    .then(function(outlineRaw) {
      var outline;
      try {
        outline = JSON.parse(outlineRaw);
      } catch (e) {
        // Try extracting JSON from markdown code block
        var match = outlineRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
        outline = match ? JSON.parse(match[1]) : JSON.parse(outlineRaw);
      }

      self.bus.publish("pipeline.planned", {
        from: "Planner",
        outline: outline
      });

      // Step 2: Research (parallel for each section)
      self.bus.publish("pipeline.status", { from: "Orchestrator", phase: "researching" });

      var researchPromises = outline.sections.map(function(section) {
        return researcher.call(
          "Research this section for an article about '" + topic + "':\n"
          + "Section: " + section.heading + "\n"
          + "Key points to cover: " + section.points.join(", "),
          self.costTracker
        );
      });

      return Promise.all(researchPromises).then(function(researchResults) {
        return { outline: outline, research: researchResults };
      });
    })
    .then(function(planAndResearch) {
      // Step 3: Write
      self.bus.publish("pipeline.status", { from: "Orchestrator", phase: "writing" });

      var writePrompt = "Write a complete article based on this outline and research.\n\n"
        + "## Outline\n" + JSON.stringify(planAndResearch.outline, null, 2) + "\n\n"
        + "## Research Notes\n" + planAndResearch.research.join("\n\n---\n\n")
        + "\n\nProduce the full article in markdown.";

      return writer.call(writePrompt, self.costTracker).then(function(draft) {
        return { outline: planAndResearch.outline, draft: draft };
      });
    })
    .then(function(result) {
      // Step 4: Review loop
      function reviewCycle(draft) {
        self.bus.publish("pipeline.status", {
          from: "Orchestrator",
          phase: "reviewing",
          revision: revisionCount
        });

        return reviewer.call(
          "Review this article:\n\n" + draft,
          self.costTracker
        ).then(function(reviewRaw) {
          var review;
          try {
            review = JSON.parse(reviewRaw);
          } catch (e) {
            var match = reviewRaw.match(/```(?:json)?\s*([\s\S]*?)```/);
            review = match ? JSON.parse(match[1]) : { overallScore: 5, approved: false,
              feedback: ["Could not parse review, requesting revision"] };
          }

          console.log("[Review] Score: " + review.overallScore
            + " | Approved: " + review.approved);

          self.bus.publish("pipeline.reviewed", {
            from: "Reviewer",
            score: review.overallScore,
            approved: review.approved
          });

          if (review.approved || review.overallScore >= 7) {
            return { draft: draft, review: review, revisions: revisionCount };
          }

          if (revisionCount >= self.maxRevisions) {
            console.log("[Pipeline] Max revisions reached, accepting current draft");
            return { draft: draft, review: review, revisions: revisionCount };
          }

          revisionCount++;
          self.bus.publish("pipeline.status", {
            from: "Orchestrator",
            phase: "revising",
            revision: revisionCount
          });

          var revisePrompt = "Revise this article based on the following feedback:\n\n"
            + "## Feedback\n" + review.feedback.join("\n- ") + "\n\n"
            + "## Current Draft\n" + draft
            + "\n\nReturn the complete revised article.";

          return writer.call(revisePrompt, self.costTracker).then(function(revised) {
            return reviewCycle(revised);
          });
        });
      }

      return reviewCycle(result.draft);
    })
    .then(function(finalResult) {
      self.bus.publish("pipeline.complete", {
        from: "Orchestrator",
        score: finalResult.review.overallScore,
        revisions: finalResult.revisions
      });

      // Print cost summary
      var costs = self.costTracker;
      console.log("\n========================================");
      console.log("Pipeline Complete");
      console.log("Final Score: " + finalResult.review.overallScore);
      console.log("Revisions: " + finalResult.revisions);
      console.log("Total Cost: $" + (costs.spentCents / 100).toFixed(4));
      console.log("Total LLM Calls: " + costs.usage.length);
      console.log("========================================\n");

      callback(null, finalResult);
    })
    .catch(function(err) {
      self.bus.publish("pipeline.error", { from: "Orchestrator", error: err.message });
      callback(err);
    });
};

// ============================================================
// Run the Pipeline
// ============================================================
var bus = new MessageBus();
var costTracker = new CostTracker();

// Optional: monitor all bus messages
bus.subscribe("pipeline.status", "Monitor", function(envelope) {
  console.log("[Monitor] Phase: " + envelope.payload.phase);
  return Promise.resolve();
});

var pipeline = new ContentPipeline({
  bus: bus,
  costTracker: costTracker,
  maxRevisions: 2
});

var topic = process.argv[2] || "Building REST APIs with Express.js";

pipeline.run(topic, function(err, result) {
  if (err) {
    console.error("Pipeline failed:", err.message);
    process.exit(1);
  }

  // Write the final article to disk
  var fs = require("fs");
  var filename = topic.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".md";
  fs.writeFileSync(filename, result.draft);
  console.log("Article written to: " + filename);
});

Run it:

npm install openai
node content-pipeline.js "Docker Multi-Stage Builds"

Expected output:

========================================
Starting content pipeline: Docker Multi-Stage Builds
========================================

[Monitor] Phase: planning
[Planner] Completed in 2340ms (1247 chars)
[Bus] pipeline.planned from Planner
[Monitor] Phase: researching
[Researcher] Completed in 3120ms (892 chars)
[Researcher] Completed in 2890ms (1034 chars)
[Researcher] Completed in 3450ms (756 chars)
[Monitor] Phase: writing
[Writer] Completed in 12450ms (4523 chars)
[Monitor] Phase: reviewing
[Review] Score: 6 | Approved: false
[Monitor] Phase: revising
[Writer] Completed in 14200ms (5102 chars)
[Monitor] Phase: reviewing
[Review] Score: 8 | Approved: true

========================================
Pipeline Complete
Final Score: 8
Revisions: 1
Total Cost: $0.0342
Total LLM Calls: 7
========================================

Article written to: docker-multi-stage-builds.md

Common Issues and Troubleshooting

1. JSON Parsing Failures from Agent Responses

SyntaxError: Unexpected token 'H' in JSON at position 0

Agents frequently return markdown-wrapped JSON or add explanatory text before the JSON. Always implement a fallback parser:

function parseAgentJSON(raw) {
  try {
    return JSON.parse(raw);
  } catch (e) {
    var match = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
    if (match) {
      return JSON.parse(match[1]);
    }
    // Last resort: find first { and last }
    var start = raw.indexOf("{");
    var end = raw.lastIndexOf("}");
    if (start !== -1 && end !== -1) {
      return JSON.parse(raw.substring(start, end + 1));
    }
    throw new Error("Could not extract JSON from agent response: " + raw.substring(0, 100));
  }
}

2. Rate Limiting Cascading Through the Pipeline

Error: 429 Too Many Requests - Rate limit exceeded

When running research agents in parallel, you can easily hit API rate limits. Use the semaphore pattern from the scaling section, and add exponential backoff:

function callWithRetry(fn, maxRetries, baseDelay) {
  var attempt = 0;
  function tryCall() {
    return fn().catch(function(err) {
      if (err.status === 429 && attempt < maxRetries) {
        attempt++;
        var delay = baseDelay * Math.pow(2, attempt);
        console.log("[Retry] Attempt " + attempt + ", waiting " + delay + "ms");
        return new Promise(function(resolve) {
          setTimeout(resolve, delay);
        }).then(tryCall);
      }
      throw err;
    });
  }
  return tryCall();
}

3. Context Window Overflow on Revision Cycles

Error: This model's maximum context length is 128000 tokens.
  However, your messages resulted in 134521 tokens.

Each revision cycle appends the full draft plus feedback to the prompt. After two or three revisions, you can blow past the context window. Fix this by summarizing previous feedback instead of including the full history:

function compressFeedback(allFeedback) {
  // Only keep the most recent round's feedback in full
  // Summarize earlier rounds
  if (allFeedback.length <= 1) return allFeedback;

  var latest = allFeedback[allFeedback.length - 1];
  var summary = "Previous review rounds addressed: "
    + allFeedback.slice(0, -1).map(function(fb) {
      return fb.join("; ");
    }).join(" | ");

  return [summary, latest];
}

4. Agent Deadlock from Circular Dependencies

[Supervisor] Assigning task 'review' to Reviewer
[Supervisor] Assigning task 'fact-check' to Researcher
... (hangs indefinitely)

This happens when Agent A waits for Agent B's output, and Agent B waits for Agent A. Prevent it by enforcing a strict DAG (directed acyclic graph) in your pipeline stages and adding timeouts:

function withTimeout(promise, ms, label) {
  return Promise.race([
    promise,
    new Promise(function(_, reject) {
      setTimeout(function() {
        reject(new Error("Timeout after " + ms + "ms: " + label));
      }, ms);
    })
  ]);
}

// Usage
withTimeout(
  researcher.call(prompt, costTracker),
  30000,
  "researcher-section-3"
);

Best Practices

  • Start with two agents, not ten. A writer and reviewer pair captures 80% of the value of multi-agent systems. Add more agents only when you have evidence that a specific step needs specialization.

  • Use structured output formats (JSON) between agents. Free-form text passing between agents causes cascading misinterpretation. JSON with explicit schemas catches format errors immediately instead of degrading output quality silently.

  • Set hard budget limits per pipeline run. A runaway review loop can generate dozens of expensive LLM calls. The CostTracker pattern with a budget ceiling prevents surprise bills.

  • Log every LLM call with input size, output size, and latency. When multi-agent pipeline quality degrades, the logs tell you exactly which agent went wrong. Without structured logging, you are debugging in the dark.

  • Use cheap models for evaluative tasks, expensive models for generative tasks. Reviewers and critics work well with GPT-4o-mini or Claude Haiku. Writers and researchers benefit from GPT-4o or Claude Sonnet. This typically cuts pipeline cost in half.

  • Implement circuit breakers on review cycles. Cap revisions at 2-3 rounds. If an agent cannot produce acceptable output after three tries, the problem is usually in the system prompt or task decomposition, not the number of retries.

  • Test agents in isolation before integrating. Each agent should produce good output given ideal input. If a single agent cannot do its job well, adding more agents around it will not fix the problem.

  • Version your system prompts. When you change an agent's system prompt, log the change with a version number. Prompt regressions are common and difficult to diagnose without version tracking.

References

Powered by Contentful