How OpenMAIC Handles Turn-Based Interactions Between AI Agents

OpenMAIC manages turn-based interactions through a dual-layer event architecture that separates fine-grained "turns" (individual assistant messages with tool calls) from coarse-grained "exchanges" (complete question-answer pairs), streaming lifecycle events via Server-Sent Events while using terminal barriers and payload slimming to ensure UI responsiveness and efficient replay.

OpenMAIC—an open-source multi-agent framework developed by THU-MAIC—builds on the pi Agent runtime to model conversations as structured event streams. The system handles turn-based interactions between AI agents by distinguishing between real-time streaming updates and persistent conversation units. This design allows the workbench to render progressive assistant output while maintaining lightweight, durable logs.

The Dual-Layer Event Model: Turns vs. Exchanges

OpenMAIC operates on two complementary granularities that define how agent interactions are tracked and displayed.

What Constitutes a Turn

A Turn (in the pi runtime) represents one assistant message plus any tool calls generated by that message. The assistant may emit several turn_start → turn_end cycles while completing a single response. Key event types include:

  • turn_start – Signals the beginning of an assistant generation
  • turn_end – Signals completion, containing the final message and stop reason

What Constitutes an Exchange

An Exchange (OpenMAIC-specific) represents one user question and the full answer the assistant ultimately produces. This is the unit rendered as a card in the workbench UI. It spans one or many pi turns and is bounded by:

  • agent_start – Begins when the user submits a query
  • agent_end – Fires when the complete answer is finalized

Streaming Turn Events to the Client

The client subscribes to the event stream via useWorkbenchStream in lib/workbench/use-workbench-session.ts. This hook registers listeners for every durable event name, including the pi turn events, ensuring the browser receives real-time updates.

// lib/workbench/use-workbench-session.ts
export function useWorkbenchStream(sessionId: string | null): void {
  const applyEvent = useWorkbenchStore((s) => s.applyEvent);
  const setAttached = useWorkbenchStore((s) => s.setAttached);
  const setError = useWorkbenchStore((s) => s.setError);

  useEffect(() => {
    if (!sessionId) return;
    const source = new EventSource(
      `/api/agent/sessions/${encodeURIComponent(sessionId)}/events?lastEventId=${from}`,
    );

    const onAny = (e: MessageEvent) => {
      const parsed = JSON.parse(e.data) as WorkbenchEvent;
      applyEvent(parsed);               // handles turn_start / turn_end …
      if (parsed.type === LIFECYCLE.mediaReady) {
        const frame = parseMediaReadyFrame(parsed.data);
        if (frame) applyMediaReadyFrame(frame);
      }
    };

    // Register listeners for every event type, including turn_*.
    for (const type of WORKBENCH_EVENT_TYPES) {
      source.addEventListener(type, onAny as EventListener);
    }
    // …cleanup omitted for brevity
  }, [sessionId, applyEvent, setAttached, setError]);
}

The WORKBENCH_EVENT_TYPES constant includes turn_start and turn_end, allowing the UI to render progressive updates, tool calls, and intermediate thinking blocks as they arrive.

Enforcing Turn Boundaries with Terminal Barriers

To handle cases where the model hits its output length limit mid-turn, OpenMAIC implements a terminal barrier in lib/agent/runtime/build-agent.ts. When buildAgent constructs the agent, it subscribes to turn_end events and checks the stop reason.

// lib/agent/runtime/build-agent.ts
export function buildAgent(opts: BuildAgentOptions): Agent {
  const agent = new Agent({
    // …streamFn, tools, etc.
  });

  let terminalBarrierActive = false;
  agent.subscribe((event) => {
    if (
      event.type === 'turn_end' &&
      event.message.role === 'assistant' &&
      event.message.stopReason === 'length' &&
      hasLengthToolCallProvenance(event.message)
    ) {
      terminalBarrierActive = true;   // stop further messages for this turn
      agent.clearAllQueues();
    } else if (event.type === 'agent_end') {
      terminalBarrierActive = false;  // resume normal operation
    }
  });

  // Guard steer / followUp calls behind the barrier.
  const originalSteer = agent.steer.bind(agent);
  agent.steer = (msg) => !terminalBarrierActive && originalSteer(msg);
  // …
  return agent;
}

When the assistant stops due to a length limit, the barrier activates, calling clearAllQueues() to prevent stray messages from entering the stream and wrapping the steer method to block further inputs until the exchange completes.

Aggregating Turns into Exchange Cards

The workbench UI folds multiple turns into a single card per exchange. The runCourseStageIds helper in lib/workbench/run-courses.ts extracts classroom IDs from the durable log on a per-exchange basis, deliberately ignoring intermediate turn events.

// lib/workbench/run-courses.ts
export function runCourseStageIds(events: readonly CourseSightingEvent[]): readonly string[] {
  let seen: readonly string[] = [];
  for (const event of events) {
    // `courseSightingsOf` looks at agent_* and checkpoint events only,
    // deliberately ignoring `turn_*` frames.
    for (const stageId of courseSightingsOf(event)) {
      seen = appendCourseSighting(seen, stageId);
    }
  }
  return seen;   // de‑duplicated, first‑seen order
}

By processing only agent_start, agent_end, and checkpoint events, the function ensures that a multi-turn answer—however many turn_end events it generates—results in exactly one UI card representing the complete exchange.

Optimizing Server-Side Event Storage

The server-side runner (lib/server/agent-runtime/runner.ts) receives raw pi events and calls slimEventDataForLog before persisting them. This function shrinks turn_end payloads by stripping unnecessary fields, keeping the durable log lightweight while preserving essential information needed for session replay. The slimmed events maintain the turn structure required to reproduce the exact UI state without excessive memory consumption.

Summary

  • Turn-based interactions in OpenMAIC rely on a dual-layer architecture separating pi runtime Turns (streaming units) from OpenMAIC Exchanges (UI units).
  • The client subscribes to Server-Sent Events via useWorkbenchStream in lib/workbench/use-workbench-session.ts to receive real-time turn_start and turn_end updates.
  • A terminal barrier in lib/agent/runtime/build-agent.ts halts message processing when the model hits length limits, ensuring clean turn termination.
  • The exchange-level folding logic in lib/workbench/run-courses.ts aggregates multiple turns into single workbench cards using agent_start and agent_end boundaries.
  • Server-side payload slimming in lib/server/agent-runtime/runner.ts maintains efficient, replayable session logs.

Frequently Asked Questions

What is the difference between a turn and an exchange in OpenMAIC?

A turn represents a single assistant message generation cycle (including its tool calls) bounded by turn_start and turn_end events. An exchange represents a complete user question and the assistant's final answer, spanning one or many turns and bounded by agent_start and agent_end events. The exchange is the unit displayed as a card in the workbench UI.

How does OpenMAIC prevent message flooding when a turn hits the model length limit?

When the turn_end event indicates a stopReason of length, the buildAgent function activates a terminal barrier. This sets an internal flag that blocks the steer method and calls clearAllQueues() to flush pending messages, preventing any further transmissions for that turn until the agent_end event resets the barrier.

Why does the client ignore turn events when displaying conversation cards?

The runCourseStageIds function in lib/workbench/run-courses.ts deliberately processes only agent_* and checkpoint events, ignoring turn_* frames. This ensures that a multi-turn answer—where the assistant may restart generation multiple times—renders as a single cohesive card rather than spawning multiple UI elements for each intermediate turn.

How does OpenMAIC keep session logs lightweight while supporting full replay?

The server-side runner.ts calls slimEventDataForLog to strip unnecessary fields from turn_end payloads before persistence. This reduces log size and memory footprint while retaining the essential event structure (turn_* and agent_* boundaries) required to reconstruct the exact UI state during session replay.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →