Lộ trình hango: Cấu tạo của một agent harness và thứ tự xây dựng
Đây là tài liệu chính của hango. Tài liệu giải thích một agent harness (bộ khung chạy agent) gồm những phần nào, vì sao mỗi phần tồn tại, nên xây các phần theo thứ tự nào, và mỗi bước mở đường cho bước sau ra sao. Seam (điểm nối) là chỗ được chủ ý để ngỏ, ví dụ một interface, một bộ hằng số hay một quy ước đặt tên, để bước sau cắm vào mà không phải viết lại code trước đó. Hợp đồng chi tiết của từng bước nằm trong plans/260922-0123-hango-agent-harness-roadmap/phase-NN-*.md và được tinh chỉnh ngay trước khi bước đó được làm. Mỗi bước kết thúc bằng một binary chạy được, đã có test, cùng một bài học trong docs/learning/.
Nguồn của thiết kế: goclaw, dự án tham chiếu (một gateway Go multi-tenant chạy thật), langchain deepagents (lõi harness ghép từ các middleware), và các báo cáo nghiên cứu trong plans/reports/. Các quyết định đã chốt về stack nằm ở docs/tech-stack.md.
1. Một harness gồm những gì
Agent harness là bộ máy bao quanh một mô hình ngôn ngữ. Nó biến việc “gọi mô hình” thành “chạy một agent an toàn, lặp lại được, cho nhiều người dùng, và biết chuyện gì đã xảy ra”. Mô hình chỉ là phần nhỏ nhất. Harness gồm các lớp sau:
| Lớp | Câu hỏi mà lớp này trả lời | Package hango |
|---|---|---|
| Transport (đường truyền) | Client nói chuyện với ta và nhận luồng trực tiếp bằng cách nào? | internal/server (net/http, SSE) |
| Danh tính và tenancy | Ai đang gọi, thuộc tenant nào, được làm gì, và mỗi dòng dữ liệu được giới hạn theo tenant ra sao? | internal/auth, internal/store |
| Provider adapter | Làm sao nói chuyện với Anthropic, OpenAI hay bất kỳ bên nào khác qua một mô hình chuẩn hóa duy nhất? | internal/provider |
| Mô hình message | Message, content block, tool call, stream event là gì, và được lưu ra sao? | internal/provider (kiểu dữ liệu), internal/store |
| Agent loop | Nghĩ, làm, quan sát: gọi mô hình, chạy các tool call, đưa kết quả về, dừng khi có stop reason. | internal/agent |
| Tool | Mô hình tác động lên thế giới bằng cách nào, và lỗi quay về dưới dạng dữ liệu thay vì làm sập chương trình ra sao? | internal/tool |
| Run và lập lịch | Một request trở thành job bền vững thế nào, để sống sót khi mất kết nối, hủy được, và drain khi shutdown? | internal/run |
| Middleware | Các tính năng móc vào loop mà không phải fork nó thế nào: system block từ PromptContributor, chèn tool, bọc lời gọi mô hình và tool? | internal/agent |
| Quản lý context | Giữ cuộc hội thoại trong cửa sổ context thế nào: offload, cắt bớt, tóm tắt? | middleware trong internal/agent |
| Virtual filesystem | Một lớp lưu trữ chung chứa file, kết quả tool lớn, memory, skill và lịch sử bị đẩy ra. | internal/fs |
| Subagent | Agent giao việc cô lập cho agent con và nhận về kết quả gọn thế nào? | middleware + lane trong internal/run |
| Memory và skill | Kiến thức được giữ qua các session ra sao, và hướng dẫn được tiết lộ dần dần thế nào? | các prefix dành riêng trong internal/fs/path.go |
| Human in the loop (con người trong vòng lặp) | Một run tạm dừng để chờ duyệt rồi chạy tiếp từ trạng thái đã lưu thế nào? | middleware + internal/run |
| Usage và quota | Mỗi tenant đã dùng bao nhiêu, tốn bao nhiêu tiền, và dừng họ ở đâu? | internal/usage |
| Đánh giá (evaluation) | Một thay đổi về prompt, middleware hay mô hình làm agent tốt lên hay tệ đi ở các tác vụ thật? | hango eval trong cmd/hango, bộ tác vụ trong evals/ |
| Observability (khả năng quan sát) | Chính xác chuyện gì đã xảy ra, theo từng tenant, từng run, từng span? | internal/telemetry, có mặt ở mọi nơi |
Hiện chỉ có internal/app, internal/config, internal/server và internal/telemetry. Các package còn lại sẽ có trong các giai đoạn ở mục 3. Xem system-architecture.md để biết phần nào đã được xây.
Lớp observability được liệt kê cuối cùng nhưng lại được xây đầu tiên. Mọi lớp khác đều được thiết kế để có thể nhìn thấy qua nó.
flowchart TB
C[Client] -->|POST run, GET SSE| S[server: net/http + auth + tenancy]
S --> R[run: durable runs, lanes, broadcaster]
R --> L[agent loop + middleware chain]
L --> P[provider: Anthropic, OpenAI adapters]
L --> T[tool registry + builtins]
L --> F[fs Backend: files, offload, memory, skills]
L --> U[usage: tokens, cost, quotas]
S --> DB[(Postgres: tenant_id on every table)]
R --> DB
F --> DB
U --> DB
O[telemetry: traces, metrics, logs] -.-> S & R & L & P & T & F & U
O -->|OTLP| COL[OTel Collector] --> G[Grafana LGTM] & LF[Langfuse]
2. Nguyên tắc thiết kế áp dụng cho mọi bước
- Observability là nền móng. Một tracer, một meter và một logger provider của OpenTelemetry ngay từ giai đoạn 1. Log đi qua cầu nối OTel slog nên mỗi dòng log mang theo trace ID và span ID. Chỉ có một đường xuất OTLP tới collector, rồi collector chia ra Grafana LGTM (trace vào Tempo, log vào Loki, metric vào Prometheus) và Langfuse (trace ở mức LLM, generation, token, chi phí). Mỗi giai đoạn sau đều khai báo các span, attribute, metric và trường log mà nó thêm vào, và bài học của giai đoạn đó có một phần “cách debug lớp này”.
- Tenant scoping có sẵn từ cách xây. Mọi bảng thuộc về tenant đều có
tenant_id uuid NOT NULLvới index bắt đầu bằng tenant. Query đi qua một scoped store dựng từ request context, và từ chối thẳng khi thiếu tenant. Mỗi bảng có một test đọc chéo tenant. Trong cùng một tenant, thành viên chỉ thấy và sửa những gì mình tạo; owner và admin thấy tất cả, được kiểm soát theo từng route. Row-level security đến muộn, làm lớp chặn phía sau, không bao giờ là cơ chế chính. - Run bền vững, không gắn chặt với request. Một run là một dòng dữ liệu có chủ sở hữu và một lease. Worker nhận nó bằng một lệnh cập nhật có điều kiện, gửi heartbeat, rồi thực thi. SSE đăng ký vào nhật ký sự kiện đã lưu của run và có thể tiếp tục từ event ID cuối cùng. Khi khởi động lại, các lease hết hạn được thu hồi và run chạy tiếp từ lượt (turn) hoàn tất gần nhất. Nhờ vậy hủy, drain khi shutdown, phục hồi sau sập và human-in-the-loop đều làm được mà không phải thiết kế lại.
- Một loop, mở rộng bằng middleware. Khi loop đã có, không tính năng nào sửa trực tiếp vào nó nữa. Lập kế hoạch, filesystem, subagent, tóm tắt, memory, skill và duyệt đều là middleware trong một slice có thứ tự. Hai bản cài đặt loop song song của goclaw là ví dụ cần tránh.
- Mọi thứ đều là filesystem. Kết quả tool lớn, lịch sử bị đẩy ra, memory và skill đều chạy trên cùng một interface
Backend. Xây interface đó một lần là mở khóa được bốn tính năng. - Audit mọi thứ. Mỗi API request có thay đổi dữ liệu và mỗi quyết định duyệt đều ghi một dòng audit chỉ thêm vào, mang theo actor, hành động, đối tượng, request ID và trace ID. Dòng audit, sự kiện run, span và log đều nối với nhau qua trace ID, nên “ai đã làm gì, và sau đó chuyện gì xảy ra” chỉ là một query.
- Lỗi là dữ liệu. Một tool bị lỗi trả về tool result có gắn cờ lỗi. Mô hình tự phục hồi. Loop không bao giờ panic và không bao giờ giấu lỗi khỏi trace.
- Chính sách nằm trong mô tả tool. Không có system prompt dài do con người soạn. Prompt luôn ngắn và động; mỗi tool tự mang chính sách sử dụng của nó.
- Ít dependency, SQL tường minh, không framework.
net/httpcủa thư viện chuẩn, pgx với các query viết trong file SQL và do sqlc sinh code, goose, SDK chính thức của Anthropic đặt sau interface của riêng ta, OpenTelemetry. Không dùng thứ gì che mất loop, các query hay tenancy.
3. Thứ tự xây dựng và lý do
Mỗi mục nói bước đó mang lại gì, vì sao đến lúc này, và nó mở ra điểm nối (seam) nào cho các bước sau. Ước lượng công sức nằm trong plan.
Các mục được đánh số theo giai đoạn, nhưng thứ tự xây là 1 → 3 → 2 → 2.1 → 4 → 5 → … → 13 → 18 → 19 → 14 → 15 → 16 → 20 → 17. Giai đoạn 3 chỉ phụ thuộc giai đoạn 1, nên một mô hình thật được stream vào Tempo và Langfuse trước khi bắt đầu việc với database. Giai đoạn 5 dừng hai lần để review: 5a cho tool và loop với provider giả, 5b cho tính bền vững. Giai đoạn 18 và 19 đến ngay sau giai đoạn 13, vì những người dùng đầu tiên là gia đình của chủ dự án và họ cần một ứng dụng chat trước khi làm dashboard và hardening. Giai đoạn 20 đến sau giai đoạn 16, để các bảng của nó bắt đầu với row-level security.
- Foundation và observability spine. Module, composition root, cấu hình bằng env, server
net/http, health và readiness, graceful shutdown, Makefile, lint, Dockerfile distroless, compose với Postgres, OTel Collector và Grafana LGTM, Langfuse nối qua collector. Vì sao bây giờ: mọi bước sau phải quan sát được từ lần chạy test đầu tiên. Seam: các hằng số tronginternal/telemetrycho attribute tenant, agent, session, run và turn mà mọi span sau này dùng. - Persistence và tenancy core. Pool pgx có tracing, migration nhúng sẵn, tenant, user, API key, audit event, scoped store, auth middleware với phân quyền theo từng route, API key admin khởi tạo dùng một lần, admin API, audit middleware, và một database testcontainers riêng cho mỗi package để chạy integration test. Vì sao bây giờ: thêm tenancy về sau để lại dấu vết vĩnh viễn, như goclaw cho thấy. Seam: mẫu scoped store mà mọi bảng sau phải theo, và mẫu test chéo tenant.
- 2.1 Chuyển store sang sqlc. Các query của giai đoạn 2 chuyển sang
internal/store/pg/queries/*.sql, và sqlc sinh code Go chạy chúng. Hành vi và interface không đổi. Vì sao bây giờ: store có năm file query, và giai đoạn 4 sẽ sao chép bất cứ mẫu nào nó tìm thấy. Seam: mỗi bảng sau này thêm một file query thay vì viết tay các đoạn scan.
- 2.1 Chuyển store sang sqlc. Các query của giai đoạn 2 chuyển sang
- Provider abstraction và Anthropic adapter. Mô hình chuẩn hóa cho message, block và stream event; interface
Provider; Anthropic adapter với gom stream, trả lại thinking, stop reason, retry. Chat span với attribute GenAI và metric token. Vì sao bây giờ: loop cần một provider, và mô hình chuẩn hóa phải được thiết kế sao cho adapter thứ hai có thể tự dựng lại khung block. Seam: mô hìnhEventmà lớp SSE sau này chuyển tiếp nguyên vẹn. - Agent, session, message. Agent theo tenant do user tạo, session, message chỉ thêm vào với số thứ tự. Vì sao bây giờ: loop cần chỗ để đọc và nối lịch sử. Seam: việc đánh địa chỉ theo từng message mà compaction và subagent dựa vào.
- Tool, agent loop, run bền vững. Interface tool với kết quả tách đôi, registry, hai builtin an toàn, loop nghĩ-làm-quan sát với giới hạn số vòng và sửa tool call bị bỏ lửng, bảng
runs, worker pool với tuần tự hóa theo session, endpoint tạo run và poll. Span gốcinvoke_agent, spanexecute_tool, metric của run, trace ID lưu trên run. Vì sao bây giờ: loop là trái tim; chạy nó như một job bền vững từ ngày đầu tránh phải viết lại sau này. Seam: mô hình sự kiện của run mà phần streaming sẽ đăng ký tiếp theo. - Streaming, hủy, drain. Sự kiện run được lưu, broadcaster trong bộ nhớ, endpoint SSE có resume, keepalive, endpoint hủy, các lane của scheduler, drain khi shutdown, goroutine có phục hồi panic, test rò rỉ goroutine. Vì sao bây giờ: harness trở nên dùng được tương tác, và ngữ nghĩa shutdown được chốt trước khi tính năng nhân lên. Seam: các lane mà subagent dùng lại.
- Middleware chain và công cụ lập kế hoạch. Các interface hook, chuỗi có thứ tự, loop được refactor để chạy trên chuỗi, middleware
write_todos, phát hiện gọi lặp như một middleware. Vì sao bây giờ: mọi tính năng sau đều là middleware; chuỗi phải có trước cái đầu tiên. Seam: bộ hook và cơ chế prompt-contributor. - Filesystem backend và offload kết quả lớn. Interface
Backend, kho lưu trên Postgres chia namespace theo tenant và agent, các file tool, kiểm tra đường dẫn, offload kết quả tool quá lớn ra file. Vì sao bây giờ: đây là lớp phòng thủ context đầu tiên và là nền cho memory, skill và offload lịch sử. Seam: các prefix dành riêng mà memory sẽ dùng. - Subagent. Tool
task, spec subagent trên agent, mặc định là general-purpose, lịch sử agent con tách biệt, trả về kết quả gọn, giới hạn độ sâu, lane cho subagent, span agent lồng nhau. Vì sao bây giờ: cần có loop, tool, filesystem và lane. Seam: mẫu child-run được dùng lại khi chạy tiếp sau duyệt. - Quản lý context. Ước lượng token, cắt bớt tham số của tool, tóm tắt không làm thay đổi dữ liệu gốc, offload lịch sử, retry khi tràn, các điểm ngắt prompt-cache. Vì sao bây giờ: các session thật giờ đã vượt cửa sổ context; cần việc đánh địa chỉ message và backend. Seam: sự kiện compaction và điểm ngắt cache mà việc đặt memory phụ thuộc vào.
- Memory dài hạn và skill.
/memories/và/skills/là các prefix dành riêng trên cùng một backend, chènAGENTS.mdsau điểm ngắt cache, tiết lộ dầnSKILL.md. Vì sao bây giờ: gần như tầm thường khi đã có backend và compaction. Seam: quy ước memory mà người dùng có thể mở rộng. - Provider tương thích OpenAI và provider registry. Adapter tự dựng khung block và gom tham số tool theo chỉ số, một provider registry với hàm tra cứu credential được tiêm vào và lấy từ cấu hình server, chọn provider và mô hình theo từng agent. Vì sao bây giờ: kiểm chứng phần trừu tượng của giai đoạn 3 khi sửa còn rẻ. Seam: hàm tra cứu credential mà tính năng tự mang API key về sau sẽ cắm vào, và registry mà bảng giá usage dựa vào.
- Usage, quota, rate limit. Bản ghi usage theo từng lượt kèm cache token, bảng giá, quota theo tenant, rate limit theo từng key, attribute chi phí đưa vào Langfuse. Vì sao bây giờ: cần cả hai provider thì mới có ý nghĩa. Seam: dữ liệu mà các dashboard đọc.
- Dashboard, cảnh báo, runbook debug. Dashboard Grafana được provision sẵn, liên kết trace với log, quy tắc cảnh báo, quy ước Langfuse, và một runbook trọn vẹn từ ID của một run lỗi đến đúng span, dòng log và tool call. Vì sao bây giờ: mọi tín hiệu đã có; bước này biến chúng thành câu trả lời. Seam: mô hình vận hành cho production.
- Human in the loop. Cấu hình ngắt theo từng tool, trạng thái run
awaiting_approval, các tool call đang chờ được lưu lại, endpoint duyệt, chạy tiếp với span link. Vì sao bây giờ: nó buộc loop phải là một hàm từng bước có thể chạy tiếp, điều mà mọi bước trước đã chuẩn bị. Seam: mẫu duyệt cho các hệ thống phân quyền sau này. - Hardening cho production và RLS backstop. Row-level security với role không phải owner, review bảo mật, test tải và soak, đo rò rỉ goroutine, CI có Postgres, bản build release có đóng dấu, hướng dẫn deploy, tài liệu kiến trúc cuối cùng. Vì sao bây giờ: gia cố một hình dạng đã hoàn chỉnh.
- Vòng đánh giá. Một bộ tác vụ cố định trong
evals/, lệnhhango evalchạy từng tác vụ qua các run thật và chấm điểm bằng các kiểm tra xác định, điểm và dataset run trong Langfuse gắn vào trace của mỗi run, vàhango eval comparegiữa hai lần chạy bộ tác vụ. Vì sao đặt cuối: cần có mọi tính năng mà nó đo, và nó là công cụ cho mọi thay đổi sau v1. Seam: bộ kiểm thử hồi quy cho hành vi của agent. - Kênh Telegram. Một bot Telegram dùng long polling, chỉ chat riêng, một bản đồ tĩnh từ user Telegram sang user và agent của hango, câu trả lời được stream bằng cách sửa một tin nhắn rồi hoàn tất thành Telegram HTML chuyển từ Markdown, các lệnh
/new,/cancel,/whoamivà/helpđăng ký làm menu của bot, xử lý rate limit và xung đột, và việc tạo run dùng chung với HTTP handler. Vì sao bây giờ: những người dùng đầu tiên là người nhà không gọi HTTP API; cần có streaming (6) và quota (13) để một cuộc chat không làm hóa đơn API tăng vọt. Seam: việc tạo run là một hàm mà kênh nào cũng gọi được, web chat sẽ dùng lại sau. - Runtime Claude Code CLI cho chủ dự án. Các agent có
provider = "claude_cli"chạyclaude -pthật như một tiến trình con bằng gói đăng ký của chủ dự án, với các tool của hango được mở ra qua một cầu MCP loopback, kiểm tra chỉ-chủ-dự-án khi lưu agent, tạo run và nhận run, và usage không tính vào quota của tenant. Vì sao bây giờ: nó dùng lại lane (6), offload tool (8) và tính usage (13), và chỉ hữu ích khi đã có một kênh. Seam: một runtime thứ hai bên cạnh loop riêng của hango, cho thấy harness mất đi những khả năng quan sát nào khi giao loop cho một chương trình khác. - Media, pairing và duyệt trên Telegram. Ảnh và album thành image block, tài liệu lưu vào filesystem, tin nhắn thoại được phiên âm (gồm cả tiếng Việt), gửi file ngược lại bằng
attach_file, mã ghép đôi (pairing code) thay cho việc sửa cấu hình, nút duyệt được ký bằng HMAC, và phản ứng trạng thái. Vì sao bây giờ: nút duyệt cần giai đoạn 15, và các bảng mới nên bắt đầu với row-level security từ giai đoạn 16. Seam: image block của provider và sự kiện runattachment, mà web chat sẽ dùng lại.
4. Khi có sự cố thì nhìn ở đâu
Cây phân cấp span chính là tấm bản đồ. Một run tạo ra một trace: invoke_agent {agent} ở gốc, một span chain cho mỗi lượt, một client span chat {model} cho mỗi lần gọi provider, một span execute_tool {tool} cho mỗi tool call, và một invoke_agent lồng nhau cho mỗi subagent. Mọi span đều mang các attribute tenant, agent, session, run và turn. Mọi dòng log đều mang trace ID và span ID. Dòng run lưu trace ID và trả nó về từ endpoint của run.
| Triệu chứng | Nơi đầu tiên cần xem | Sau đó |
|---|---|---|
| Run lỗi hoặc bị kẹt | Dashboard runs của Grafana theo tenant và trạng thái | Trace trong Tempo theo trace ID của run |
| Mô hình trả lời tệ hoặc chi phí tăng vọt | Trace của run trong Langfuse: generation, token, chi phí | Attribute của chat span: mô hình, finish reason, cache token |
| Tool hoạt động sai | Span execute_tool với loại lỗi và thời lượng | Log trong Loki lọc theo trace ID |
| Provider báo lỗi hoặc bị throttle | Tỷ lệ lỗi provider và cảnh báo 429 | Các lần retry trên chat span |
| Client không thấy luồng stream | Span của SSE handler và dãy sự kiện của run | Số goroutine và số topic của broadcaster (hồ sơ rò rỉ chỉ chạy trong soak test) |
| Nghi ngờ lộ dữ liệu chéo tenant | Span của scoped store với attribute tenant | Bộ test chéo tenant, các policy RLS |
| Ai đã đổi agent, key hay quota này | Audit event của tenant, lọc theo đối tượng | Trace ID trên dòng audit để sang Tempo và Loki |
| Tràn context | Span event gen_ai.conversation.compacted | Chat span của bộ tóm tắt, file lịch sử đã offload |
Giai đoạn 14 viết bảng này thành một runbook đầy đủ, kèm các dấu hiệu lỗi và lời giải mẫu.
5. Mỗi bước được lên kế hoạch và bàn giao thế nào
- Trước khi một bước bắt đầu, file giai đoạn của nó được review và tinh chỉnh dựa trên code đang có. File giai đoạn là hợp đồng: yêu cầu, kiến trúc với bản phác interface và DDL, các file cần tạo, các bước đánh số, tiêu chí thành công kèm lệnh chính xác, và rủi ro kèm cách xử lý đã quyết sẵn.
- Bàn giao một bước gồm: cài đặt, test (có ít nhất một test chạy trên Postgres thật khi đụng đến store), build và vet và lint đều xanh, span và metric hiện trong Grafana và Langfuse khi liên quan, viết xong bài học, cập nhật tài liệu kiến trúc, tick các tiêu chí thành công trong file giai đoạn, tạo lại
plan.htmlbằnggo run build_plan_html.gotừ thư mục plan, rồi commit. - Bài học của mỗi bước bao gồm khái niệm, thiết kế của hango và lý do, goclaw làm thế nào, deepagents làm thế nào, hango khác ở đâu, và cách debug lớp đó.
- Cổng review: người dùng review binary, các test và bài học trước khi bước tiếp theo bắt đầu.
6. Ngoài phạm vi của lộ trình này
Cầu MCP (các MCP server làm phần cài đặt của tool), credential provider tự mang theo từng tenant (cần danh sách cho phép base URL và lưu trữ có mã hóa), tool phía server của Anthropic và việc chuyển nguyên block mờ mà chúng đòi hỏi, deploy nhiều replica (rate limiter dùng chung, registry run phân tán, phát sự kiện đến nhiều nơi), tool thực thi trong sandbox, các kênh chat khác ngoài Telegram, chat nhóm Telegram (thiết kế của goclaw được giữ ở giai đoạn 20), chế độ webhook của Telegram, giao diện web chat (dự kiến sau Telegram) và một trang web admin, cả hai viết bằng Svelte 5, một context inspector hiển thị và chỉnh sửa từng phần của một request model (làm sau trang web admin), một LLM gateway với quota theo từng người dùng, ứng dụng desktop, i18n, hỗ trợ nhiều provider hơn ngoài Anthropic và các provider tương thích OpenAI. Mỗi thứ là một phần mở rộng tự nhiên từ một seam ở trên và được ghi như việc làm tiếp trong plan.