Chuyển đến nội dung

Subagent

Tổng quan

Thêm tool task(description, subagent_type): một middleware sinh ra một agent loop con (child) với lịch sử message riêng biệt, middleware stack riêng, và dùng chung filesystem backend. Spec của subagent nằm trong config của agent cha, và một subagent mặc định general-purpose được tự động chèn vào. Kết quả quay về agent cha dưới dạng đoạn text assistant cuối cùng không rỗng. Đệ quy bị từ chối ngay lúc gọi bằng một thông báo thay vì giấu tool đi, và có một giới hạn độ sâu (depth cap). Các run của subagent chạy trên lane subagent của scheduler để chúng không làm đói các run cấp cao nhất.

Yêu cầu

Chức năng

  • Một tool duy nhất, task(description, subagent_type), mà phần mô tả nhúng sẵn danh sách subagent khả dụng, để việc khám phá diễn ra qua schema chứ không qua system prompt.
  • Spec của subagent lấy từ agents.config->'subagents': name, description, system_prompt, tools (nil thì kế thừa từ cha), model (rỗng thì kế thừa), interrupt_on (phase 15; nil thì kế thừa).
  • Một subagent general-purpose được tự động chèn vào trừ khi agent đã định nghĩa một subagent trùng tên đó.
  • Child loop bắt đầu với lịch sử message rỗng, chỉ chứa description làm message user đầu tiên; transcript của agent cha không bao giờ được kế thừa.
  • Kết quả trả về cho agent cha là text block assistant cuối cùng không rỗng; nếu không có, trả về một kết quả lỗi tường minh nêu tên vấn đề.
  • Các lần ghi file của child hiển thị với agent cha vì cả hai dùng chung namespace backend {tenant, agent}.
  • Giới hạn độ sâu HANGO_SUBAGENT_MAX_DEPTH (mặc định 2): khi chạm giới hạn, task trả về một chuỗi từ chối giải thích giới hạn đó; tool vẫn hiển thị.
  • Việc thực thi của child được tính vào cùng một run: nó chạy đồng bộ bên trong tool call của agent cha, và lane `subagent` được lấy **không chặn (non-blocking)**. Ở depth > 0, gate là `TryAcquire`: nếu không còn slot trống, `task` trả về một tool result không phải lỗi, nói rằng năng lực ủy quyền đang bận và bảo thử lại sau hoặc tự làm trực tiếp. Bên gọi ở depth 0 được phép chờ, bị chặn bởi deadline của run. Nếu acquire có chặn ở depth > 0 thì sẽ tự deadlock (agent cha giữ một slot trong khi chờ một slot khác), nên việc chặn re-entrant bị cấm ngay từ cách thiết kế, không phải bằng quy ước.
  • Các tool call trong cùng một batch được thực thi **tuần tự** ở v1, kể cả nhiều lời gọi `task`. Thực thi tool song song không phải tính năng của v1, nên không có hành vi "các lời gọi task song song bị giới hạn bởi lane" nào cần test; lane giới hạn concurrency giữa các run, không phải bên trong một batch.
  • Event của child được phát vào event stream của run cha kèm nhãn depth và subagent, để client SSE thấy tiến trình của subagent.
  • Todo, state memory và metadata skill không được lan ngược về state của agent cha.

Phi chức năng

  • Child loop dùng lại cùng agent.Agent, chain builder và store; không có bản cài đặt loop nào bị nhân đôi. Child được gọi qua Agent.Run(ctx, sessionID, input, sink, RunOptions{Depth: d+1, ParentSink: sink}) của phase 5 — chữ ký hàm không đổi trong phase này.
  • `internal/agent` vẫn không import gì từ `internal/run`. Lane được tiêm vào dưới dạng `agent.Gate func(ctx context.Context) (release func(), error)` (một closure `TryAcquire`) do `internal/app` dựng từ scheduler, và event của child đi qua `agent.EventSink` mà agent cha đã nắm sẵn.
  • Một run cha bị hủy thì hủy các child của nó ngay lập tức (dùng chung context).

Kiến trúc

// internal/agent/subagent.go
type Subagent struct {
	Name        string   `json:"name"`
	Description string   `json:"description"`
	SystemPrompt string  `json:"system_prompt"`
	Tools       []string `json:"tools,omitempty"`  // nil = inherit parent
	Model       string   `json:"model,omitempty"`  // "" = inherit
	InterruptOn map[string]InterruptConfig `json:"interrupt_on,omitempty"`
}

// Red team: F15 — InterruptConfig is defined here, in the phase that first references it.
// Phase 15 extends it with the When clause and consumes it in HITLMiddleware.
type InterruptConfig struct {
	AllowedDecisions []string `json:"allowed_decisions,omitempty"` // default: all four
}

// internal/agent/mwsubagent.go
// Red team: F5 — Gate is a plain func injected by the composition root, so internal/agent
// has no import of internal/run. Red team: F4 — it never blocks at depth > 0.
type Gate func(ctx context.Context) (release func(), error) // returns ErrNoCapacity when full

type SubagentMiddleware struct {
	Specs    []Subagent
	MaxDepth int
	Gate     Gate
	Build    ChildBuilder // constructs a child Agent + chain from a spec
} // ToolProvider

const refusalNoCapacity = "delegation capacity is busy right now; retry this task later or complete it directly"

type ChildBuilder func(ctx context.Context, parent *Agent, spec Subagent, depth int) (*Agent, error)

const refusalAtDepth = "subagent depth limit reached; complete this work directly instead of delegating"
-- migrations/00007_subagent_calls.sql
create table subagent_calls (
  id uuid primary key,
  tenant_id uuid not null references tenants(id) on delete cascade,
  run_id uuid not null references runs(id) on delete cascade,
  parent_tool_call_id text not null,
  subagent_name text not null,
  depth int not null,
  description text not null,
  state text not null check (state in ('running','succeeded','failed','cancelled')),
  result text not null default '',
  iterations int not null default 0,
  usage jsonb,
  trace_id text not null default '',
  started_at timestamptz not null default now(),
  finished_at timestamptz
);
create index subagent_calls_tenant_run_idx on subagent_calls (tenant_id, run_id, started_at);

Instrumentation

  • Một span lồng invoke_agent {subagent name} cho mỗi child, có cha là span execute_tool task, mang gen_ai.operation.name=invoke_agent, gen_ai.agent.name=<subagent>, hango.subagent.depth, hango.subagent.parent_tool_call_id, và langfuse.observation.type=agent để Langfuse hiển thị một observation agent lồng bên trong trace của agent cha.
  • Các span chat và execute_tool của child treo dưới span invoke_agent của child, nên cây trace trong Langfuse phản chiếu chính xác cây ủy quyền.
  • Span event trên agent cha: subagent.spawned, subagent.refused_depth, subagent.empty_result.
  • Metric: gen_ai.invoke_agent.duration và ...inference_calls có thêm attribute hango.subagent.depth; counter hango.subagent.calls (attribute subagent_name, state); histogram hango.subagent.lane_wait.
  • Trường log: log của child mang hango.subagent.name và hango.subagent.depth bên cạnh các trường run của agent cha, nên Loki lọc theo hango.run.id sẽ thấy log của cha và con xen kẽ nhưng vẫn phân biệt được.

Các file liên quan

Tạo mới

  • internal/agent/subagent.go, internal/agent/mwsubagent.go, internal/agent/mwsubagent_test.go.
  • internal/agent/childbuilder.go — dựng child stack (Filesystem → offload → slot Summarization → RepairToolCalls → RepeatCall → allowlist), không có Subagent trừ khi độ sâu cho phép.
  • internal/run/lane.go — semaphore của lane cộng TryAcquire, được phơi ra cho internal/app dưới dạng closure agent.Gate; internal/agent không bao giờ import nó.
  • internal/store/pg/subagent_call.go, internal/store/pg/subagent_call_test.go.
  • migrations/00007_subagent_calls.sql.
  • docs/learning/09-subagents.md.

Sửa đổi

  • internal/agent/mwstack.go — điền slot Subagent (sau Filesystem, trước Summarization); truyền độ sâu để child stack bỏ Subagent khi chạm giới hạn.
  • internal/agent/loop.go — điền RunOptions.Depth và bọc sink bằng decorator depth/subagent; chữ ký hàm không đổi so với phase 5.
  • internal/run/scheduler.go — đăng ký lane subagent và phơi ra Acquire/TryAcquire; internal/app chuyển chúng thành một agent.Gate.
  • internal/server/handlers_runs.go — GET /v1/runs/{id} kèm phần tóm tắt các subagent call.
  • internal/config/config.go — HANGO_SUBAGENT_MAX_DEPTH, HANGO_LANE_SUBAGENT_CONCURRENCY.
  • internal/store/pg/tenancy_test.go — thêm subagent_calls.
  • docs/system-architecture.md, docs/learning/README.md.

Các bước triển khai

  1. Viết internal/agent/subagent.go với kiểu spec và ParseSubagents(cfg json.RawMessage) ([]Subagent, error), kiểm tra tên (duy nhất, không rỗng, không có khoảng trắng) và chèn general-purpose khi chưa có.
  2. Viết internal/run/lane.go: một semaphore dùng buffered channel với Acquire(ctx) (có chặn, chỉ dùng ở depth 0) và TryAcquire() (không chặn, dùng ở depth > 0, trả về ErrNoCapacity), ghi lại hango.subagent.lane_wait. internal/app bọc hàm phù hợp theo từng độ sâu thành closure agent.Gate.
  3. Viết internal/agent/childbuilder.go: với Agent cha, một spec và một độ sâu, resolve model và allowlist tool (kế thừa khi chưa đặt), dựng child stack qua DefaultStack với Depth: depth+1 và bỏ slot Subagent khi chạm giới hạn, rồi trả về một Agent con dùng chung store, provider, backend và emitter.
  4. Viết internal/agent/mwsubagent.go: một ToolProvider phơi ra task. Phần mô tả của nó liệt kê các subagent khả dụng cùng mô tả của chúng, cộng với đoạn chính sách (khi nào nên ủy quyền, child chỉ thấy description, kết quả là message cuối của child). Execute kiểm tra subagent_type, kiểm tra độ sâu (trả về refusalAtDepth dưới dạng kết quả không phải lỗi để model có thể phục hồi), lấy một slot lane qua Gate (trả về refusalNoCapacity dưới dạng kết quả không phải lỗi khi gate báo hết năng lực, không bao giờ chặn ở depth > 0) , lưu một dòng subagent_calls, chạy child loop với description là message user duy nhất, trích đoạn text assistant cuối cùng không rỗng, lưu trạng thái kết thúc, rồi trả nó về.
  5. Lưu message của child: message của child được ghi vào một child session (sessions.key = parent session key + ":sub:" + subagent_call_id) để có thể kiểm tra transcript mà không làm bẩn session cha. Lịch sử của agent cha chỉ có thêm tool result.
  6. Nối việc lan truyền event: bọc EventSink của agent cha để event của child mang depth và subagent_name, và phát các run event subagent.started/subagent.finished.
  7. Thêm span lồng và metric theo phần Instrumentation.
  8. Chèn vào DefaultStack và cập nhật test cố định thứ tự (pinned order test).
  9. Test: tự động chèn general-purpose; child chỉ thấy description (assert danh sách message đầu tiên của child); agent cha nhận được text không rỗng cuối cùng; đường kết quả rỗng trả về lỗi có tên; giới hạn độ sâu trả về chuỗi từ chối và tool vẫn được liệt kê; một lane đầy ở depth > 0 trả về refusalNoCapacity ngay lập tức và run cha hoàn tất (test hồi quy cho tự deadlock) ; một lần ghi file của child hiển thị với agent cha qua read_file, và việc cha/con ghi đồng thời vào cùng một path cho ra kết quả version conflict chứ không phải mất cập nhật ; hủy agent cha thì hủy child (assert cả hai trạng thái kết thúc); các case chéo tenant và trong cùng tenant của subagent_calls.
  10. Cập nhật docs và viết learning doc.

Dàn ý tài liệu học

docs/learning/09-subagents.md phải có:

  • Khái niệm: ủy quyền là cách cô lập context, không phải song song hóa — điểm mấu chốt là transcript của child không bao giờ đi vào cửa sổ context của agent cha.
  • Các lựa chọn thiết kế: một tool task với danh sách subagent nằm trong phần mô tả, spec là dữ liệu config của agent, backend dùng chung nhưng lịch sử tách riêng, trích kết quả bằng text cuối không rỗng, từ chối đệ quy ngay lúc gọi, giới hạn độ sâu, một scheduler lane riêng, child session được lưu để kiểm tra.
  • Đánh đổi: thực thi đồng bộ trong tool so với xếp hàng một run riêng (chọn đồng bộ: tool call của agent cha cần kết quả, và lane vẫn giới hạn concurrency); kết quả text mất thông tin so với kết quả có cấu trúc; dùng chung filesystem so với cô lập nó.
  • So sánh với goclaw: việc sinh subagent nằm trong các tool cộng một bảng subagent_tasks, với internal/orchestration cung cấp BatchQueue[T] và ChildResult tổng quát, cùng các tính năng teams/delegation/cron mà hango bỏ qua.
  • So sánh với deepagents: SubAgentMiddleware thêm một tool task với {available_agents} nhúng trong phần mô tả; mỗi SubAgent khai báo tự biên dịch stack riêng (Filesystem → Summarization → PatchToolCalls); kết quả là text AI cuối cùng không rỗng; _EXCLUDED_STATE_KEYS chặn messages/todos/structured_response/skills_metadata/private không cho lan truyền; _FORK_RECURSION_REFUSAL từ chối ngay lúc gọi; recursion_limit thực tế là không giới hạn; mode="fork" (thử nghiệm) kế thừa transcript của agent cha.
  • Vì sao hango đi khác: có giới hạn độ sâu thật, có lane bị chặn trên, child session được lưu và các dòng subagent_calls để audit được, không có chế độ fork.
  • Cái bẫy mất thông tin đã biết: một child làm hết việc bằng tool call rồi kết thúc bằng một message rỗng, và cách lùi lại (walk-back) để xử lý.
  • Cách debug tầng này: trace Langfuse cho thấy cây observation agent lồng nhau, nên “subagent nào đốt token” chỉ cách một cú click; hango.subagent.calls theo name và state cho thấy chỗ lỗi tập trung; span event subagent.empty_result nhận diện cái bẫy kết quả bị mất thông tin trên production; Loki lọc theo hango.run.id cộng hango.subagent.depth tách phần kể chuyện của cha khỏi con.
  • Vì sao semaphore chặn re-entrant là một deadlock còn gate không chặn kèm tool result "thử lại sau" thì không, và vì sao gate là một hàm được tiêm vào chứ không phải một kiểu được import.
  • Bài tập: định nghĩa subagent thứ hai trong config của agent và ủy quyền cho nó; lồng đến giới hạn độ sâu và đọc thông báo từ chối; làm đầy lane và đọc thông báo từ chối do hết năng lực; cho một child kết thúc bằng message rỗng và quan sát đường lỗi.
  • Ngân sách thời gian: 10h cài đặt + 2h learning doc.

Tiêu chí thành công

  • go build ./... && go vet ./... && go test ./... đều qua.
  • general-purpose tồn tại mà không cần cấu hình; một subagent do người dùng định nghĩa trùng tên đó sẽ ghi đè nó (test).
  • Danh sách message ban đầu của child chứa đúng description (test).
  • Một lần ghi file của child đọc được từ agent cha qua read_file (integration test).
  • Ở giới hạn độ sâu, task trả về chuỗi từ chối dưới dạng kết quả không phải lỗi và tool vẫn được quảng bá (test).
  • Khi lane subagent đầy, một lời gọi task ở depth > 0 trả về thông báo từ chối do hết năng lực ngay lập tức (không chặn) và run cha vẫn đạt succeeded; một chuỗi ủy quyền sâu 2 không bao giờ deadlock.
  • Hủy agent cha đưa child về cancelled trong đường hủy của lane (test).
  • Các dòng subagent_calls ghi lại name, depth, state, iterations, usage và trace id; bảng nằm trong bộ test chéo tenant.
  • Trong Langfuse, một run có ủy quyền hiển thị như một trace với một observation agent lồng chứa các generation và tool call của child; trong Tempo cùng kiểu lồng đó hiện ra dưới span execute_tool task.
  • Các series hango.subagent.calls và hango.subagent.lane_wait tồn tại trong Prometheus.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngDấu hiệuPhản ứng định sẵn
Ủy quyền chạy mất kiểm soát làm cạn token hoặc workerTrung bình x CaoFan-out task sâu hoặc rộng, lane bão hòaGiới hạn độ sâu kèm chuỗi từ chối, giới hạn concurrency của lane subagent, và deadline của run cha chặn tổng lượng việc
Kết quả subagent rỗng làm agent cha bối rốiCao x Trung bìnhEvent subagent.empty_resultLùi lại đến text assistant không rỗng cuối cùng; nếu vẫn rỗng, trả về một kết quả lỗi có tên bảo model thử lại với chỉ dẫn rõ ràng hơn
Child vô tình kế thừa transcript của agent chaTrung bình x CaoLượng token của child xấp xỉ của agent chaChild builder dựng một lịch sử mới; một test assert chính xác danh sách message đầu tiên
Filesystem dùng chung để child ghi đè file của agent chaTrung bình x Trung bìnhNội dung file bất ngờ, mất cập nhậtCùng một namespace là có chủ ý (đó là cách truyền kết quả); cột version của phase 8 biến việc ghi đè đồng thời thành một kết quả conflict có thể thử lại. Guard chặn thay đổi trong cùng một batch không bao phủ trường hợp này và không được viện dẫn như một biện pháp giảm thiểu
Lấy lane re-entrant tự deadlockTrung bình x Nghiêm trọngMột ủy quyền sâu 2 treo đến khi hết deadline của runTryAcquire ở depth > 0 kèm tool result từ chối do hết năng lực; test lane đầy là một tiêu chí thành công
Span lồng làm phình kích thước traceThấp x ThấpTrace rất lớn trong TempoGiới hạn độ sâu chặn mức lồng; tỉ lệ sampling cấu hình được
Mẫu đệ quy không giới hạn của deepagentsChắc chắn x Trung bìnhCám dỗ bỏ qua giới hạnĐã chốt: HANGO_SUBAGENT_MAX_DEPTH mặc định 2, từ chối ngay lúc gọi

Rollback

goose down một bước sẽ bỏ subagent_calls. Gỡ slot Subagent khỏi DefaultStack sẽ gỡ tool task; các child session đã ghi vẫn đọc được qua session API.