Chuyển đến nội dung
Tool, agent loop, run bền vững

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ừ allowlist tools củ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 khi StopReason == 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_use mà không có tool_result tương ứng, thì tổng hợp các tool result lỗi và lưu chúng.
  • Bảng runs với các trạng thái queued|running|succeeded|failed|cancelled; POST /v1/sessions/{id}/runs chè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.Fake cộ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 chain cho mỗi lần lặp (attribute hango.turn, langfuse.observation.type=chain), là cha của span chat và các span execute_tool của lượt đó.
  • Tool span: execute_tool {tool} cho mỗi lời gọi với gen_ai.operation.name=execute_tool, gen_ai.tool.name, gen_ai.tool.call.id, langfuse.observation.type=tool, error.type khi lỗi, và input/output chỉ được ghi khi bật HANGO_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 (attribute gen_ai.tool.name), gauge hango.runs.by_state (attribute state, hango.tenant.id), gauge hango.run.queue_depth (attribute hango.lane), counter hango.tool.errors (attribute gen_ai.tool.name, error.type), histogram hango.run.iterations, counter hango.run.claims (attribute outcome = won|lost) và counter hango.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_id củ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 (server httptest cho 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ấp invoke_agent → chain → chat/execute_tool và 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 case runs ché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 (validate tools theo 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.
CheckpointKhái niệmCác bướcXong khi
5aLõi harness1–6, cộng các test registry và loop từ bước 12Tool 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 12Bả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

  1. Viết internal/tool/tool.go và schema.go. schema.Object(schema.Field("url", "string", "…", true), …) giữ các literal JSON Schema ra khỏi file tool.
  2. Viết registry.go. View.Call tra 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 qua context.WithTimeout, bọc Execute trong một hàm có defer recover() để biến panic thành Result{IsError:true} kèm message của panic và một stack được ghi log.
  3. Viết hai builtin. http_fetch phâ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 đa max_bytes (mặc định 64KiB) và trả về body cùng status.
  4. Viết internal/agent/prompt.go: system prompt là system_prompt củ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.
  5. Viết internal/agent/repair.go: với []provider.Message, tìm các id tool_use không khớp trong assistant message cuối và tạo các ToolResultBlock{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.
  6. 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. Khi RunOptions.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ọi Stream, đẩy mọi event vào một provider.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ưu Accumulator.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ỗi ToolUseBlock, gọi Tools.Call, dựng một user message chứa tất cả ToolResultBlock theo đúng thứ tự yêu cầu, lưu nó, ghi lượt đã hoàn tất vào runs.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.
  7. Viết RunStore: Create, Get, ListBySession, ClaimQueued, RenewLease, MarkTerminal(...), RequestCancel, ReclaimExpired. Mọi thao tác ghi là một lệnh UPDATE ... WHERE id=$1 AND state=$expected AND owner_id=$me RETURNING có điều kiện duy nhất và trả về ErrStateConflict khi không ảnh hưởng dòng nào; không phương thức nào đọc rồi mới ghi.
  8. Viết internal/run/scheduler.go: một worker pool có giới hạn gồm HANGO_RUN_WORKERS goroutine đọ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. Enqueue khô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ột registry trong bộ nhớ ánh xạ run id → hàm cancel.
  9. Viết internal/run/worker.go: lấy một run id ra, claim nó bằng ClaimQueued (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 theo run.TenantID/run.CreatedBy và dựng store.Scope{TenantID, UserID, Role} từ role hiện tại của họ. Khi creator đã bị xóa hoặc status không phải active, MarkTerminal run thành failed với lỗi creator_inactive rồ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ựng Agent, khởi động heartbeat qua safego, thực thi với RunOptions{ResumeFrom: run.ResumeTurn}, và MarkTerminal có điều kiện theo owner_id. Phần kết thúc chạy trên một background context tạo bằng context.WithoutCancel để một run đã bị hủy vẫn lưu được state dở dang.
  10. Viết internal/server/handlers_runs.go: POST /v1/sessions/{id}/runs kiểm tra quyền truy cập session theo scope (giới hạn theo creator đối với member), 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_SESSION run đang chờ.
  11. Viết lần quét reconciliation (ticker safego mỗi HANGO_RUN_RECLAIM_INTERVAL, cộng một lượt khi khởi động): ReclaimExpired đưa các run running có lease đã hết hạn về queued, xóa owner_id, giữ nguyên resume_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.
  12. 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_turn sinh ra ErrUnsupportedStop và 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ột MarkTerminal từ owner đã cũ trả về ErrStateConflict và 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 case runs chéo tenant và trong cùng tenant.
  13. Viết internal/agent/trace.go và nối nó vào: worker bắt đầu root span invoke_agent vớ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ưu trace_id/span_id vào run, mở một span chain cho mỗi lượt, và đường đi của tool mở các span execute_tool. Đăng ký các instrument metric ở cấp agent (tên lấy từ internal/telemetry/metrics_names.go) và cập nhật hango.runs.by_state cùng hango.run.queue_depth từ scheduler.
  14. 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.
  15. 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.Run với khối V2 nguyên khối và pipeline stage V3 sau một feature flag, giới hạn lặp 20, FinalizeStage trên background context, phát hiện lặp trong toolloop.go, interface Tool với Result.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 root invoke_agent, lượt nào thất bại, span chat hoặc execute_tool nào mang lỗi, và chính xác tham số của tool; cùng run đó mở được trong Langfuse khi lọc theo metadata.run_id, với các observation generation và tool theo đúng thứ tự; Loki lọc theo hango.run.id cho ra toàn bộ câu chuyện qua log.
  • So sánh với deepagents: loop là create_agent của LangGraph; tool là bề mặt do middleware cung cấp; PatchToolCallsMiddleware sửa tool call bị treo ở before_agent; recursion_limit gầ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=1 và đọc message kết thúc; kill -9 tiế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_turn làm run thất bại với ErrUnsupportedStop; mỗi test assert chuỗi message đã lưu.
  • Hai worker claim cùng một run queued: đúng một ClaimQueued trả 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 ở running vớ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).
  • MarkTerminal từ một tiến trình có lease đã bị thu hồi trả về ErrStateConflict và để dòng không đổi.
  • Một member không thể tạo, đọc hay hủy run trên session của user khác (404); owner/admin thì được.
  • Run của một owner trên session của một member thự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úc failed với lỗi creator_inactive và 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_events với action run.create, target_kind = 'run' và trace_id của run (integration test).
  • Một tool bị panic tạo ra tool result IsError và run vẫn đạt succeeded.
  • POST /v1/sessions/{id}/runs trả về 202 kèm Location; poll GET /v1/runs/{id} chuyển queued → 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 TestCrossTenant bao gồm runs.
  • http_fetch từ 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 đó ở running mà 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ột trace_id phân giải trong Tempo thành root span invoke_agent với các con chain, chat và execute_tool theo đú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.id và metadata.tenant_id/agent_id/run_id được đặt.
  • Các series hango.runs.by_state, hango.run.queue_depth và hango.tool.errors tồn tại trong Prometheus sau một lần chạy test.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngTín hiệuCách xử lý đã quyết trước
Worker mất tenant scope vì HTTP context không cònCao x Nghiêm trọngErrNoTenant bên trong worker, hoặc tệ hơn, một query không có scopeWorker 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 scopeTrung bình x CaoRun 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 memberRole đượ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 providerTrung bình x CaoCạn pool khi có vài run đồng thờiLoop 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ặpTrung bình x Trung bìnhCác tool call giống hệt lặp đi lặp lạiHiệ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_fetchTrung bình x CaoFetch 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 crashCao x Trung bìnhRun kẹt ở running mãi mãi, hango.run.reclaimed đứng yên trong khi các run cứ già điLease 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ọngLời gọi provider trùng lặp, tác dụng phụ của tool bị trùngConditional 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 idempotentTrung bình x CaoGhi ra bên ngoài bị trùng sau một lần crashResume 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 StageChắc chắn x ThấpCá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.