← opencode report

T1 — ReAct loop với finish-reason-aware exit

Vòng lặp agent của opencode kiểm tra finish_reason SAU khi tool execute xong — không phải trước — để tránh bỏ rơi tool call pending khi model trả stop không đúng thời điểm.
Nhóm: A — Agent Loop & StreamingFile: session/prompt.tsHàm: runLoop()Lines: 1305–1530ID: A.1

Tổng quan Overview

ReAct (Reasoning + Acting) là pattern phổ biến nhất cho agent harness hiện đại: model suy luận, gọi tool, nhận kết quả, tiếp tục suy luận. Vòng lặp đơn giản về khái niệm — nhưng ẩn chứa nhiều edge case nguy hiểm khi làm việc với streaming API thực tế.

Điểm khác biệt cốt lõi của opencode: kiểm tra exit condition SAU khi tool đã execute xong, không phải ngay khi nhận finish_reason từ model. Nhiều harness khác break ngay khi thấy finishReason === "stop" — điều này tạo ra silent corruption khi model vừa gọi tool VÀ vừa trả stop trong cùng một response (behavior có ở một số provider).

Tại sao quan trọng: Tool call không được execute = model nhận context thiếu = kết quả sai hoặc agent bị kẹt. Lỗi này không throw exception — nó silent. Agent vẫn chạy bình thường nhưng output sai hoàn toàn. Đây là loại bug khó nhất để debug trong production.
Provider quirk thực tế: Một số LLM provider (đặc biệt khi dùng compatible API) đôi khi trả finish_reason: "stop" trong khi tool_calls array vẫn có dữ liệu. Naive harness sẽ exit và bỏ rơi tool calls đó.
opencode unique: Dùng Effect-TS Generator syntax (yield*) thay vì async/await thuần — cho phép interrupt, retry, và structured concurrency mà không phải viết try/catch phức tạp.

Phân tích code chi tiết Anatomy

Điều kiện thoát vòng lặp — ba guard cùng lúc

opencode kiểm tra ba điều kiện ĐỒNG THỜI trước khi break. Thiếu bất kỳ điều kiện nào cũng có thể gây ra lỗi:

session/prompt.ts — exit condition (runLoop)

TS
{`
while (true) {
  yield* status.set(sessionID, { type: "busy" })
  let msgs = yield* MessageV2.filterCompactedEffect(sessionID)

  // Guard 1: lastAssistant?.finish — model đã có response chưa?
  // Guard 2: !["tool-calls"].includes(lastAssistant.finish) — không còn tool pending?
  // Guard 3: !hasToolCalls — không có tool call nào trong current response?
  // Guard 4: lastUser.id < lastAssistant.id — assistant đã respond sau user chưa?
  if (
    lastAssistant?.finish &&
    !["tool-calls"].includes(lastAssistant.finish) &&
    !hasToolCalls &&
    lastUser.id < lastAssistant.id
  ) {
    yield* slog.info("exiting loop")
    break
  }

  step++
  const result = yield* handle.process(streamInput)
  if (result === "stop") return "break"
  if (result === "compact") yield* compaction.create(...)
}
`}

Điều kiện lastAssistant?.finish kiểm tra xem có phản hồi từ model chưa (tránh exit ngay vòng đầu). Điều kiện !["tool-calls"].includes(...) chỉ exit khi finish reason KHÔNG phải tool-calls. !hasToolCalls là double-check xem response hiện tại có chứa tool call không. Cả bốn điều kiện phải thỏa mãn.

Step counter và compaction trigger

Biến step được tăng sau mỗi iteration (không phải trước exit check). Nó phục vụ hai mục đích: (1) cung cấp context cho T5 (synthetic reminder chỉ chạy khi step > 1), và (2) trigger compaction check sau mỗi bước.

session/prompt.ts — sau exit check

TS
{`
step++  // tăng SAU khi check exit, không trước

const result = yield* handle.process(streamInput)
// result có thể là:
// "stop"    → user cancel hoặc error không thể recover
// "compact" → context overflow, cần compact trước khi tiếp
// undefined → normal, tiếp tục loop
`}

Effect Generator và structured concurrency

Toàn bộ loop chạy trong Effect Generator function (function* với yield*). Điều này cho phép Effect runtime quản lý lifetime, interrupt, và resource cleanup mà không cần code boilerplate.

Effect Generator pattern

TS
{`
// Mỗi yield* là một "suspension point" — Effect có thể interrupt tại đây
// nếu user cancel hoặc có lỗi unrecoverable
yield* status.set(sessionID, { type: "busy" })
yield* slog.info("loop", { step })
`}

Tương tác với các kỹ thuật khác Interaction

T1 là orchestrator trung tâm — nó gọi hầu hết các kỹ thuật khác trong nhóm A:

┌─────────────────────────────────────────────────────────────┐ │ T1 — runLoop() │ │ │ │ ┌─────────┐ ┌─────────────────────────────────────┐ │ │ │ step │ │ handle.process() │ │ │ │ counter │ │ (gọi T2 — stream demultiplexer) │ │ │ └────┬────┘ └──────────────┬──────────────────────┘ │ │ │ │ │ │ step>1? T2 consume stream │ │ │ │ │ │ ▼ ▼ │ │ ┌─────────┐ ┌─────────────────────────────────────┐ │ │ │ T5 │ │ T3 — Deferred coordination │ │ │ │reminder │ │ (đợi tất cả tool done) │ │ │ └─────────┘ └──────────────┬──────────────────────┘ │ │ │ │ │ T4 wraps T2+T3 với ensuring │ │ │ │ │ doom loop check (T6) │ │ feeds exit condition │ └─────────────────────────────────────────────────────────────┘ Flow mỗi iteration: check exit ──► NO ──► step++ ──► T2 stream │ T6 doom check trong T2 │ T3 await all tools done │ T4 cleanup nếu interrupt │ vòng tiếp theo ──► check exit

T2 (streaming demultiplexer) được gọi bởi handle.process() — T1 không trực tiếp đụng vào stream. T3 (Deferred coordination) chạy bên trong T2 và T4 (interruption cleanup) bọc ngoài cả pipeline. T5 (synthetic reminder) chạy trong build-messages phase trước khi T1 gửi request mới. T6 (doom loop) feed signal vào T2, và T2 trả về flag cho T1 để stop.

Failure modes Failures

Failure 1: Exit sớm với tool calls pending

Đây là lỗi phổ biến nhất. Naive harness break ngay khi nhận finish_reason === "stop" mà không kiểm tra xem có tool call chưa được execute không.

Naive implementation — sai

TS
{`
// WRONG: exit ngay khi stop, không kiểm tra tool_calls
while (true) {
  const resp = await llm.call(messages)
  if (resp.finishReason === "stop") break  // có thể bỏ rơi tool calls!
  for (const tc of resp.toolCalls) {
    messages.push(await executeToolCall(tc))
  }
}
// Nếu model trả finish_reason=stop VÀ tool_calls=[...] cùng lúc
// → tool không được chạy → model nhận context thiếu
`}

Failure 2: Không có doom loop guard

Nếu không tích hợp với T6, vòng lặp có thể chạy vô hạn khi model bị kẹt. opencode đặt doom loop detection trong T2, nhưng T1 phải handle signal stop từ T2 (result === "stop").

Thiếu stop signal handling

TS
{`
// Nếu handle.process() không trả về "stop" khi doom loop detected
// → T1 tiếp tục gọi model vô hạn
const result = yield* handle.process(streamInput)
// PHẢI check:
if (result === "stop") return "break"
`}

Failure 3: Step counter không reset khi retry

Nếu step không được reset đúng cách sau retry, các kỹ thuật phụ thuộc vào step (như T5) sẽ bị confused. opencode handle điều này bằng cách reset context đầy đủ trong cleanup.

Step counter và retry

TS
{`
// Nếu retry không reset step → T5 sẽ wrap ALL user messages
// ngay từ step đầu, không phải chỉ step > 1
// → prompt bloated không cần thiết

// opencode: step là local variable trong runLoop scope
// mỗi session invocation bắt đầu từ 0
let step = 0
`}

So sánh với các harness khác Compare

HarnessLoop styleFinish-reason guardĐiểm yếu
opencodeEffect Generator while(true)Có — check sau tool execComplex state machine, Effect learning curve
Claude CodeRecursive call styleKhông rõ ràngKhó trace control flow qua recursion
AiderPrompt-first loopKhôngGửi xong rồi mới check tools — sequential
ClineSequential, one tool mỗi lầnKhông batchKhông hỗ trợ parallel tool execution
AutoGPTwhile loop + max_iterationsChỉ đếm số lầnCount-based, không content-aware

opencode là harness duy nhất trong danh sách batch nhiều tool calls trong 1 step VÀ có explicit finish-reason guard. Claude Code dùng recursion có thể làm điều tương đương nhưng flow ít tường minh hơn.

Implementation recipe Recipe

Minimal TypeScript implementation của finish-reason-aware loop, không cần Effect dependency:

finish-reason-aware-loop.ts

TS
{`
interface LLMResponse {
  finishReason: "stop" | "tool_calls" | "length" | "end_turn"
  toolCalls: ToolCall[]
  content: string
}

interface ToolCall {
  id: string
  name: string
  input: unknown
}

async function runFinishAwareLoop(
  messages: Message[],
  callLLM: (msgs: Message[]) => Promise<LLMResponse>,
  executeTool: (tc: ToolCall) => Promise<unknown>,
  options = { maxSteps: 50 }
) {
  let lastResponse: LLMResponse | null = null
  let step = 0

  while (true) {
    // EXIT CHECK: tất cả điều kiện phải thỏa
    const hasToolCalls = lastResponse?.toolCalls.length ?? 0 > 0
    if (
      lastResponse !== null &&
      lastResponse.finishReason !== "tool_calls" &&
      !hasToolCalls
    ) {
      break
    }

    if (step >= options.maxSteps) {
      throw new Error("Max steps exceeded — possible doom loop")
    }

    step++

    // Gọi model
    const response = await callLLM(messages)
    messages.push({ role: "assistant", content: response.content })
    lastResponse = response

    // Execute ALL tool calls — không skip ngay cả khi finishReason=stop
    if (response.toolCalls.length > 0) {
      const results = await Promise.all(
        response.toolCalls.map(async (tc) => ({
          toolCallId: tc.id,
          result: await executeTool(tc),
        }))
      )
      messages.push({ role: "tool", toolResults: results })
    }
  }

  return lastResponse
}
`}
Recipe trên là simplified version. Production cần thêm: doom loop detection (T6), synthetic reminder injection (T5), interruption handling (T4), và compaction trigger.

Tham khảo Refs

Nguồn chính