# How OpenMAIC Handles Turn-Based Interactions Between AI Agents

> OpenMAIC handles turn-based interactions with a dual-layer event architecture separating turns and exchanges for responsive UI and efficient replay via Server-Sent Events.

- Repository: [MAIC/OpenMAIC](https://github.com/THU-MAIC/OpenMAIC)
- Tags: internals
- Published: 2026-09-11

---

**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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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.

```tsx
// 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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/lib/agent/runtime/build-agent.ts). When `buildAgent` constructs the agent, it subscribes to `turn_end` events and checks the stop reason.

```ts
// 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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/lib/workbench/run-courses.ts) extracts classroom IDs from the durable log on a per-exchange basis, deliberately ignoring intermediate turn events.

```ts
// 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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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`](https://github.com/THU-MAIC/OpenMAIC/blob/main/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.