Chuyển đến nội dung
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.Middleware là 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.
  • WrapModelCall là 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.
  • WrapToolCall là một onion bọc quanh tool.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.
  • BeforeRun chạ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 plan

Instrumentation

  • Việc dựng chain được ghi một lần cho mỗi run dưới dạng span event agent.middleware_stack trên span invoke_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 WrapModelCall và WrapToolCall là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.name và một lý do.
  • Các hook BeforeRun dùng chung một span INTERNAL agent.before_run vớ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_updated vớ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ào langfuse.observation.metadata.todos_total/todos_completed.
  • Các lần chặn gọi lặp phát span event middleware.repeat_call_blocked và tăng hango.middleware.repeat_blocked (attribute gen_ai.tool.name).
  • Metric: histogram hango.middleware.before_run.duration (attribute hango.middleware.name), counter hango.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ừ/đến runs.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 qua Chain.ModelCall, các lời gọi tool đi qua Chain.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ành RepairToolCallsMiddleware cài đặt BeforeRun.
  • internal/run/worker.go — dựng stack từ cấu hình agent, nạp/lưu State quanh run.
  • internal/store/pg/run.go — đọc/ghi state_json.
  • internal/config/config.go — HANGO_REPEAT_CALL_THRESHOLD.
  • docs/system-architecture.md, docs/learning/README.md.

Các bước triển khai

  1. Viết internal/agent/middleware.go với các interface ở trên và internal/agent/state.go với State, MarshalState/UnmarshalState cho runs.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.
  2. Viết chain.go: tại BuildChain, phân hoạch slice một lần thành []ModelWrapper, []ToolWrapper, []RunHook, []ToolProvider, []PromptContributor bằng type assertion. ModelCall ghé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.
  3. Viết mwstack.go với DefaultStack, 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 slot nil bị bỏ qua một cách tường minh cho các phase chưa cài đặt.
  4. Refactor loop.go: thay lời gọi provider trực tiếp bằng chain.ModelCall và Tools.Call trực tiếp bằng chain.ToolCall. Việc ghép prompt chuyển vào trong base model handler dưới dạng req.System = chain.SystemBlocks(def, state), nên nó chạy bên trong onion ở mỗi lần gọi model và mọi middleware WrapModelCall đều thấy (và có thể viết lại) các block đã ghép. Chạy chain.BeforeRun mộ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.
  5. Chuyển hàm repair của phase 5 thành RepairToolCallsMiddleware với BeforeRun, giữ nguyên các test của nó.
  6. Viết mwallowlist.go: một ToolWrapper từ 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.
  7. Viết mwtodo.go: một ToolProvider phơi write_todos có 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ục in_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à thay State.Todos nguyên khối, và một PromptContributor trả về một text block chứa danh sách hiện tại. Todo không bao giờ được thêm vào State.Messages.
  8. Viết mwrepeat.go: một ToolWrapper giữ một struct bộ đếm có kiểu cho mỗi run trong State.Private qua 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.
  9. Lưu State vào runs.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ó.
  10. 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 State vớ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.
  11. 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.Private khó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.RawMessage cộng accessor generic so với map[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 Stage V3 với Continue/BreakLoop/AbortRun, cộng mẹo DI bằng setter-interface XxxAware, và vì sao hango dùng tham số constructor thay thế.
  • So sánh với deepagents: AgentMiddleware với before_agent/wrap_model_call/wrap_tool_call, thứ tự mặc định chính xác trong graph.py:855-905, bộ máy ghép nối _apply_custom_middleware/excluded_middleware, write_todos như thay thế nguyên danh sách với OmitFromInput, và việc todo là opt-in từ bản 0.7.
  • Cách debug tầng này: span event agent.middleware_stack cho biết middleware nào đã chạy và theo thứ tự nào trong đúng run đó; các span event middleware.* 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.duration cho 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.go assert 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_todos thay danh sách, xuất hiện trong system prompt, và không bao giờ xuất hiện trong State.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_json giữ 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át middleware.state_missing.
  • Thứ tự block của Request.System đã ghép được assert theo chỉ số bên trong ModelCall, 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_agent của run mang một event agent.middleware_stack liệ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 event middleware.repeat_call_blocked.

Đánh giá rủi ro

Rủi roKhả năng x Tác độngTín hiệuCách xử lý đã quyết trước
Refactor âm thầm làm đổi hành vi của loopCao x CaoTest phase 5/6 thất bại hoặc, tệ hơn, vẫn qua trong khi các event đã đổiRefactor 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ầmTrung bình x CaoMột phase sau chèn một middleware vào sai slotDefaultStack 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ưuTrung bình x CaoHistory lệch với những gì đã gửi cho providerWrapModelCall 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 resumeTrung bình x CaoMột run được resume hoạt động như thể state middleware của nó rỗngmap[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ỉnhThấp x Trung bìnhYêu cầu tính năngNgoà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.