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
descriptionlà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,tasktrả 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
depthvà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 quaAgent.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à spanexecute_tool task, manggen_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
chatvàexecute_toolcủa child treo dưới spaninvoke_agentcủ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.durationvà...inference_callscó thêm attributehango.subagent.depth; counterhango.subagent.calls(attributesubagent_name,state); histogramhango.subagent.lane_wait. - Trường log: log của child mang
hango.subagent.namevàhango.subagent.depthbên cạnh các trường run của agent cha, nên Loki lọc theohango.run.idsẽ 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ộngTryAcquire, được phơi ra chointernal/appdưới dạng closureagent.Gate;internal/agentkhô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ềnRunOptions.Depthvà bọc sink bằng decoratordepth/subagent; chữ ký hàm không đổi so với phase 5.internal/run/scheduler.go— đăng ký lanesubagentvà phơi raAcquire/TryAcquire;internal/appchuyển chúng thành mộtagent.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êmsubagent_calls.docs/system-architecture.md,docs/learning/README.md.
Các bước triển khai
- Viết
internal/agent/subagent.govớ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èngeneral-purposekhi chưa có. - Viết
internal/run/lane.go: một semaphore dùng buffered channel vớiAcquire(ctx)(có chặn, chỉ dùng ở depth 0) vàTryAcquire()(không chặn, dùng ở depth > 0, trả vềErrNoCapacity), ghi lạihango.subagent.lane_wait.internal/appbọc hàm phù hợp theo từng độ sâu thành closureagent.Gate. - Viết
internal/agent/childbuilder.go: vớiAgentcha, một spec và một độ sâu, resolve model và allowlist tool (kế thừa khi chưa đặt), dựng child stack quaDefaultStackvớiDepth: depth+1và bỏ slot Subagent khi chạm giới hạn, rồi trả về mộtAgentcon dùng chung store, provider, backend và emitter. - Viết
internal/agent/mwsubagent.go: mộtToolProviderphơi ratask. 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).Executekiểm trasubagent_type, kiểm tra độ sâu (trả vềrefusalAtDepthdướ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 quaGate(trả vềrefusalNoCapacitydướ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òngsubagent_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ề. - 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. - Nối việc lan truyền event: bọc
EventSinkcủa agent cha để event của child mangdepthvàsubagent_name, và phát các run eventsubagent.started/subagent.finished. - Thêm span lồng và metric theo phần Instrumentation.
- Chèn vào
DefaultStackvà cập nhật test cố định thứ tự (pinned order test). - 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ềrefusalNoCapacityngay 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 quaread_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ủasubagent_calls. - 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
taskvớ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ớiinternal/orchestrationcung cấpBatchQueue[T]vàChildResulttổng quát, cùng các tính năng teams/delegation/cron mà hango bỏ qua. - So sánh với deepagents:
SubAgentMiddlewarethêm một tooltaskvới{available_agents}nhúng trong phần mô tả; mỗiSubAgentkhai 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_KEYSchặnmessages/todos/structured_response/skills_metadata/privatekhông cho lan truyền;_FORK_RECURSION_REFUSALtừ chối ngay lúc gọi;recursion_limitthự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.callstheo name và state cho thấy chỗ lỗi tập trung; span eventsubagent.empty_resultnhận diện cái bẫy kết quả bị mất thông tin trên production; Loki lọc theohango.run.idcộnghango.subagent.depthtá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-purposetồ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,
tasktrả 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ọitaskở 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 đạtsucceeded; một chuỗi ủy quyền sâu 2 không bao giờ deadlock. - Hủy agent cha đưa child về
cancelledtrong đường hủy của lane (test). - Các dòng
subagent_callsghi 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
agentlồ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 spanexecute_tool task. - Các series
hango.subagent.callsvàhango.subagent.lane_waittồn tại trong Prometheus.
Đánh giá rủi ro
| Rủi ro | Khả năng x Tác động | Dấu hiệu | Phản ứng định sẵn |
|---|---|---|---|
| Ủy quyền chạy mất kiểm soát làm cạn token hoặc worker | Trung bình x Cao | Fan-out task sâu hoặc rộng, lane bão hòa | Giớ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ối | Cao x Trung bình | Event subagent.empty_result | Lù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 cha | Trung bình x Cao | Lượng token của child xấp xỉ của agent cha | Child 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 cha | Trung bình x Trung bình | Nội dung file bất ngờ, mất cập nhật | Cù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ự deadlock | Trung bình x Nghiêm trọng | Một ủy quyền sâu 2 treo đến khi hết deadline của run | TryAcquire ở 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 trace | Thấp x Thấp | Trace rất lớn trong Tempo | Giớ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 deepagents | Chắc chắn x Trung bình | Cá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.