Bài 1 mô tả tool loop bằng 5 bước đơn giản: model nhận prompt, sinh text và tool call, executor chạy tool, kết quả quay trở lại, lặp lại. Đơn giản trên giấy. Nhưng khi bạn chạy một tác vụ thực tế, ví dụ “tìm tất cả chỗ dùng useAuth rồi refactor thành hook mới”, loop đó không chạy tuyến tính một tool một bước.

Bài này đi sâu vào phần binary không document rõ: parallel tool calls, retry behavior khi tool thất bại, phân loại lỗi, và cơ chế end-of-turn. Cuối bài là ví dụ cụ thể một turn với 3 tool call chạy song song để thấy toàn bộ flow.

Recap 5 bước (nhanh)

Từ bài 1, tool loop có dạng:

1. Model nhận prompt (L1-L5 assembled)
2. Model output: text + optional tool calls
3. Binary executor chạy tool call(s)
4. Kết quả được nối lại vào conversation
5. Quay lại bước 1 (nếu còn tool call tiếp theo)

Vòng lặp kết thúc khi model output không chứa tool call (end-of-turn), hoặc user gửi message mới.

Điều bài 1 không nói rõ là ở bước 2 và 3 có thể có nhiều tool call cùng lúc, và ở bước 3 có thể có failure cần handle trước khi về bước 4.

Parallel tool calls

Khi nào model emit song song

Model có thể quyết định phát nhiều tool call trong một response, thay vì phát từng cái một. Điều này xảy ra khi model nhận thấy các sub-task độc lập nhau về mặt logic: không có cái nào cần kết quả của cái kia để chạy.

Ví dụ bạn yêu cầu: “Tìm tất cả file dùng useState, đồng thời đọc package.json để xem phiên bản React.”

Model sẽ emit hai tool call cùng một lúc trong cùng một response chunk:

[tool_use: Grep, pattern="useState", path="src/"]
[tool_use: Read, file="package.json"]

Hai call này độc lập hoàn toàn. Binary executor không cần đợi Grep xong mới chạy Read.

Binary executor xử lý thế nào

Binary nhận danh sách tool call từ model response, phân loại độc lập hay có phụ thuộc, rồi dispatch. Những call độc lập được chạy concurrently. Binary đợi tất cả complete, gom kết quả, nối lại vào conversation theo đúng thứ tự ID đã phát.

Sơ đồ:

Model response (1 turn)
  |
  +-- tool_call_1 (Grep)  ----+
  |                           |---> executor (concurrent)
  +-- tool_call_2 (Read)  ----+
  |                           |
  +-- tool_call_3 (Bash)  ----+
                              |
                         results ready
                              |
                     nối kết quả vào conversation
                              |
                         model nhận lại (next turn)

Điều quan trọng: model không biết tool nào chạy trước hay sau về mặt wall clock. Nó chỉ biết khi nào tất cả kết quả về đủ, một gói. Nên model không thể “đọc giữa chừng” kết quả của tool_call_1 và điều chỉnh tool_call_2 trong cùng turn đó.

Khi nào không parallel

Nếu tool call B cần kết quả của A, model sẽ không emit B trong cùng turn với A. Nó emit A, đợi kết quả về, phân tích, rồi mới emit B ở turn tiếp theo. Đây là lý do một tác vụ “đọc file, sửa theo nội dung đọc được” bao giờ cũng mất 2 turn dù tưởng như có thể 1 turn.

Model học pattern này từ training, không phải từ cơ chế bắt buộc. Tức là về lý thuyết model có thể emit B song song với A dù B phụ thuộc A. Executor vẫn chạy, nhưng kết quả B sẽ sai. Thực tế Sonnet và Opus ít khi mắc lỗi này với các tác vụ phổ biến.

Retry behavior

Model có tự retry không?

Câu trả lời ngắn: không có cơ chế retry tự động ở tầng binary. Khi tool thất bại, executor gửi lại kết quả là error message (không phải thành công). Model nhận error đó như một “kết quả” bình thường và quyết định bước tiếp theo.

Quyết định tiếp theo của model tùy context:

  • Nếu error có thể fix được (ví dụ: file path sai, model vừa đoán sai tên file), model sẽ tự sửa path và gọi lại.
  • Nếu error là permission denied, model thường báo user thay vì retry.
  • Nếu error là command not found (Bash), model có thể thử lệnh thay thế.
  • Nếu tool timeout, behavior phụ thuộc vào cách model được train với loại error đó.

Về mặt cơ chế, đây không phải “retry” theo nghĩa kỹ thuật (exponential backoff, max attempts). Đây là model tự quyết định call lại tool với argument khác. Sự khác biệt có nghĩa: nếu lỗi là transient (network flake), model sẽ không tự retry với cùng argument. Nó chỉ retry nếu nó “nghĩ” cần thay đổi gì đó.

Vòng lặp không có lối thoát

Một tình huống thực tế: model nhận error, sửa argument, gọi lại, nhận error khác, sửa tiếp, lặp lại. Nếu root cause là thứ model không thể sửa, vòng này có thể chạy nhiều turn trước khi model báo “tôi không làm được”. Token tốn, thời gian hao. Khi thấy model “đang vật lộn” với một tool error, tốt hơn là interrupt và cung cấp thêm context.

Phân loại error trong tool loop

Không phải lỗi nào cũng giống nhau. Hiểu phân loại giúp bạn biết khi nào nên để model tự xử lý và khi nào phải can thiệp.

Loại lỗiVí dụModel thường làm gì
Tool output errorBash trả về exit code != 0Đọc stderr, thử lại với lệnh khác
Permission deniedBash tool bị block bởi deny listBáo user, không retry
File not foundRead path saiThử Glob để tìm file đúng
TimeoutBash command chạy quá lâuBáo user, đề xuất cách khác
Tool unavailableGọi tool không có trong L3Dùng ToolSearch để load schema
Schema mismatchArgument sai type / thiếu fieldSửa argument, gọi lại

Hai loại đặc biệt cần chú ý.

Permission denied: hành vi khác hoàn toàn

Khi tool bị block bởi deny list trong settings.json, executor không thực thi. Nó trả về error ngay lập tức. Model nhận được message kiểu Tool call denied by permission policy. Model không retry (đúng đắn: retry cũng vô nghĩa vì sẽ bị block lại). Nó báo user rằng action đó không được phép.

Đây là cơ chế safety quan trọng. Bài 4 sẽ đi sâu vào permission model và cách dùng deny list để hard-block các operation nguy hiểm.

Tool unavailable: deferred tools

Một số tool không load sẵn vào L3 (tiết kiệm token). Khi model cần dùng tool chưa load, nó gọi ToolSearch để fetch schema. Đây không phải “lỗi” về mặt kỹ thuật: nó là bước lookup hợp lệ. Nhưng nó tốn thêm một turn. Nếu bạn muốn giảm latency cho workflow hay dùng tool deferred, cách là khai báo chúng qua MCP để chúng luôn nằm trong L3.

End-of-turn: loop dừng khi nào

Loop dừng trong hai trường hợp:

1. Model không emit thêm tool call. Response cuối của model chỉ chứa text (câu trả lời, giải thích, hoặc câu hỏi ngược lại với user). Executor không có gì để chạy. Turn kết thúc, control trở về user.

2. User interrupt. Người dùng gửi message mới (Enter với text mới) hoặc nhấn Escape. Binary flush các tool call đang chờ (chưa thực thi hoặc đang chạy), đưa conversation về state sạch để nhận message mới.

Một điểm tinh tế: model không có “ngân sách turn” cố định. Không có giới hạn cứng kiểu “sau 10 turn tool loop, tự dừng”. Giới hạn thực tế đến từ context window: nếu tool results quá dài hoặc history quá nhiều turn, compaction sẽ xảy ra (xem bài 5). Một số version CC có safety limit riêng cho agentic run để tránh runaway loop, nhưng đây không phải default behavior của interactive session.

Hook intersection: PreToolUse và PostToolUse

Bài 9 sẽ đi chi tiết về hooks. Ở đây chỉ cần biết vị trí của hai hook quan trọng trong loop:

Model emit tool_call_N
         |
         v
[PreToolUse hook fire ở đây]
         |
         v
Binary executor chạy tool
         |
         v
[PostToolUse hook fire ở đây]
         |
         v
Tool result nối vào conversation

PreToolUse nhận được tool name và argument trước khi chạy. Bạn có thể dùng nó để:

  • Log mọi tool call (audit trail).
  • Inject thêm context vào argument.
  • Block điều kiện phức tạp hơn deny list trong settings.json.

PostToolUse nhận được tool result sau khi chạy. Bạn có thể dùng nó để:

  • Notify Slack / Telegram khi một action quan trọng vừa xong.
  • Mutate kết quả trước khi model nhìn thấy (dùng cẩn thận).
  • Trigger side effect ngoài tool loop (ví dụ: cập nhật Jira ticket).

Với parallel tool calls, mỗi tool call có PreToolUse và PostToolUse riêng. Hook fire per-call, không per-batch.

Ví dụ cụ thể: 1 turn với 3 tool call parallel

Giả sử bạn gửi:

“Đọc src/hooks/useAuth.ts, tìm tất cả component import useAuth, đọc tsconfig.json.”

Ba task độc lập nhau. Model nhận xét chúng không có phụ thuộc và emit 3 tool call trong một response:

Response chunk 1 (text):
  "Để hoàn thành yêu cầu, tôi cần..."

Response chunk 2 (tool calls):
  tool_use id=tc_001: Read { file: "src/hooks/useAuth.ts" }
  tool_use id=tc_002: Grep { pattern: "useAuth", path: "src/" }
  tool_use id=tc_003: Read { file: "tsconfig.json" }

Binary nhận 3 call, dispatch concurrent:

t=0ms   tc_001 starts (Read useAuth.ts)
t=0ms   tc_002 starts (Grep useAuth)
t=0ms   tc_003 starts (Read tsconfig.json)
        [PreToolUse fires 3 lần, per-call]

t=5ms   tc_003 done (tsconfig.json nhỏ, xong trước)
        [PostToolUse fires cho tc_003]

t=12ms  tc_001 done (useAuth.ts đọc xong)
        [PostToolUse fires cho tc_001]

t=45ms  tc_002 done (Grep mất lâu hơn)
        [PostToolUse fires cho tc_002]

Binary đợi tất cả 3 xong (t=45ms), gom kết quả:

tool_result id=tc_001: [nội dung useAuth.ts]
tool_result id=tc_002: [danh sách file match useAuth]
tool_result id=tc_003: [nội dung tsconfig.json]

Ba kết quả này được nối vào conversation theo đúng thứ tự ID. Model nhận cả 3 cùng lúc ở turn tiếp theo và bắt đầu phân tích.

Tổng thời gian: 45ms (thời gian của call chậm nhất), không phải 5+12+45=62ms như nếu chạy tuần tự. Đây là lợi thế của parallel calls với tác vụ I/O-bound.

Nếu bất kỳ call nào thất bại, executor gắn error vào tool_result tương ứng. Hai call kia vẫn chạy bình thường. Model nhận kết quả của 2 call thành công và error của call thứ 3, rồi quyết định tiếp theo.

Tóm tắt và bài tiếp theo

  • Tool loop không chỉ tuyến tính. Model có thể emit nhiều tool call trong một turn; executor dispatch chúng concurrent nếu chúng độc lập nhau.
  • Không có retry tự động. Model tự quyết định có gọi lại với argument khác hay không, dựa trên nội dung error. Transient error sẽ không được tự retry.
  • Lỗi phân thành nhiều loại: output error, permission denied, timeout, tool unavailable, schema mismatch. Mỗi loại model phản ứng khác nhau.
  • Loop dừng khi model không còn tool call hoặc user interrupt. Không có giới hạn turn cứng trong interactive session.
  • PreToolUse và PostToolUse hook fire per-call, kể cả trong parallel batch. Đây là nơi can thiệp vào loop mà không cần sửa model hay executor.

Bài 4 sẽ đi sâu vào permission model: cách deny list, allow list, và 4 mode (ask, auto, acceptEdits, bypassPermissions) phối hợp với nhau trong tool loop. Đây là bài học tôi từng học theo cách đắt nhất.


Bài thuộc series Claude Code từ zero. Series plan tại bài giới thiệu.