my-agent-crew

Kiểm thử

Phiên bản: 0.20.0 · Cập nhật: 2026-10-14

Ba tầng, một quy tắc: mỗi tính năng ra kèm một test ở tầng thấp nhất có thể thấy nó. Bản thân các tệp test là bản kiểm kê đầy đủ; trang này nói mỗi tầng dùng để làm gì, chạy ra sao, và — ở mục cuối trang — tính năng nào được test ở đâu, để người sửa một tính năng biết test nào phải đổi theo. Số lượng test không giữ ở đây — chạy lệnh.

Tầng Chạy bằng Thấy được gì
pytest (tests/) uv run pytest -q vòng lặp, tool, store, provider, profile, scheduler, kênh, HTTP API và các stream SSE của nó — mọi thứ server làm, với model thay bằng MY_AGENT_ROUTES=fake:echo (hoặc fake:slow: cùng model giả, nhưng chờ giữa hai mảnh để nhìn được stream tới dần)
vitest (web/) cd web && npm test parser, reducer, API client, từng component, và cả App chạy trên một fake server trong bộ nhớ. Worker tắt web storage riêng của Node, nên máy Node 26 và CI Node 24 cùng dùng storage của jsdom, và mỗi test bắt đầu với storage rỗng (test-environment.test.ts)
Playwright (web/e2e/) cd web && npm run e2e trình duyệt thật trên Vite dev server thật, /api do một mock trong test trả lời; dùng cho các luồng chỉ hỏng trong trình duyệt (SSE reconnect, layout ở bề rộng điện thoại, drawer điện thoại là modal, bàn phím). Proxy /api của dev server khi chạy e2e trỏ vào một cổng không ai nghe (API_ORIGIN), nên spec nào quên mock sẽ hỏng chứ không gọi vào đội đang chạy trên máy (dev-proxy.spec.ts)

Trình trợ giúp desktop là một chương trình Swift riêng, ngoài ba tầng này: bộ XCTest của nó chạy bằng scripts/desktop-helper-check.sh trên macOS; xem Desktop của agent.

Ưu tiên tầng thấp nhất: một quy tắc của vòng lặp thuộc về pytest, một reducer thuộc về vitest, và Playwright chỉ cho những gì chỉ DOM mới cho thấy. Hành vi vắt qua nhiều tầng (một duyệt tạm dừng vòng lặp và thanh hiện ra; nâng trần chi phí gỡ over_budget ở server và mở lại ô soạn) có một test ở mỗi bên ranh giới. Fake server của vitest và mock của Playwright tự tính lại những trường server suy ra (như over_budget từ trần), để test không tin vào một con số cũ. Route của Playwright trả cả thân một lần, nên một stream còn đang chạy khi bấm Dừng được dựng ngay trong trang bằng addInitScript (test Dừng trong chat-smoke.spec.ts), và luồng hoạt động cũng vậy khi một số đếm phải đổi sau khi trang đã mở (manage-row-phone.spec.ts, spec này còn chờ font tải xong rồi mới mở mục, vì font đến muộn làm mọi pill rộng ra); còn run do kênh khác chạy và nút Thử lại của luồng trực tiếp có test App riêng trong app-thread-refresh.test.tsx; những lần tải lại chạy đua với lượt của chính tab này (một tin gửi khi lần tải còn trên đường; một run của kênh khác đã chạy từ trước khi gửi, bắt đầu sau run của tab, hay được một quyết định ở nơi khác tiếp tục trong lúc quyết định ở đây nhận 409; hai lượt liền nhau của tab, và run mà quyết định ở đây tiếp tục, vẫn là của tab nên không tải thêm lần nào; một lần tải, một lần tải hỏng (500 hay mất mạng) hay một 409 của cuộc trò chuyện vừa rời trả về khi cuộc khác đã mở; lời báo “đã được xử lý” còn nguyên qua lần tải do run được tiếp tục hay luồng kết nối lại, và chỉ mất khi lần tải mang về một yêu cầu mới hay khi mở cuộc khác) nằm ở app-thread-refresh-races.test.tsx, nơi mỗi fetch lấy câu trả lời lúc gửi nhưng chỉ trao ra khi test mở “cửa” của nó; các lần đọc danh sách run chồng lên nhau (câu trả lời đọc trước khi run kết thúc lại về sau cùng) được giữ cửa theo cùng cách trong hooks/use-activity.test.ts. Việc dò bản mới so entry có hash của trang với entry mà / đang phục vụ, nên nó im trên Vite dev server; version-refresh.spec.ts và test App của nó gắn một entry giả vào trang để trang trông như bản build. Bố cục những gì chat nói về trạng thái của nó ở bề rộng điện thoại (dòng trạng thái giữ một chiều cao khi nút Thử lại hiện ra) nằm ở chat-state-phone.spec.ts; luồng trực tiếp được một route giữ lại để bắt được cả lúc đang kết nối lẫn lúc đã mất.

Fake server của vitest giữ yêu cầu duyệt đang mở như server thật: một lượt dừng ở approval_required để lại yêu cầu trên cuộc trò chuyện, và quyết định cho một yêu cầu đã đóng nhận 409. Nhờ vậy việc duyệt ngay trong Quản lý › Duyệt được test ở vitest cả khi tab khác hay lượt quét hết hạn giành trước (báo “đã được xử lý”, không báo bận), cả khi đồng hồ đếm về 0 (nút khoá, danh sách tải lại, rồi yêu cầu được đọc lại cho tới khi lượt quét — có thể trễ vài phút sau hạn — khép nó), còn Playwright chỉ giữ phần chỉ trình duyệt thấy: bấm Cho phép trên bề rộng 390px mà trang không cuộn ngang, và trên màn điện thoại thấp 390×664 mở đầy đủ một tệp 120 dòng mà các nút quyết định vẫn trên màn hình, trang không cuộn. Lịch sử duyệt bên dưới được đọc lại mỗi khi một yêu cầu có thể vừa khép: run của nó chạy tiếp, run kết thúc (kể cả khi chạy tiếp và kết thúc đến trong cùng một lần cập nhật), run của nó chạy tiếp đúng lúc run của một cuộc khác bắt đầu chờ (trong một snapshot khi luồng kết nối lại hay hai run đến cùng lúc: số run chờ và số run xong đều giữ nguyên, chỉ run nào đang chờ là đổi), một run trang chưa từng thấy chờ đến nơi đã xong, hay hàng vừa quyết định thấy run dừng chờ yêu cầu kế tiếp; mỗi đường có một ca trong app-attention.test.tsx.

Run dừng chờ duyệt sống qua lần khởi động lại: tests/test_activity_restart.py dựng một activity hub thứ hai trên cùng store như một tiến trình mới sẽ dựng, rồi kiểm tra quyết định đến sau đó chạy tiếp chính run đã dừng (kể cả sau hai lần khởi động lại), còn run chờ duyệt không còn gì để chờ (yêu cầu đã được quyết khi không tiến trình nào giữ run, run không thuộc cuộc nào, một lần dừng cũ hơn của cùng cuộc, hay yêu cầu đang chờ thuộc cuộc khác) bị đóng là interrupted; một step chạy tiếp sau khi máy khởi động lại không bao giờ báo thời lượng âm. Xoá một cuộc chỉ đóng run chờ duyệt của chính cuộc đó, không đụng run đang chờ ở cuộc khác hay lượt còn đang chạy. Xoá cuộc đang chờ duyệt cũng đánh thức những ai đang chờ nó: tests/test_memory_conversation_title.py kiểm tra không trả tiền cho model đặt tên một cuộc đã bị xoá, hay đã được người dùng tự đặt tên, trong lúc chờ; tests/test_tools_delegate.py kiểm tra agent cha đọc được lời báo cuộc con đã bị xoá thay vì một KeyError trần.

Test bảo vệ

Vài test bảo vệ repo chứ không phải một tính năng:

Chạy các cổng

./scripts/gates.sh chạy mọi cổng CI theo thứ tự và dừng ở cổng đỏ đầu tiên; xem code-standards.md. Danh sách cổng là .github/workflows/ci.yml, trừ job desktop-helper: job ấy cần macOS và bộ công cụ Swift nên gates.sh không chạy nó; ai sửa desktop-helper/ chạy scripts/desktop-helper-check.sh trên máy Mac trước khi đẩy.

Dev server của Playwright nghe ở cổng 4173. Khi hai checkout cùng chạy e2e, E2E_PORT=<cổng> npm run e2e dời nó sang cổng khác: trên cùng một cổng, lần chạy sau dùng lại server của lần trước và lặng lẽ test mã nguồn của checkout kia. Vitest (qua TZ) và Playwright (qua timezoneId) đều chạy ở múi giờ Asia/Ho_Chi_Minh, nên ranh giới ngày (“Hôm nay”, “Hôm qua”, ghi chú hôm nay) rơi cùng một chỗ trên máy và trên CI, vốn chạy UTC; test về ngày cứ viết theo giờ Việt Nam, không tự đặt múi giờ.

Benchmark model trên vòng lặp thật

scripts/llm_bench.py đo các model ứng viên ngay trên vòng lặp agent, tách khỏi crew đang chạy: mỗi model một home tạm dưới --out, một server riêng trên --port (mặc định 8797, không bao giờ là cổng live), không có token Telegram, không có tuyến live. Từ môi trường của người chạy, server chỉ nhận những biến một chương trình cần để chạy (PATH, HOME, LANG và các LC_*, TERM, TMPDIR, USER, LOGNAME, SHELL, TZ) cùng OPENROUTER_API_KEY; hook và lệnh server chạy cũng chỉ thấy chừng ấy. Hai bộ việc, chọn bằng --tasks short, --tasks multi hoặc liệt kê id (mặc định chạy cả hai):

Mỗi việc được chấm đúng/sai (việc giao việc còn phải để lại đủ số run con) và đo thời gian tường, số lần gọi model, thời gian tới token đầu (first_token_ms của bước model), phần prompt được cache và chi phí, đọc từ /api/activity/runs. Kết quả ghi ra results.json và results.md sau mỗi model:

uv run python scripts/llm_bench.py --models deepseek/deepseek-v4-flash,qwen/qwen3.7-flash --out /tmp/llm-bench
uv run python scripts/llm_bench.py --models deepseek/deepseek-v4-flash --tasks multi --out /tmp/llm-bench
uv run python scripts/llm_bench.py --models deepseek/deepseek-v4-flash@deepinfra --out /tmp/llm-bench

Dạng model@provider ghim một provider OpenRouter (openrouter_providers, không dự phòng) trong home tạm, để so cùng một model qua các bên khác nhau.

Một lượt hỏi người dùng (ask_user) tính là hỏng — bench không có ai trả lời; yêu cầu duyệt tool thì bench tự duyệt và tính vào thời gian lượt. Cổng bận thì script từ chối chạy thay vì giết chủ cổng.

Kiểm thử hành vi bằng model thật

Bench ở trên so các model với nhau; kiểm thử hành vi hỏi câu khác: agent của mình — đúng persona, đúng bộ tool, đúng model mà crew thật đang dùng — có làm điều nó phải làm và tránh điều nó không được làm không. Đó là chỗ duy nhất bắt được kiểu hỏng “sửa persona xong, agent quên một luật cũ” hay “thêm tool xong, agent gọi nó bừa”; model giả không thấy được những thứ đó. scripts/run_evals.py chơi các case viết tay trên một bản sao của home và chấm cái agent cố làm: gọi tool nào, xin duyệt việc gì, nói gì. Nó không chấm một lệnh in ra gì, vì lệnh cần duyệt bị từ chối theo mặc định.

Chạy tay, không nằm trong CI: tốn tiền thật, và một model không trả lời hai lần giống nhau. Chạy trước mỗi lần phát hành và sau mỗi lần đổi persona, prompt hay bộ tool của một agent:

export OPENROUTER_API_KEY=...      # bí mật duy nhất server eval được nhận
uv run python scripts/run_evals.py --runs 3 --max-usd 0.5
uv run python scripts/run_evals.py --only <id>,<id> --runs 1
uv run python scripts/run_evals.py --dry-run

Không có gì chạy trên crew thật. Home và các workspace mà agent dùng được chép vào <out>/run (chỉ chủ sở hữu đọc được); một server khởi động trên bản sao ở --port (mặc định 8798, không bao giờ là cổng live) với --no-schedule — không scheduler, không Telegram hay kênh nào — và HOME trỏ vào bản sao. Cả hai biến mất khi xong; --keep-home giữ bản sao lại, và vì nó chứa dữ liệu của các agent, tự xoá tay. Bản sao:

Mọi cuộc trò chuyện của eval không tự động (autonomous tắt) và bản sao không còn danh sách cho phép lệnh shell, nên mọi shell_run đều hỏi trước. Danh sách cho phép mà còn sót thì một lệnh khớp nó chạy không ai hỏi, kể cả lệnh nêu đường dẫn live, nên build_home từ chối một bản sao còn danh sách ấy. Runner từ chối hoặc duyệt theo approvals của case; kể cả khi duyệt, một lệnh nêu đường dẫn của home hay workspace live vẫn bị từ chối, vì lệnh ấy có thể đọc hay ghi cây live. Câu hỏi của agent lấy câu trả lời kế tiếp trong answers; hết câu trả lời thì lần chơi hỏng. Agent con xin duyệt trong cuộc trò chuyện của nó, luồng của cha không mang những yêu cầu ấy, nên runner hỏi các cuộc con mỗi nửa giây và trả lời theo cùng chính sách; không có nó, lượt của cha chờ mãi.

Rủi ro còn lại, đã thu hẹp chứ chưa xoá: persona của vài agent gõ lệnh kèm đường dẫn tuyệt đối vào cây live. Mọi lệnh shell đều hỏi và runner không duyệt lệnh nêu đường dẫn ấy, nên một lệnh như vậy không chạy; nhưng runner chỉ nhận ra đường dẫn viết nguyên văn, nên một lệnh dựng đường dẫn bằng cách khác vẫn có thể lọt nếu case duyệt. Vì thế mọi case mặc định deny và chấm cái agent thử làm; approve chỉ dành cho việc không đụng đường dẫn live. Các lớp chặn còn lại: HOME của server trỏ vào bản sao, môi trường của server, và của mọi hook hay lệnh nó chạy, chỉ có một danh sách nhỏ biến, bản sao không có .venv và không có symlink. Một chuỗi trong manifest hay config.yaml vẫn nêu đường dẫn live được báo trong cảnh báo của lần chạy chứ không bị viết lại.

Model giả (fake:echo) cấp cho mỗi lệnh gọi một mã riêng, như provider thật vẫn làm: agent con được tìm lại theo mã lệnh gọi của cha, nên hai cuộc trò chuyện giao việc ở cùng một chỗ mà cùng mã thì cuộc sau nhận nhầm agent con của cuộc trước.

Mã nằm trong scripts/, tách theo việc để mỗi tệp ≤ 200 dòng như mã của gói (cùng một test kiểm cả hai): run_evals.py (điểm vào), eval_cli.py (tuỳ chọn, chọn case, từ chối sớm), eval_play.py (chơi một lần, chạy hết các case, bản ghi), eval_reset.py (đưa server và bộ nhớ về như lúc đầu trước mỗi lần chơi), eval_home.py + eval_copy.py + eval_layout.py (bản sao), eval_cases.py + eval_expect.py + eval_check.py (đọc và chấm case), eval_shape.py (dạng giá trị chung của case, kỳ vọng và bước), eval_canvas.py (bước canvas), eval_note.py (bước canvas có tới agent không), eval_paste.py (luật chép canvas vào chat), eval_observe.py (biến một cuộc trò chuyện thành thứ chấm được), eval_client.py (duyệt, câu hỏi, sổ cái, canvas), eval_report.py (báo cáo). Server và client HTTP chia với bench: llm_bench_server.py, llm_bench_client.py.

Smoke trực tiếp (thủ công)

MY_AGENT_ROUTES=fake:echo trên một MY_AGENT_HOME tạm, rồi qua UI hoặc curl: chat → /tool workspace_list {"path":"."} → /tool workspace_write {...} → duyệt → tệp tồn tại trong MY_AGENT_HOME/workspace. Với profile agent có lịch: POST /api/jobs/<agent>/<schedule>/run phải tạo ra một run trên /api/activity/runs và một thẻ trong rail. Với ít nhất một agent khác đã cài: POST /api/inbound {"text": "Nhờ kongming …"} (với fake:echo, viết thẳng /tool delegate {"agent": "kongming", "task": "…"} vì provider giả không tự chọn tool) phải trả lời bằng chính lời của agent con (lượt chỉ có một lần giao việc thì không kể lại) và để lại một run con có source là delegate:<conversation id> trên /api/activity/runs. Đây là bước kiểm tra cần lặp lại trước khi gắn tag phát hành. Duyệt tool qua API: id nằm trong sự kiện SSE approval_required của luồng tin nhắn, vì GET /api/approvals chỉ liệt kê các yêu cầu đã được quyết.

Tính năng nào được test ở đâu

CLAUDE.md đòi trang này nối mỗi tính năng với test giữ nó. Tệp vitest tính từ web/src/, spec Playwright từ web/e2e/; test Python ghi theo node id của pytest, chạy thẳng được bằng uv run pytest <node id>. Chữ trong ngoặc kép đứng sau một tệp là tiêu đề describe, it hay test trong tệp đó, chép nguyên văn để grep tìm thấy. Bản đồ bắt đầu từ các tính năng ra sau bản 0.8.0; phần có từ trước được kể theo tầng ở trên và vào đây khi được sửa tới. Thêm hay đổi tên một test thì sửa dòng của nó trong cùng commit.

Trang không tự cuộn

Ứng dụng cao đúng một cửa sổ, mỗi cột tự cuộn. Lỗi thật thấy trên live ngày 2026-10-05 (v0.11.0, canvas dài mở cạnh chat): cuộn hết một cột thì cả ứng dụng bị kéo lên, bên dưới là một dải trắng. Nguyên nhân: nhãn .sr-only trong tin nhắn lấy khung ngoài thread làm mốc, nên nằm lại ở chỗ của thread chưa cuộn, cách cửa sổ hàng nghìn pixel, và làm chính trang cuộn được. Sửa: .thread có position: relative.

Rà các cột cuộn còn lại cùng ngày tìm ra hai chỗ nữa mắc đúng lỗi này, đều đã sửa bằng position: relative trên chính cột cuộn: .conversation-list (chấm chưa đọc mang một nhãn .sr-only) và .manage-body (huy hiệu, dòng trạng thái và .canvas-library-said:empty). Các cột khác đã có mốc sẵn: mỗi .step của dòng thời gian và mỗi .md-code tự là mốc cho nhãn của mình.

Kết quả tạo canvas ở lượt đọc xa web

Lỗi thật thấy trên Telegram ngày 2026-10-05: được nhờ “viết vào canvas rồi gửi tệp”, agent gọi artifact_export rồi viết FILE: <tệp workspace> ở cả bốn lượt, không lần nào dùng FILE: artifact:<id>. Kết quả tool nó vừa đọc nói “người thấy nó ngay cạnh khung chat”. Sửa: lượt Telegram và job nhận câu khác và một dòng nêu đúng FILE: artifact:<id>.

Dòng FILE: trỏ vào ảnh trên Telegram

Thấy trên Telegram thật ngày 2026-10-05: agent viết FILE: hinhtron.png và chat chỉ nhận “(không gửi được tệp: hinhtron.png)”. Từ nay dòng FILE: trỏ vào png, jpg, jpeg hay webp trong workspace đi bằng sendPhoto.

Lượt do server giữ, tab chỉ xem

Trước đây lượt web sống nhờ chính kết nối SSE của tab gửi: đóng tab hay đổi cuộc trò chuyện là lượt dừng giữa chừng, và tab mở sau không thấy gì cho tới khi lượt xong. Từ nay server tự đọc lượt tới hết (turn_host.py); luồng SSE chỉ là một góc nhìn. Mọi lượt đi qua tracked() đều phát sự kiện vào hub.turns (activity/turn_watch.py), nên lượt của Telegram và job cũng xem được qua GET /api/conversations/{id}/turn. Chữ đang viết chỉ nằm trong bộ nhớ: restart là mất, và nhật ký cuộc trò chuyện lại là toàn bộ sự thật.

Tin gửi lại không nói hai lần

Tab gửi tin, server đã nhận và chạy lượt, nhưng câu trả lời rớt trên đường về: chữ quay lại ô nhập như mọi lần gửi không nghe thấy gì, và người dùng bấm gửi lần nữa. Trước đây đó là hai tin và hai lượt. Từ nay mỗi lần gửi từ web mang một request_id (32 ký tự hex, web/src/lib/request-id.ts); server lưu tên đó trong cùng một commit với tin mà nó đặt tên (bảng message_requests, hoặc cột queued_messages.request_id khi tin còn xếp hàng), nên không thể nhớ tên mà mất tin hay giữ tin mà quên tên. Gửi lại cùng tên thì không lưu gì và không chạy gì: tin còn xếp hàng được đáp lại đúng sự kiện queued cũ (cùng item_id, vị trí hiện tại); tin đã vào nhật ký được đáp bằng khung watching rồi lượt đang chạy, hoặc running: false nếu lượt đã xong. Tên theo từng cuộc trò chuyện và còn nguyên sau khi restart. Lần gửi bị từ chối (409, 422, 429) hay tin bị Dừng rút khỏi hàng thì tên chưa dùng, gửi lại là tin mới. POST /api/inbound không nhận request_id.

Tên của lần gửi không nghe hồi âm được giữ cho tới khi trang hiện tin ấy là đã nói (web/src/lib/send-names.ts). Người dùng chỉ biết điều trang cho thấy: chừng nào trang còn nói gửi lỗi và trả chữ về ô nhập, cùng những chữ ấy gửi lại là cùng một tin và đi dưới cùng một tên. Khi cuộc trò chuyện đã hiện tin đó, ngay trong lần vẽ ấy trang rút những chữ nó đã trả về ra khỏi ô nhập (trừ khi người dùng đã sửa chúng) và buông tên; cùng những chữ ấy gõ lại sau đó là tin mới với lượt riêng, như khi đọc xong câu trả lời rồi gõ “tiếp tục” lần nữa.

Bản đầu của quy tắc này suy từ các run: buông tên khi tab thấy lượt mà tin có thể đã nhận kết thúc. Nó sai ở đúng trường hợp chính. Tab nghe tin run của mình xong và tin lần gửi hỏng gần như cùng lúc, buông tên trong khi trang vẫn nói gửi lỗi và vẫn giữ chữ trong ô, nên bấm gửi lại thì tin đi dưới tên mới và server chạy lượt thứ hai. Self-test trên trình duyệt thật bắt được lỗi này (15 trong 358 phép kiểm, ở cả ba bề rộng); các test đơn vị lúc đó thì không, vì hai tin ấy chỉ cách nhau vài mili giây bên trong trang. Quy tắc mới không nhìn run nào nữa.

Server không nói tên nào nhận lượt nào, nên “đã hiện” là một phép đếm: số tin của người dùng có đúng những chữ ấy trong thread trên màn hình, kể cả bong bóng tạm của chính tab và các chip đang xếp hàng, so với con số lúc tên đi lần đầu. Đếm được nhiều hơn tức là tin đã có mặt. Lúc tên đi mà thread đã lưu của cuộc trò chuyện chưa lên màn hình (lần tải mở đầu chưa về) thì không có con số gốc, và tên ấy không bao giờ bị coi là đã hiện. Mỗi cuộc trò chuyện giữ một tên, của lần gửi không hồi âm gần nhất ở đó; gửi chữ khác vào cuộc ấy thì buông, gửi sang cuộc khác thì không. Lần gửi đang trên đường giữ ô nhập của nó (holdDraft trong web/src/hooks/use-draft.ts, gọi từ use-send-lock.ts): trong lúc đó trang không rút chữ và không buông tên, để lần gửi ấy lấy đúng tên cũ. Không có hạn theo thời gian. Giữ tên quá lâu tốn một lần gửi bị nuốt, buông quá sớm thì agent làm việc hai lần, nên chỗ không chắc đều nghiêng về giữ.

Lượt dang dở được nối lại sau khi server khởi động lại

Server tắt giữa một lượt (kickstart, crash, mất điện) từng để lại run interrupted và một câu hỏi không ai trả lời cho tới khi người dùng nhắn tiếp. Từ nay server khởi động xong thì tự nối lại lượt đó, đúng một lần: run cũ được mở lại (cùng id, cùng timeline, cùng số tiền đã tiêu), được đánh dấu resumed và lưu ngay trước khi lượt bước bước đầu tiên, nên một lượt làm sập server sẽ không được nối lần thứ hai. Lượt nối lại vẫn nằm dưới trần chi phí của cuộc trò chuyện: đã hết ngân sách thì không nối, đang nối mà chạm trần thì dừng như mọi lượt. Tool call chưa có kết quả lúc server tắt được xử theo chính sách của tool: tool khai replay_safe (chỉ đọc) được gọi lại; tool không khai gì coi như có đổi trạng thái, call được đóng bằng một kết quả nói nó bị cắt và không rõ đã chạy hay chưa, để model tự kiểm rồi quyết. Call chưa kịp chạy (còn phải hỏi người, tool không tồn tại, đối số hỏng) được lượt xử như thường lệ. Chỉ server có lịch (schedule=True) mới tự nối; Dừng do người bấm, lượt lỗi hay bot Telegram khởi động lại khi server vẫn chạy thì đóng run như cũ.

Trí nhớ đổi theo lượt đứng cạnh tin mở lượt

Tóm tắt cuộc trước và ghi chú hôm qua, hôm nay từng khép system prompt, nên một dòng memory_save đổi văn bản đứng trước cả cuộc trò chuyện và provider bỏ cache của mọi thứ đã nói (đo trên bộ cài thật: 15,1% token prompt được tính giá cache ở lần gọi ngay sau memory_save, so với 85,5% ở các lần khác). Nay chúng được lưu cùng tin mở lượt, trong cột messages.turn_notes, và model đọc chúng trong một khối có khung ngay trước tin đó. Mỗi tin chỉ mang phần đã đổi so với lần gần nhất cuộc trò chuyện được kể: trọn mục lần đầu, các dòng mới của ghi chú chỉ dài thêm, bản mới của mục bị viết lại, một dòng cho mục đã trống. System prompt chỉ còn dòng ngày là đổi, và lịch sử là một prefix chỉ dài thêm.

Tuyến leo thang cho lượt bị kẹt

Một agent có thể nêu escalation_route trong agent.yaml: một provider:model mà lượt của nó chỉ chuyển sang khi bị kẹt — bộ chặn lặp sắp dừng lượt, hoặc một lần gọi model hỏng trên mọi tuyến chính trước khi hiện chữ nào. Tắt theo mặc định, không thừa hưởng, mỗi lượt chuyển tối đa một lần và lượt sau quay về tuyến chính. Việc chuyển phát event escalated, web hiện dòng báo và run có step escalation.

Máy chủ MCP

Agent dùng được tool của các máy chủ MCP ở xa. Máy chủ khai trong mcp_servers của config.yaml, agent nêu tên máy chủ trong mcp:, và mỗi tool vào sổ đăng ký dưới tên mcp__<máy chủ>__<tool>. Mọi tool MCP hỏi trước khi chạy và không được gọi lại sau restart, trừ những tool chủ ghi vào read_only. Tệp cấu hình không giữ khoá; đăng nhập OAuth chỉ bắt đầu từ chính máy chạy crew. Chi tiết ở tools.md. Mọi test nói chuyện với một máy chủ MCP giả trong tiến trình (tests/mcp_fakes.py), không test nào ra mạng.

Tool của máy chủ MCP mặc định không được khai cho model ở mỗi lời gọi. Agent giữ tool như thế được giao tool_search để tìm bằng vài từ khoá; tool tìm thấy được khai từ lời gọi model kế tiếp, và cái đã nạp được đọc lại từ chính hội thoại nên còn nguyên sau restart (tools.md).

Gọi tool từ script (tool_script)

Agent giữ ít nhất một tool script gọi được (một tool có sẵn chỉ đọc, hoặc một tool MCP nằm trong read_only và không hidden) được giao tool_script, có máy chủ MCP hay không. Model viết một đoạn script ngắn; script chạy trong một tiến trình con, tự gọi các tool chỉ đọc, và chỉ những gì nó in ra mới quay về cho model (tools.md). Agent bật script_calls: gated thì script của nó gọi được cả tool có ghi, mỗi lời gọi qua cổng duyệt trước khi chạy; phần đó có mục riêng ở dưới.

Master được kể mỗi agent đang có gì

Dưới dòng của mỗi agent trong roster (“Đội của bạn”), system prompt của master mang các dòng hệ thống tự đọc từ đội đang chạy mỗi lần lắp prompt: máy chủ MCP kèm mức với tới, kỹ năng, job đang bật, công cụ, model và model leo thang. Không lưu ở đâu, nên thứ một agent vừa được thêm hay vừa mất có mặt ngay ở lần gọi model kế tiếp (agents.md).

Trình duyệt của agent

Agent có browser: read hay act trong tệp của nó, trên máy đã bật browser_enabled, giữ các tool browser_*: một Chromium riêng cho mỗi agent, tab thuộc về cuộc trò chuyện, trang được kể thành dàn ý có ref giữa hai dòng rào, và mọi kết nối của trình duyệt đi qua một proxy chỉ cho tới địa chỉ công khai. Chi tiết ở tools.md. Test chạy trên chính Chromium mà gói Playwright ghim (CI cài nó trước khi chạy pytest; trên máy mình thì chạy uv run playwright install chromium một lần, thiếu nó các test này đỏ chứ không bị bỏ qua), với các trang do máy chủ giả trên loopback phục vụ (tests/browser_servers.py); cổng của máy chủ giả được miễn rào mạng theo từng cổng, chỉ trong test. Không test nào ra mạng. tests/browser_tools.py dựng bộ tool cho một agent, tests/browser_crew.py dựng một đội có agent giữ trình duyệt.

Desktop của agent

Agent có computer: "on" trong tệp của nó giữ bốn tool computer_*, nói chuyện với trình trợ giúp desktop qua một Unix socket; chi tiết ở tools.md. Không test nào chạm vào màn hình thật hay hỏi macOS một quyền nào. Phía server, pytest nói chuyện với một trình trợ giúp giả (tests/desktop_helper.py): một server Unix socket trả lời các lệnh theo trạng thái test đặt và giữ mọi yêu cầu nó nhận, với socket trong một thư mục ngắn riêng dưới /tmp vì đường dẫn thư mục của pytest dài quá 103 byte. tests/desktop_tools.py dựng bốn tool của một agent trên trình trợ giúp giả ấy và tách phần trong rào khỏi phần ngoài. Phía trình trợ giúp, gói Swift desktop-helper/ có bộ XCTest riêng ở desktop-helper/Tests, chạy bằng scripts/desktop-helper-check.sh (thêm tên một lớp test để chỉ chạy lớp đó, ví dụ scripts/desktop-helper-check.sh SocketServerTests); test chỉ tới máy qua một hệ thống giả (FakeSystem.swift), nên không bao giờ bật hộp thoại. CI chạy bộ này ở job desktop-helper trên macOS; ./scripts/gates.sh không chạy nó, nên ai sửa desktop-helper/ chạy script ấy trên máy Mac trước khi đẩy. Phía web, web/src/test/fake-desktop.ts giữ một bộ quy tắc của các route desktop mà cả server giả của vitest lẫn mock của Playwright dùng.