Middleware chain và tool lập kế hoạch
Middleware chain và tool lập kế hoạch
Tổng quan
Đưa vào kiến trúc mở rộng mà mọi tính năng harness còn lại đều xây trên đó: một middleware chain có thứ tự, điều khiển bằng dữ liệu, với các hook BeforeRun, WrapModelCall, WrapToolCall, ToolProvider và PromptContributor. Refactor loop của phase 5 sang chain này mà không đổi hành vi quan sát được, rồi chứng minh chain bằng hai middleware nhỏ: lập kế hoạch write_todos (một field state + tool + prompt fragment) và phát hiện gọi lặp được chuyển ra khỏi loop.
Yêu cầu
Chức năng
agent.Middlewarelà một marker interface; các capability interface là tùy chọn và được phát hiện bằng type assertion khi dựng chain (không phải ở mỗi lần gọi).- Thứ tự chain do một constructor duy nhất tạo ra từ cấu hình của agent; không có thuật toán merge và không có thứ tự do người dùng cung cấp.
WrapModelCalllà một onion: middleware có thể sửa request cho một lần gọi, retry, hoặc thay response. Nó không được thay đổi history đã lưu.WrapToolCalllà một onion bọc quanhtool.View.Call.- Các middleware
ToolProviderđóng góp tool; thứ tự tool trình bày cho model theo thứ tự chain. - **System prompt được ghép bên trong chain, tại thời điểm gọi model.** `PromptContributor.SystemBlocks(s *State) []provider.Block` trả về các block, không phải một string, và base model handler nối các system block của chính agent rồi tới block của từng contributor theo thứ tự chain để tạo ra `Request.System`. Việc ghép diễn ra ở mỗi lần gọi model (rẻ và phải phản ánh state hiện tại), không bao giờ chỉ một lần trước loop. Chính điều này làm cho các ràng buộc về cache breakpoint và thứ tự memory diễn đạt được: `PromptCacheMiddleware` (phase 10) đặt `CacheControl` lên một block **theo chỉ số** trong slice đã ghép đó, và `MemoryMiddleware` (phase 11) đóng góp các block rơi sau chỉ số breakpoint vì nó đứng sau trong chain. Các assert về thứ tự được thực hiện trên chỉ số breakpoint, không phải trên một slice các string.
BeforeRunchạy một lần cho mỗi run, trước lần gọi model đầu tiên, và có thể trả về các cập nhật state.TodoMiddleware:write_todos(todos)thay thế nguyên danh sách, state được lưu trên run (persist để một run được resume giữ nguyên kế hoạch), được render vào system prompt, không bao giờ được vọng lại như một message.RepeatCallMiddleware: băm tên tool + args đã chuẩn hóa; ở lần gọi giống hệt thứ ba trong một run, trả về một tool result nhắc nhở thay vì thực thi.
Phi chức năng
- Refactor loop không được đổi kết quả của bất kỳ test nào ở phase 5 hay phase 6; các test đó là cổng hồi quy.
- Không có công việc nặng về cấp phát bộ nhớ cho mỗi delta: chain chỉ bọc các lời gọi model và lời gọi tool, không bao giờ bọc từng stream event.
- State riêng của middleware nằm trong `State.Private map[string]json.RawMessage` với khóa là tên middleware. Mỗi middleware sở hữu một struct có kiểu và marshal/unmarshal nó qua hai accessor nhỏ (`loadX(s) (X, bool, error)` / `storeX(s, X) error`), nên việc lưu run state vào `runs.state_json` chỉ là một lần marshal `State` thông thường và một run được resume dựng lại state có kiểu thay vì im lặng nhận `map[string]any` ở chỗ nó mong một struct. Một key bị thiếu khi resume là một tình huống tường minh được xử lý: accessor trả về `found=false`, middleware khởi tạo giá trị mặc định, và ghi một span event `middleware.state_missing` với `hango.middleware.name`, không bao giờ là một giá trị zero im lặng. Các field có kiểu ở cấp cao nhất chỉ tồn tại cho các hợp đồng giữa các middleware (`Todos`).
- State scratch theo từng batch (tập đường dẫn đã bị sửa mà filesystem middleware dùng ở phase 8, các bộ đếm gọi lặp) được loop xóa tường minh ở cuối mỗi batch tool call, để một run dài không thể tích lũy nó và một run được resume không thừa hưởng một batch cũ.
Kiến trúc
// internal/agent/middleware.go
type Middleware interface{ Name() string }
type State struct {
RunID, SessionID, AgentID, TenantID uuid.UUID
Messages []provider.Message // the loop's working history (persisted separately)
Todos []Todo
// Red team: F10 — per-middleware typed state, marshalled by its owner.
Private map[string]json.RawMessage // keyed by middleware name
}
type ModelRequest struct {
Req *provider.Request
State *State
}
type ModelResponse struct {
Message provider.Message
StopReason provider.StopReason
Usage provider.Usage
}
type ModelHandler func(context.Context, *ModelRequest) (*ModelResponse, error)
type ModelWrapper interface {
WrapModelCall(ctx context.Context, req *ModelRequest, next ModelHandler) (*ModelResponse, error)
}
type ToolCallRequest struct {
Call provider.ToolUseBlock
State *State
}
type ToolHandler func(context.Context, *ToolCallRequest) (tool.Result, error)
type ToolWrapper interface {
WrapToolCall(ctx context.Context, req *ToolCallRequest, next ToolHandler) (tool.Result, error)
}
type RunHook interface {
BeforeRun(ctx context.Context, s *State) error
}
type ToolProvider interface {
ProvideTools(ctx context.Context, s *State) []tool.Tool
}
// Red team: F6 — contributors return blocks so cache breakpoints can be placed by index.
type PromptContributor interface {
SystemBlocks(s *State) []provider.Block
}
// internal/agent/chain.go
type Chain struct{} // built once per run from an ordered []Middleware
func BuildChain(mws []Middleware) *Chain
func (c *Chain) ModelCall(ctx context.Context, req *ModelRequest, base ModelHandler) (*ModelResponse, error)
func (c *Chain) ToolCall(ctx context.Context, req *ToolCallRequest, base ToolHandler) (tool.Result, error)
func (c *Chain) BeforeRun(ctx context.Context, s *State) error
func (c *Chain) Tools(ctx context.Context, s *State) []tool.Tool
// Red team: F6 — called by the base model handler on every model call, inside ModelCall.
func (c *Chain) SystemBlocks(def store.Agent, s *State) []provider.Block
// internal/agent/mwstack.go — the single place order is decided
func DefaultStack(cfg StackConfig) []Middleware
// order: Skills -> Filesystem -> Subagent -> Summarization -> RepairToolCalls ->
// Todo -> RepeatCall -> PromptCache -> Memory -> HumanInTheLoop -> ToolAllowlist
// (later phases fill slots; this phase ships RepairToolCalls, Todo, RepeatCall, ToolAllowlist)// internal/agent/mwtodo.go
type Todo struct {
Content string `json:"content"`
Status string `json:"status"` // pending|in_progress|completed
}
type TodoMiddleware struct{} // ToolProvider + PromptContributor-- migrations/00005_run_state.sql
alter table runs add column state_json jsonb not null default '{}'::jsonb;
-- holds Todos and serializable middleware state so a resumed or approved run keeps its planInstrumentation
- Việc dựng chain được ghi một lần cho mỗi run dưới dạng span event
agent.middleware_stacktrên spaninvoke_agent, với tên các middleware theo thứ tự làm một attribute, để câu hỏi “stack nào đã chạy” không bao giờ là đoán mò. - Mỗi middleware
WrapModelCallvàWrapToolCalllàm đổi hành vi đều ghi một span event trên turn span bao quanh:middleware.request_rewritten,middleware.short_circuited,middleware.retried, mỗi cái cóhango.middleware.namevà một lý do. - Các hook
BeforeRundùng chung một span INTERNALagent.before_runvới một span con cho mỗi hook (hango.middleware.name), để một hook load-một-lần chậm (memory, skill ở các phase sau) lộ ra ngay lập tức. - State của todo được ghi dưới dạng span event
agent.todos_updatedvới số đếm theo trạng thái (không bao giờ ghi nội dung, vì có thể là dữ liệu của người dùng) và được phản chiếu vàolangfuse.observation.metadata.todos_total/todos_completed. - Các lần chặn gọi lặp phát span event
middleware.repeat_call_blockedvà tănghango.middleware.repeat_blocked(attributegen_ai.tool.name). - Metric: histogram
hango.middleware.before_run.duration(attributehango.middleware.name), counterhango.middleware.tool_short_circuits.
Các file liên quan
Tạo mới
internal/agent/middleware.go,internal/agent/chain.go,internal/agent/chain_test.go.internal/agent/mwstack.go,internal/agent/mwstack_test.go(assert thứ tự chính xác và thứ tự tool mà model thấy).internal/agent/mwtodo.go,internal/agent/mwtodo_test.go.internal/agent/mwrepeat.go,internal/agent/mwrepeat_test.go.internal/agent/mwallowlist.go— thực thi allowlist tool của agent ở cuối cùng để không middleware nào thêm lại được một tool bị cấm.internal/agent/state.go—State, các helper persist từ/đếnruns.state_json.migrations/00005_run_state.sql.docs/learning/07-middleware-architecture.md.
Sửa
internal/agent/loop.go— các lời gọi model đi quaChain.ModelCall, các lời gọi tool đi quaChain.ToolCall, system prompt được ghép bên trong base model handler từChain.SystemBlocks, các tool từChain.Toolsđược gộp với registry view.internal/agent/repair.go— trở thànhRepairToolCallsMiddlewarecài đặtBeforeRun.internal/run/worker.go— dựng stack từ cấu hình agent, nạp/lưuStatequanh run.internal/store/pg/run.go— đọc/ghistate_json.internal/config/config.go—HANGO_REPEAT_CALL_THRESHOLD.docs/system-architecture.md,docs/learning/README.md.
Các bước triển khai
- Viết
internal/agent/middleware.govới các interface ở trên vàinternal/agent/state.govớiState,MarshalState/UnmarshalStatechoruns.state_json, cùng các accessor private-state generic (LoadPrivate[T](s, name) (T, bool, error)/StorePrivate[T](s, name, v) error) mà mọi middleware dùng thay vì đụng thẳng vào map. - Viết
chain.go: tạiBuildChain, phân hoạch slice một lần thành[]ModelWrapper,[]ToolWrapper,[]RunHook,[]ToolProvider,[]PromptContributorbằng type assertion.ModelCallghép các wrapper sao cho chỉ số 0 là ngoài cùng, và base handler thực hiện lời gọi provider cộng việc tích lũy. - Viết
mwstack.govớiDefaultStack, constructor có thứ tự duy nhất. Ghi chú cho từng slot lý do nó đứng ở đó: allowlist ở cuối để không gì thêm lại được một tool bị cấm; prompt cache trước memory để việc sửa memory không làm vô hiệu prefix của cache; summarization sau filesystem để nó thấy các tool result đã được offload. Để lại các slotnilbị bỏ qua một cách tường minh cho các phase chưa cài đặt. - Refactor
loop.go: thay lời gọi provider trực tiếp bằngchain.ModelCallvàTools.Calltrực tiếp bằngchain.ToolCall. Việc ghép prompt chuyển vào trong base model handler dưới dạngreq.System = chain.SystemBlocks(def, state), nên nó chạy bên trong onion ở mỗi lần gọi model và mọi middlewareWrapModelCallđều thấy (và có thể viết lại) các block đã ghép. Chạychain.BeforeRunmột lần trước loop. Xóa state scratch theo từng batch ở cuối mỗi batch. Không đổi cách phát event. - Chuyển hàm repair của phase 5 thành
RepairToolCallsMiddlewarevớiBeforeRun, giữ nguyên các test của nó. - Viết
mwallowlist.go: mộtToolWrappertừ chối bất kỳ lời gọi nào có tên không nằm trong allowlist hiệu lực của agent bằng một tool result lỗi, cộng một bộ lọc áp lên danh sách tool giao cho provider. - Viết
mwtodo.go: mộtToolProviderphơiwrite_todoscó mô tả mang văn bản chính sách (khi nào dùng, khi nào không, đúng một mụcin_progress, đánh dấu hoàn thành không phải là một câu trả lời), một handler validate các trạng thái và thayState.Todosnguyên khối, và mộtPromptContributortrả về một text block chứa danh sách hiện tại. Todo không bao giờ được thêm vàoState.Messages. - Viết
mwrepeat.go: mộtToolWrappergiữ một struct bộ đếm có kiểu cho mỗi run trongState.Privatequa các accessor; hash làfnv64(name + canonicalJSON(args)); ở ngưỡng thì trả vềtool.Errorf("this exact call was already made N times; use the previous result or change approach")mà không thực thi. - Lưu
Statevàoruns.state_jsonở mỗi ranh giới lượt để một run bị hủy hoặc bị tạm dừng chờ approval resume được cùng kế hoạch của nó. - Test: test thứ tự chain (một middleware ghi lại assert ngoài cùng/trong cùng); thứ tự ghép system block được assert theo chỉ số block, với một chỉ số cache-breakpoint giữ chỗ để cố định hợp đồng mà phase 10 và 11 phụ thuộc ; một model wrapper retry một lần; một tool wrapper short-circuit; ngữ nghĩa thay thế và render prompt của tool todo; ngưỡng gọi lặp; thực thi allowlist, gồm cả một middleware cố thêm lại một tool bị cấm; private-state round trip: marshal
Statevới state có kiểu của ba middleware, nạp lại, và assert mỗi middleware đọc lại đúng struct của mình, cộng một case thiếu key assert giá trị mặc định được khởi tạo vàmiddleware.state_missingđược ghi lại ; state scratch theo batch rỗng ở đầu mỗi batch; chạy hồi quy toàn bộ các suite của phase 5 và phase 6. - Cập nhật docs và viết tài liệu học.
Dàn ý tài liệu học
docs/learning/07-middleware-architecture.md phải có:
- Khái niệm: mẫu onion/decorator áp dụng cho lời gọi model và tool; vì sao “mọi tính năng đều là một middleware” là quyết định chịu lực của cả harness.
- Lựa chọn thiết kế: marker interface cộng các capability interface tùy chọn, phân hoạch theo kiểu một lần, viết lại request không làm thay đổi gốc,
State.Privatekhóa theo tên, stack có thứ tự từ một constructor, run state được persist. - Lý do của thứ tự, từng slot một, gồm hai ràng buộc không hiển nhiên (allowlist ở cuối, prompt cache trước memory) và cái gì hỏng nếu vi phạm.
- Đánh đổi: middleware chain so với stage pipeline so với ghép hàm thuần; type assertion so với năm slice riêng trong constructor;
map[string]json.RawMessagecộng accessor generic so vớimap[string]any(cái sau không sống sót qua một lần đi vòng qua database, mà đó chính là toàn bộ ý nghĩa của run state được persist). - Ghép prompt như một thao tác tại thời điểm chain: vì sao đóng góp `[]provider.Block` thay vì string là điều làm cho cache breakpoint và thứ tự memory diễn đạt được, và vì sao các assert thứ tự phải được thực hiện trên chỉ số breakpoint.
- So sánh với goclaw: pipeline
StageV3 vớiContinue/BreakLoop/AbortRun, cộng mẹo DI bằng setter-interfaceXxxAware, và vì sao hango dùng tham số constructor thay thế. - So sánh với deepagents:
AgentMiddlewarevớibefore_agent/wrap_model_call/wrap_tool_call, thứ tự mặc định chính xác tronggraph.py:855-905, bộ máy ghép nối_apply_custom_middleware/excluded_middleware,write_todosnhư thay thế nguyên danh sách vớiOmitFromInput, và việc todo là opt-in từ bản 0.7. - Cách debug tầng này: span event
agent.middleware_stackcho biết middleware nào đã chạy và theo thứ tự nào trong đúng run đó; các span eventmiddleware.*cho biết cái nào đã viết lại request, short-circuit một tool, hoặc chặn một lần gọi lặp;hango.middleware.before_run.durationcho biết hook load-một-lần nào đang chậm. - Vì sao hango đi khác: không cho người dùng ghép nối middleware (thứ tự là code), không merge state-schema động, todo luôn có sẵn nhưng bị chặn bởi allowlist tool của agent.
- Bài tập: viết một middleware log mọi model request; đổi chỗ hai slot và quan sát test thất bại.
- Ngân sách thời gian: 11h cài đặt + 2h tài liệu học.
Tiêu chí thành công
-
go build ./... && go vet ./... && go test ./...đều qua, kể cả mọi test không đổi của phase 5 và phase 6. -
mwstack_test.goassert thứ tự middleware chính xác và thứ tự tool chính xác được trình bày cho provider. - Test chain chứng minh chỉ số 0 là ngoài cùng cho cả lời gọi model lẫn lời gọi tool.
- Một middleware cố thêm lại một tool không được phép bị middleware allowlist đánh bại (test).
-
write_todosthay danh sách, xuất hiện trong system prompt, và không bao giờ xuất hiện trongState.Messages(test). - Middleware gọi lặp chặn lần gọi giống hệt thứ ba và trả về lời nhắc (test).
-
runs.state_jsongiữ danh sách todo sau một run; nạp lại run thì khôi phục được nó (integration test). - Private-state round trip: state có kiểu của mọi middleware sống sót qua marshal →
runs.state_json→ unmarshal; một key bị thiếu khởi tạo giá trị mặc định đã ghi tài liệu và phátmiddleware.state_missing. - Thứ tự block của
Request.Systemđã ghép được assert theo chỉ số bên trongModelCall, và một contributor được thêm sau trong chain rơi vào chỉ số cao hơn contributor được thêm trước. - Trong Tempo, span
invoke_agentcủa run mang một eventagent.middleware_stackliệt kê đúng stack có thứ tự, và các lần gọi lặp bị chặn xuất hiện dưới dạng eventmiddleware.repeat_call_blocked.
Đá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 |
|---|---|---|---|
| Refactor âm thầm làm đổi hành vi của loop | Cao x Cao | Test phase 5/6 thất bại hoặc, tệ hơn, vẫn qua trong khi các event đã đổi | Refactor mà không sửa test nào; các assert chuỗi event ở phase 6 là hợp đồng hành vi |
| Thứ tự trở thành kiến thức ngầm | Trung bình x Cao | Một phase sau chèn một middleware vào sai slot | DefaultStack mang một comment cho từng slot nêu ràng buộc, và mwstack_test.go cố định thứ tự |
| Một middleware làm thay đổi history đã lưu | Trung bình x Cao | History lệch với những gì đã gửi cho provider | WrapModelCall nhận một bản sao của Req.Messages; base handler chỉ lưu những gì loop quyết định, và một test assert State.Messages không đổi sau một middleware viết lại |
State.Private mất state có kiểu khi resume | Trung bình x Cao | Một run được resume hoạt động như thể state middleware của nó rỗng | map[string]json.RawMessage với các accessor marshal/unmarshal theo từng middleware, một đường missing-key tường minh có span event, và test round-trip là cổng kiểm |
| Người dùng yêu cầu thứ tự middleware tùy chỉnh | Thấp x Trung bình | Yêu cầu tính năng | Ngoài phạm vi: thứ tự là code (bộ máy ghép nối của deepagents là cái giá được dẫn ra) |
Rollback
goose down một bước sẽ xóa runs.state_json. Chain là phần thêm vào quanh loop của phase 5: dựng DefaultStack với một slice rỗng cho đúng hành vi của phase 5, đó là phương án dự phòng nếu refactor hoạt động sai.