Tool, agent loop, run bền vững
Tổng quan
Đây là lõi của harness. Thêm interface Tool với cách tách kết quả Result{ForLLM, ForUser, IsError}, một registry phát ra các tool definition cho provider, hai builtin tool an toàn, và vòng lặp think→act→observe với giới hạn số lần lặp, sửa các tool call bị treo (dangling) và biến lỗi thành tool result. Sau đó làm cho run bền vững (durable): một dòng runs được tạo bởi POST /v1/sessions/{id}/runs trả về 202, được thực thi bởi một worker pool chạy trong tiến trình và tuần tự hóa theo từng session, có thể theo dõi bằng cách poll GET /v1/runs/{id}. Chưa có streaming; đó là việc của phase 6.
Yêu cầu
Chức năng
- Interface
Tool: tên, mô tả, tham số dạng JSON Schema,Execute(ctx, args) Result. - Registry: đăng ký, tra cứu,
ProviderDefs(allowlist []string) []provider.ToolDef, và một view đã lọc theo từng agent từ allowlisttoolscủa agent (allowlist rỗng = toàn bộ tool đã đăng ký). - Builtin:
get_time(timezone?)vàhttp_fetch(url, max_bytes?)với allowlist host lấy từ config, timeout 5 giây và giới hạn cứng cho kích thước response. - Loop: dựng system prompt và history, gọi provider, đẩy các event vào một
provider.Accumulatorđồng thời chuyển tiếp chúng cho sink, lưu assistant message, thực thi các tool được yêu cầu, lưu các tool-result message, lặp lại khiStopReason == ToolUse; dừng ởHANGO_MAX_ITERATIONS(mặc định 20) với một message kết thúc giải thích về giới hạn đó. - Lỗi của tool (panic, error, timeout, tool không tồn tại, tham số không hợp lệ) trở thành
ToolResultBlock{IsError:true}và loop tiếp tục. - `pause_turn` không được resume. Adapter trả về `ErrUnsupportedStop`, loop làm run thất bại với lỗi đó, và message nêu tên stop reason. Không có bộ đếm resume.
- Tool call bị treo: trước mỗi lời gọi provider, nếu assistant message cuối có các block
tool_usemà không cótool_resulttương ứng, thì tổng hợp các tool result lỗi và lưu chúng. - Bảng
runsvới các trạng tháiqueued|running|succeeded|failed|cancelled;POST /v1/sessions/{id}/runschèn một dòng rồi đưa vào hàng đợi, trả về 202 kèm run;GET /v1/runs/{id}trả về state, lỗi, tổng usage và id của assistant message cuối cùng. - Mỗi session chỉ có một run thực thi tại một thời điểm; request thứ hai khi đang có run hoạt động sẽ được xếp hàng phía sau.
- **Quyền sở hữu và lease.** `runs` có `owner_id uuid` (instance tiến trình đã claim run) và `lease_expires_at timestamptz`. Worker chỉ nhận việc qua một conditional update — `UPDATE runs SET state='running', owner_id=$me, lease_expires_at=now()+$ttl, started_at=coalesce(started_at, now()) WHERE id=$1 AND state='queued' RETURNING *` — nên hai worker, hoặc một worker và một tiến trình vừa restart, không bao giờ cùng thực thi một run: chỉ có đúng một lệnh `UPDATE` trả về dòng. Worker gia hạn lease theo nhịp heartbeat mỗi `HANGO_RUN_HEARTBEAT_INTERVAL` (mặc định `lease_ttl/3`). Không có `MarkRunning` riêng: chính việc claim *là* bước chuyển sang `running`. `MarkTerminal`, `RequestCancel` và các chuyển trạng thái approval của phase 15 đều có điều kiện theo cả state hiện tại mong đợi **lẫn** `owner_id`, nên một tiến trình đã mất lease không thể ghi đè state của run mà người khác đang sở hữu.
- **Lập trường khi restart: requeue và resume tại ranh giới lượt (turn).** Bước reconciliation khi khởi động (và trong lần quét định kỳ) chỉ thu hồi các run có `lease_expires_at < now()`, chuyển `running → queued`, xóa `owner_id`, và để `runs.resume_turn` trỏ vào lượt hoàn tất cuối cùng (phase 7 thêm `runs.state_json` cho state của middleware, được ghi tại cùng ranh giới đó). Một run được requeue bắt đầu lại từ ranh giới lượt đó, không phải từ đầu: mọi thứ đã lưu đến ranh giới (assistant message, tool result) là history mà nó đọc lại, nên không lượt nào đã hoàn tất bị thực thi lại. Một lượt bị gián đoạn giữa chừng sẽ được chạy lại, nghĩa là **tool phải ghi rõ tính idempotent của mình**: mỗi builtin nêu trong Go doc việc thực thi lại có an toàn không, còn `http_fetch` (chỉ GET idempotent) và `get_time` thì hiển nhiên an toàn. Các tool không idempotent xuất hiện sau này phải được chặn bởi interrupt của phase 15 hoặc được làm idempotent bằng một key do người gọi cung cấp.
- `HANGO_DRAIN_TIMEOUT` phải tối thiểu bằng một lượt đầy đủ (mặc định ghi trong tài liệu là 120s) để lần tắt êm (graceful shutdown) dừng ở ranh giới lượt thay vì giữa lượt; giá trị ngắn hơn vẫn hợp lệ nhưng sẽ khiến các lượt bị chạy lại khi restart, và phần validate config sẽ cảnh báo về điều này.
- Enqueue được khử trùng lặp: hàng đợi trong bộ nhớ của scheduler dùng run id làm khóa, và việc enqueue lại một run đã ở trạng thái queued hoặc running là no-op, nhờ vậy approval resume, DB poller (phase 6) và reconciliation đều có thể enqueue cùng một run mà không đua nhau.
- Phân quyền run: một `member` chỉ được tạo, đọc và hủy run trên các session do chính họ tạo; `owner`/`admin` thao tác được trên mọi run trong tenant. Predicate lấy từ helper `visibility(scope)` dùng chung; bị từ chối khi đọc thì trả 404.
Phi chức năng
- Việc thực thi tool không bao giờ panic vào loop: mọi lời gọi
Executeđều được bọc bằng recover. - Mỗi tool call có timeout riêng lấy từ config; loop có một deadline tổng cho cả run.
- Worker không bao giờ giữ một kết nối database xuyên qua một lời gọi provider.
- Integration test dùng
provider.Fakecộng Postgres thật; không có mạng. - **Quy tắc phụ thuộc đã chốt: `internal/agent` không import gì từ `internal/run`.** Package agent định nghĩa các interface; `internal/run` cài đặt chúng. Cụ thể: `agent.EventSink` được định nghĩa trong `internal/agent` và được `run.Emitter` cài đặt; subagent lane gate (phase 9) là `agent.Gate func(ctx context.Context) (release func(), error)` được định nghĩa trong `internal/agent` và do `internal/app` cung cấp từ scheduler. Bất kỳ kiểu nào cần chiều ngược lại là một lỗi thiết kế, không phải lý do để nới lỏng quy tắc.
- Chữ ký của `Agent.Run` được chốt ở đây và không đổi nữa. Việc mở rộng diễn ra bên trong `RunOptions`, nên các bổ sung về depth và parent-sink của phase 9 chỉ là thêm field, không phải phá chữ ký. Các nơi gọi cần cập nhật khi chữ ký được đưa vào: `internal/run/worker.go` (nơi gọi production duy nhất), `internal/agent/loop_test.go`, `internal/run/scheduler_test.go`, và từ phase 9 trở đi là `internal/agent/mwsubagent.go`. Không có nơi nào khác.
Kiến trúc
// internal/tool/tool.go
type Tool interface {
Name() string
Description() string
Parameters() map[string]any // JSON Schema object
Execute(ctx context.Context, args json.RawMessage) Result
}
type Result struct {
ForLLM string // what the model sees
ForUser string // what a human/UI sees; falls back to ForLLM when empty
IsError bool
}
// Red team: F15 — Result.Meta was never read by any phase; the offload wrapper (phase 8)
// records its path on the span and in its own state, so the field is deleted.
func Errorf(format string, a ...any) Result // IsError: true
// internal/tool/registry.go
type Registry struct{} // concurrency-safe, built once at startup
func (r *Registry) Register(t Tool) error // rejects duplicate names
func (r *Registry) Get(name string) (Tool, bool)
func (r *Registry) View(allow []string) *View // per-agent filtered view
func (v *View) ProviderDefs() []provider.ToolDef
func (v *View) Call(ctx context.Context, call provider.ToolUseBlock, timeout time.Duration) Result // recovers panics// internal/agent/loop.go
type Agent struct {
Def store.Agent
Provider provider.Provider
Tools *tool.View
Store store.Scoped
Limits Limits // MaxIterations, ToolTimeout, RunDeadline // Red team: D4 — MaxResumes removed
}
type Status uint8 // StatusDone, StatusFailed, StatusCancelled // StatusAwaitingApproval added in phase 15
type Result struct {
Status Status
StopReason provider.StopReason
Iterations int
Usage provider.Usage
FinalMessage *store.Message
Err error
}
// Red team: F5 — final signature; later phases add fields to RunOptions, never parameters.
type RunOptions struct {
Depth int // 0 for a top-level run; phase 9 sets it for children
ParentSink EventSink // nil at depth 0; phase 9 wraps the parent's sink with depth labels
ResumeFrom int // turn index to resume at; 0 means start from history. Red team: D3
}
func (a *Agent) Run(ctx context.Context, sessionID uuid.UUID, input []provider.Block, emit EventSink, opt RunOptions) (Result, error)
// EventSink is defined here and implemented in internal/run (never the reverse). // Red team: F5
type EventSink interface { // phase 6 replaces the no-op implementation with the broadcaster-backed emitter
Emit(ctx context.Context, ev Event) error
}// internal/run/scheduler.go
type Scheduler struct{} // in-process; single replica (documented limitation)
func (s *Scheduler) Enqueue(ctx context.Context, r store.Run) error // per-session FIFO, lane "main"; dedup by run id
func (s *Scheduler) Shutdown(ctx context.Context) error // drain; phase 6 adds MarkDraining
// Red team: D3, F2 — the only way a worker starts executing a run.
// Returns (run, true) for exactly one caller; everyone else gets (zero, false).
func (s *RunStore) ClaimQueued(ctx context.Context, runID, ownerID uuid.UUID, lease time.Duration) (store.Run, bool, error)
func (s *RunStore) RenewLease(ctx context.Context, runID, ownerID uuid.UUID, lease time.Duration) error
func (s *RunStore) ReclaimExpired(ctx context.Context, limit int) (int, error) // running+expired lease -> queued-- migrations/00003_runs.sql
create table runs (
id uuid primary key,
tenant_id uuid not null references tenants(id) on delete cascade,
session_id uuid not null references sessions(id) on delete cascade,
agent_id uuid not null references agents(id) on delete cascade,
created_by uuid not null references users(id),
lane text not null default 'main',
state text not null check (state in ('queued','running','succeeded','failed','cancelled')),
owner_id uuid, -- Red team: D3 — process instance holding the run
lease_expires_at timestamptz, -- Red team: D3 — only an expired lease may be reclaimed
resume_turn int not null default 0, -- Red team: D3 — turn boundary a requeued run restarts at
input jsonb not null,
error text not null default '',
stop_reason text not null default '',
iterations int not null default 0,
usage jsonb,
final_message_id uuid references messages(id),
trace_id text not null default '',
span_id text not null default '',
cancel_requested_at timestamptz,
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz not null default now()
);
create index runs_tenant_session_created_idx on runs (tenant_id, session_id, created_at desc);
create index runs_state_idx on runs (state) where state in ('queued','running');
-- Red team: D3 — reclaim sweep and the phase-6 queued-run poller both read this index
create index runs_lease_idx on runs (state, lease_expires_at) where state = 'running';
create index runs_queued_created_idx on runs (state, created_at) where state = 'queued';Instrumentation
- Root span cho mỗi run:
invoke_agent {agent name}, kind INTERNAL, do worker tạo làm gốc của một trace mới (HTTP request đã tạo run là một trace riêng, được nối bằng span link, vì run sống lâu hơn request). Attribute:gen_ai.operation.name=invoke_agent,gen_ai.agent.name,gen_ai.agent.id,gen_ai.conversation.id=<session_id>,hango.tenant.id,hango.agent.id,hango.session.id,hango.run.id,hango.lane. - Attribute cấp trace của Langfuse trên root span:
langfuse.observation.type=agent,langfuse.trace.name=<agent name>,langfuse.session.id=<hango session_id>,langfuse.user.id=<run.created_by>,langfuse.trace.tags(agent slug, lane),langfuse.trace.metadata.tenant_id,...metadata.agent_id,...metadata.run_id. Chính những thứ này giúp định vị bất kỳ lỗi nào theo tenant và run trong giao diện Langfuse. - Turn span: một observation
chaincho mỗi lần lặp (attributehango.turn,langfuse.observation.type=chain), là cha của spanchatvà các spanexecute_toolcủa lượt đó. - Tool span:
execute_tool {tool}cho mỗi lời gọi vớigen_ai.operation.name=execute_tool,gen_ai.tool.name,gen_ai.tool.call.id,langfuse.observation.type=tool,error.typekhi lỗi, và input/output chỉ được ghi khi bậtHANGO_TRACE_LLM_CONTENT. - Metric (mọi tên đều khai báo trong
internal/telemetry/metrics_names.go):gen_ai.invoke_agent.duration,gen_ai.invoke_agent.inference_calls,gen_ai.invoke_agent.tool_calls,gen_ai.execute_tool.duration(attributegen_ai.tool.name), gaugehango.runs.by_state(attributestate,hango.tenant.id), gaugehango.run.queue_depth(attributehango.lane), counterhango.tool.errors(attributegen_ai.tool.name,error.type), histogramhango.run.iterations, counterhango.run.claims(attributeoutcome=won|lost) và counterhango.run.reclaimed. - Span event trên run root span:
run.claimed(kèm owner id và deadline của lease),run.lease_renewedở mức debug,run.reclaimed(kèm owner trước đó và lượt resume). - Dòng run lưu
trace_id(vàspan_idcủa root) đểGET /v1/runs/{id}trả về chúng, và người dùng có thể dán trace id thẳng vào Tempo hoặc mở trace trong Langfuse. - Trường log: worker cài một context logger gắn với
hango.tenant.id,hango.agent.id,hango.session.id,hango.run.id, nên mọi dòng log phát ra ở bất kỳ đâu trong loop đều lọc được theo run.
Các file liên quan
Tạo mới
internal/tool/tool.go,internal/tool/registry.go,internal/tool/schema.go(helper dựng JSON Schema object),internal/tool/registry_test.go.internal/tool/builtin/gettime.go,internal/tool/builtin/httpfetch.go, cùng các test (serverhttptestcho fetch).internal/agent/loop.go,internal/agent/prompt.go(ghép system prompt ngắn và động),internal/agent/repair.go(sửa tool call bị treo),internal/agent/loop_test.go.internal/run/scheduler.go,internal/run/worker.go,internal/run/registry.go(map các run đang hoạt động trong bộ nhớ),internal/run/scheduler_test.go.internal/agent/trace.go— helper span cho run/turn/tool và các instrument metric ở cấp agent.internal/agent/trace_test.go— test dùng span recorder trong bộ nhớ, assert phân cấpinvoke_agent→chain→chat/execute_toolvà bộ attribute của Langfuse.internal/store/pg/run.go,internal/store/pg/run_test.go.internal/server/handlers_runs.go.migrations/00003_runs.sql.internal/run/reconcile.go,internal/run/reconcile_test.go— lần quét thu hồi lease hết hạn.docs/learning/05-tools-loop-and-runs.md.
Sửa
internal/store/store.go,internal/store/types.go—Run,RunStore.internal/store/pg/tenancy_test.go— thêm các caserunschéo tenant và trong cùng tenant.internal/config/config.go—HANGO_MAX_ITERATIONS,HANGO_TOOL_TIMEOUT,HANGO_RUN_DEADLINE,HANGO_RUN_WORKERS,HANGO_HTTP_FETCH_ALLOWED_HOSTS,HANGO_MAX_QUEUED_RUNS_PER_SESSION,HANGO_RUN_LEASE_TTL(mặc định 90s),HANGO_RUN_HEARTBEAT_INTERVAL(mặc định lease/3),HANGO_RUN_RECLAIM_INTERVAL.internal/app/app.go— dựng registry, đăng ký builtin, dựng scheduler, khởi động worker, đăng ký shutdown cho scheduler.internal/server/server.go,internal/server/handlers_agents.go(validatetoolstheo registry),docs/system-architecture.md,docs/learning/README.md.
Các checkpoint review
Giai đoạn này chứa hai khái niệm, nên nó dừng lại hai lần để review. Số phase không đổi.| Checkpoint | Khái niệm | Các bước | Xong khi |
|---|---|---|---|
| 5a | Lõi harness | 1–6, cộng các test registry và loop từ bước 12 | Tool và loop chạy trong tiến trình với provider.Fake: các test loop, test tool bị panic và các test http_fetch đều qua |
| 5b | Độ bền (durability) | 7–11, 13, 14, cộng các test run từ bước 12 | Bảng runs, conditional claim, lease, heartbeat, reclaim, worker và endpoint hoạt động: các tiêu chí về claim race, reclaim, resume, phân quyền, audit và trace đều qua |
Các bước triển khai
- Viết
internal/tool/tool.govàschema.go.schema.Object(schema.Field("url", "string", "…", true), …)giữ các literal JSON Schema ra khỏi file tool. - Viết
registry.go.View.Calltra cứu tool, không unmarshal gì (tool tự sở hữu args của mình), áp timeout cho từng lời gọi quacontext.WithTimeout, bọcExecutetrong một hàm códefer recover()để biến panic thànhResult{IsError:true}kèm message của panic và một stack được ghi log. - Viết hai builtin.
http_fetchphân giải URL, từ chối scheme không phải http(s), từ chối host không có trong allowlist, từ chối IP riêng/loopback sau khi resolve (chống SSRF), stream tối đamax_bytes(mặc định 64KiB) và trả về body cùng status. - Viết
internal/agent/prompt.go: system prompt làsystem_promptcủa agent cộng, về sau, các fragment từ prompt contributor. Không có gì do hango tự viết. Trả về[]provider.Blockđể phase 10 có thể gắn cache control. - Viết
internal/agent/repair.go: với[]provider.Message, tìm các idtool_usekhông khớp trong assistant message cuối và tạo cácToolResultBlock{IsError:true, Content:"tool call interrupted"}tổng hợp. Lưu chúng qua message store trước lời gọi provider đầu tiên. - Viết
internal/agent/loop.go:- Nạp
History, chạy repair, thêm các input block của người gọi như một user message. KhiRunOptions.ResumeFrom > 0, nhảy tới ranh giới lượt đó thay vì thêm lại input. - Lặp: dựng
provider.Request(model, system, messages,Tools.ProviderDefs(), max tokens), gọiStream, đẩy mọi event vào mộtprovider.Accumulatorđồng thời chuyển tiếp cho sink, lưu assistant message cùng stop reason và usage. Khi gặp lỗi giữa chừng stream, lưuAccumulator.Partial()và làm lượt đó thất bại, không bao giờ im lặng bỏ đi những gì đã nhận được. - Nếu
StopReason == ToolUse: với mỗiToolUseBlock, gọiTools.Call, dựng một user message chứa tất cảToolResultBlocktheo đúng thứ tự yêu cầu, lưu nó, ghi lượt đã hoàn tất vàoruns.resume_turn, rồi tiếp tục. - Nếu
StopReason == PauseTurn: provider đã trả vềErrUnsupportedStop; làm run thất bại với lỗi đó. - Nếu
StopReason == ContextExceeded: hiện tại thất bại với một lỗi có kiểu; phase 10 biến nó thành nén context rồi thử lại. - Dừng khi gặp
EndTurn|MaxTokens|StopSequence|Refusal, khi chạm giới hạn số lần lặp, hoặc khi cóctx.Err(). - Cộng dồn usage qua các lượt.
- Nạp
- Viết
RunStore:Create,Get,ListBySession,ClaimQueued,RenewLease,MarkTerminal(...),RequestCancel,ReclaimExpired. Mọi thao tác ghi là một lệnhUPDATE ... WHERE id=$1 AND state=$expected AND owner_id=$me RETURNINGcó điều kiện duy nhất và trả vềErrStateConflictkhi không ảnh hưởng dòng nào; không phương thức nào đọc rồi mới ghi. - Viết
internal/run/scheduler.go: một worker pool có giới hạn gồmHANGO_RUN_WORKERSgoroutine đọc từ một lane queue, cộng một map FIFO theo từng session để mỗi session chỉ có một run đang chạy.Enqueuekhông chèn gì (handler đã tạo dòng rồi), khử trùng lặp theo run id, và đẩy run id vào hàng đợi. Giữ mộtregistrytrong bộ nhớ ánh xạ run id → hàm cancel. - Viết
internal/run/worker.go: lấy một run id ra, claim nó bằngClaimQueued(bỏ qua im lặng khi claim trả về false, vì người khác đang sở hữu), dựng lại tenant scope từ dòng đã claim (request context không còn nữa): nạp dòng user hiện tại của creator theorun.TenantID/run.CreatedByvà dựngstore.Scope{TenantID, UserID, Role}từ role hiện tại của họ. Khi creator đã bị xóa hoặcstatuskhông phảiactive,MarkTerminalrun thànhfailedvới lỗicreator_inactiverồi dừng; run không bao giờ thực thi dưới những quyền mà creator không còn có. Sau đó nạp agent, dựngAgent, khởi động heartbeat quasafego, thực thi vớiRunOptions{ResumeFrom: run.ResumeTurn}, vàMarkTerminalcó điều kiện theoowner_id. Phần kết thúc chạy trên một background context tạo bằngcontext.WithoutCancelđể một run đã bị hủy vẫn lưu được state dở dang. - Viết
internal/server/handlers_runs.go:POST /v1/sessions/{id}/runskiểm tra quyền truy cập session theo scope (giới hạn theo creator đối vớimember), tạo run, enqueue, trả về 202 +Location: /v1/runs/{id}.GET /v1/runs/{id}trả về run. Từ chối run mới khi session đã cóHANGO_MAX_QUEUED_RUNS_PER_SESSIONrun đang chờ. - Viết lần quét reconciliation (ticker
safegomỗiHANGO_RUN_RECLAIM_INTERVAL, cộng một lượt khi khởi động):ReclaimExpiredđưa các runrunningcó lease đã hết hạn vềqueued, xóaowner_id, giữ nguyênresume_turn, và enqueue lại chúng. Các run có lease còn hiệu lực được để yên, đó chính là điều làm cho rolling restart an toàn. - Test: registry trùng tên/panic/timeout; loop với
provider.Fakeđược kịch bản hóa thành (a) chỉ có text, (b) một tool call rồi text, (c) tool lỗi rồi phục hồi, (d) hết giới hạn số lần lặp, (e)pause_turnsinh raErrUnsupportedStopvà một run thất bại; integration test vòng đời run; test tuần tự hóa theo session; hai worker đua nhau claim một run queued: chỉ đúng một worker thực thi (assert bằng một fake provider có đếm); mộtMarkTerminaltừ owner đã cũ trả vềErrStateConflictvà không đổi dòng; một run bị kill giữa lượt được reclaim sau khi lease hết hạn và resume tại ranh giới lượt mà không lặp lại lượt đã hoàn tất; các caserunschéo tenant và trong cùng tenant. - Viết
internal/agent/trace.govà nối nó vào: worker bắt đầu root spaninvoke_agentvới một span link tới span context của request đã tạo (được lưu trên dòng run lúc tạo), lưutrace_id/span_idvào run, mở một spanchaincho mỗi lượt, và đường đi của tool mở các spanexecute_tool. Đăng ký các instrument metric ở cấp agent (tên lấy từinternal/telemetry/metrics_names.go) và cập nhậthango.runs.by_statecùnghango.run.queue_depthtừ scheduler. - Gọi `SetAuditAction(c, "run.create", "run", runID)` trong handler tạo run, và ghi agent id, session id và lane vào `detail`. Dòng audit được ghi sau khi handler trả về, nên nó mang cùng `trace_id` mà dòng run lưu, nhờ đó người review đi từ "ai đã bắt đầu run này" tới trace của chính run đó chỉ trong một bước.
- Cập nhật docs và tài liệu học.
Dàn ý tài liệu học
docs/learning/05-tools-loop-and-runs.md phải có:
- Khái niệm: vòng lặp think→act→observe, vì sao nó kết thúc, và stop reason thực sự điều khiển điều gì.
- Lựa chọn thiết kế: tách
Result.ForLLM/ForUser, lỗi thành tool result, recover panic tại ranh giới registry, tool view theo từng agent, giới hạn số lần lặp, sửa tool call bị treo, dòng run bền vững + 202 + poll, và lease sở hữu. - Quyền sở hữu, lease và resume tại ranh giới lượt: vì sao "claim bằng conditional UPDATE" chính là toàn bộ thiết kế concurrency, vì sao lease tốt hơn một bảng heartbeat, `resume_turn` đảm bảo điều gì, và vì sao tính idempotent của tool là một thuộc tính được ghi tài liệu chứ không phải một giả định.
- Quy tắc package một chiều (`internal/agent` không bao giờ import `internal/run`) và cách `EventSink` cùng một hàm gate được inject giữ cho quy tắc đó đúng.
- Đánh đổi: thực thi trong request so với worker bền vững (ngắt kết nối, tắt êm, khả năng resume, HITL); tuần tự hóa theo session so với optimistic concurrency; nơi đặt tool timeout.
- Quyết định về kỷ luật prompt: không có base prompt do hango tự viết, chính sách nằm trong mô tả tool.
- So sánh với goclaw:
Loop.Runvới khối V2 nguyên khối và pipeline stage V3 sau một feature flag, giới hạn lặp 20,FinalizeStagetrên background context, phát hiện lặp trongtoolloop.go, interfaceToolvớiResult.ForLLM/ForUser, engine chính sách tool 5 lớp, lane scheduler với hàng đợi theo từng session. - Cách debug tầng này: từ một run id bị lỗi,
GET /v1/runs/{id}trả vềtrace_id; dán nó vào Tempo để thấy rootinvoke_agent, lượt nào thất bại, spanchathoặcexecute_toolnào mang lỗi, và chính xác tham số của tool; cùng run đó mở được trong Langfuse khi lọc theometadata.run_id, với các observation generation và tool theo đúng thứ tự; Loki lọc theohango.run.idcho ra toàn bộ câu chuyện qua log. - So sánh với deepagents: loop là
create_agentcủa LangGraph; tool là bề mặt do middleware cung cấp;PatchToolCallsMiddlewaresửa tool call bị treo ởbefore_agent;recursion_limitgần như không bị giới hạn. - Vì sao hango đi khác: một cài đặt loop duy nhất (không bao giờ hai cái sau một flag), repair là một bước tường minh, dòng run bền vững thay vì goroutine trong request, allowlist đơn giản thay cho policy engine.
- Bài tập: thêm tool thứ ba; làm một tool panic và xem loop sống sót; đặt
HANGO_MAX_ITERATIONS=1và đọc message kết thúc;kill -9tiến trình giữa run rồi xem lease hết hạn, lần quét requeue và run resume. - Ngân sách thời gian: 17h cài đặt + 3h tài liệu học.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...đều qua. - Các test loop bao phủ chỉ-text, tool call, tool lỗi, giới hạn số lần lặp, và
pause_turnlàm run thất bại vớiErrUnsupportedStop; mỗi test assert chuỗi message đã lưu. - Hai worker claim cùng một run queued: đúng một
ClaimQueuedtrả về dòng, đúng một lời gọi provider được thực hiện, vàhango.run.claims{outcome="lost"}tăng đúng một lần. - Một worker bị kill giữa lượt để lại run ở
runningvới lease sắp hết hạn; sau khi hết hạn, lần quét requeue nó, một worker thứ hai claim nó, và run hoàn tất mà không thực thi lại các lượt đã lưu (một counting fake tool assert mỗi tool đã hoàn tất chỉ chạy một lần). -
MarkTerminaltừ một tiến trình có lease đã bị thu hồi trả vềErrStateConflictvà để dòng không đổi. - Một
memberkhông thể tạo, đọc hay hủy run trên session của user khác (404);owner/adminthì được. - Run của một
ownertrên session của mộtmemberthực thi và nạp session đó; một run queued mà creator bị vô hiệu hóa trước khi claim kết thúcfailedvới lỗicreator_inactivevà không thực hiện lời gọi provider nào (có test). - Tạo một run ghi một dòng
audit_eventsvới actionrun.create,target_kind = 'run'vàtrace_idcủa run (integration test). - Một tool bị panic tạo ra tool result
IsErrorvà run vẫn đạtsucceeded. -
POST /v1/sessions/{id}/runstrả về 202 kèmLocation; pollGET /v1/runs/{id}chuyểnqueued → running → succeeded. - Test tuần tự hóa theo session: hai run được enqueue trên một session thực thi nghiêm ngặt tuần tự (assert bằng việc timestamp chồng lấn là không thể).
-
go test ./internal/store/pg/ -run TestCrossTenantbao gồmruns. -
http_fetchtừ chối host không có trong allowlist và địa chỉ loopback; các test assert cả hai. - Restart tiến trình requeue một run trước đó ở
runningmà lease đã hết hạn (không bao giờ làm nó thất bại) và để yên một run có lease vẫn đang do một tiến trình sống khác nắm giữ. -
GET /v1/runs/{id}trả về mộttrace_idphân giải trong Tempo thành root spaninvoke_agentvới các conchain,chatvàexecute_tooltheo đúng thứ tự. - Cùng run đó xuất hiện trong Langfuse như một trace mang tên agent, với
session.id,user.idvàmetadata.tenant_id/agent_id/run_idđược đặt. - Các series
hango.runs.by_state,hango.run.queue_depthvàhango.tool.errorstồn tại trong Prometheus sau một lần chạy test.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Tín hiệu | Cách xử lý đã quyết trước |
|---|---|---|---|
| Worker mất tenant scope vì HTTP context không còn | Cao x Nghiêm trọng | ErrNoTenant bên trong worker, hoặc tệ hơn, một query không có scope | Worker dựng lại store.Scope từ dòng runs cộng role hiện tại của creator và assert nó hợp lệ trước khi dựng agent; một test chạy worker với dòng bị sửa và mong đợi thất bại, không phải một lần đọc rộng |
| Worker có sai role trong scope | Trung bình x Cao | Run của một member đọc được toàn tenant, hoặc run của một owner không nạp được session của member | Role được nạp từ dòng user hiện tại của creator tại thời điểm claim, role rỗng là không hợp lệ (phase 2), và visibility(scope) fail closed với nó (phase 4) |
| Giữ kết nối database xuyên qua một lời gọi provider | Trung bình x Cao | Cạn pool khi có vài run đồng thời | Loop chỉ lưu trong các transaction ngắn; một test với MaxConns=2 chạy thành công 4 run đồng thời |
| Vòng ping-pong vô hạn giữa tool và model trong giới hạn lặp | Trung bình x Trung bình | Các tool call giống hệt lặp đi lặp lại | Hiện tại dùng giới hạn số lần lặp; phát hiện gọi lặp đến ở phase 7 dưới dạng middleware |
SSRF qua http_fetch | Trung bình x Cao | Fetch tới 169.254.169.254 hoặc host nội bộ | Allowlist host cộng từ chối IP riêng sau khi resolve; cả hai đều được test |
| Run bị bỏ rơi sau một lần crash | Cao x Trung bình | Run kẹt ở running mãi mãi, hango.run.reclaimed đứng yên trong khi các run cứ già đi | Lease hết hạn cộng lần quét reclaim requeue chúng tại ranh giới lượt; test resume là cổng kiểm |
| Một run thực thi hai lần (restart chồng lấn hoặc enqueue đôi) | Trung bình x Nghiêm trọng | Lời gọi provider trùng lặp, tác dụng phụ của tool bị trùng | Conditional claim với owner_id + lease là cửa vào duy nhất, mọi chuyển trạng thái đều có điều kiện, enqueue khử trùng lặp theo run id, và test đua hai worker là một tiêu chí thành công |
| Một lượt được chạy lại lặp lại tác dụng phụ của tool không idempotent | Trung bình x Cao | Ghi ra bên ngoài bị trùng sau một lần crash | Resume rơi vào ranh giới lượt, nên chỉ lượt bị gián đoạn mới chạy lại; mọi tool ghi rõ tính idempotent và các tool không idempotent bị chặn sau các interrupt của phase 15 |
Nghiên cứu khuyến nghị một interface pipeline Stage | Chắc chắn x Thấp | Cám dỗ dựng các stage ngay bây giờ | Các hàm tuần tự thuần trong giai đoạn này; middleware chain ở phase 7 là điểm mở rộng duy nhất (đường V2/V3 kép của goclaw là anti-pattern được dẫn ra) |
Rollback
goose down một bước sẽ xóa runs. Các package loop và tool là phần thêm vào; gỡ các route run và không khởi động scheduler trong internal/app sẽ đưa server về hành vi của phase 4, với message vẫn thêm được qua API.