Add composable function-invocation middleware to the tool autocall loop - #638
Conversation
ea330aa to
2452eb9
Compare
This comment has been minimized.
This comment has been minimized.
2452eb9 to
0232cdf
Compare
This comment has been minimized.
This comment has been minimized.
Introduce FunctionInvocationContext and FunctionInvocationMiddleware in the agent package, exposing a per-tool-call interception point that the run-level Middleware cannot express. Wire a FunctionMiddleware chain into the toolautocall Config so middleware can mutate arguments before the tool runs, replace the result afterward, and set Terminate to stop the tool-calling loop. The chain composes outermost-first, mirroring the run-level middleware composition. This matches .NET's FunctionInvocationDelegatingAgent and Python's FunctionMiddleware, which both intercept individual function invocations.
0232cdf to
0ea4aa7
Compare
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Pull request overview
Adds a new per-tool-call middleware hook to the toolautocall harness so callers can intercept each tool invocation (mutate arguments, wrap/replace results, and optionally request loop termination) without changing behavior when no middleware is configured.
Changes:
- Introduces
agent.FunctionInvocationContextandagent.FunctionInvocationMiddlewarefor per-tool-call interception. - Wires
FunctionMiddlewarethroughtoolautocall.Configand composes it aroundtool.Callin the autocall loop (withIterationandTerminatesupport). - Adds a focused test validating argument mutation, result replacement, outermost-first composition, iteration counting, and termination behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| agent/middleware.go | Adds new per-invocation middleware context + middleware function type in the agent package. |
| agent/harness/toolautocall/autocall.go | Threads FunctionMiddleware into autocall execution and composes middleware around each tool call; adds termination propagation for the main loop. |
| agent/harness/toolautocall/autocall_test.go | Adds TestFunctionInvoking_FunctionMiddleware to validate middleware composition and semantics. |
| for i, fc := range funcCalls { | ||
| go func() { | ||
| defer wg.Done() | ||
| parallelResults[i] = f.processFunctionCall(ctx, tools, fc) | ||
| parallelResults[i] = f.processFunctionCall(ctx, tools, fc, iteration) | ||
| }() | ||
| } |
| return nil, errCount, nil | ||
| } | ||
| newMsg, errCount, err := f.processFunctionCalls(ctx, tools, funcCalls, errCount) | ||
| newMsg, errCount, _, err := f.processFunctionCalls(ctx, tools, funcCalls, errCount, 0) |
This comment has been minimized.
This comment has been minimized.
# Conflicts: # agent/middleware.go
This comment has been minimized.
This comment has been minimized.
Parity Review —
|
| Go identifier | Upstream equivalent |
|---|---|
agent.FunctionInvocationContext |
Python FunctionInvocationContext / .NET FunctionInvocationContext (from Microsoft.Extensions.AI) |
agent.FunctionInvocationMiddleware (func type) |
Python FunctionMiddlewareCallable / .NET Func<AIAgent, FunctionInvocationContext, ...> |
toolautocall.Config.FunctionMiddleware []agent.FunctionInvocationMiddleware |
Python middleware= on Agent / .NET AIAgentBuilder.Use(...) |
Parity assessment
The core semantics — intercept each tool call, mutate arguments before next, observe/replace the result after next, and optionally terminate the loop — are present in all three SDKs. No behavior-divergence parity issues found.
Minor observations (not blocking)
-
metadata/AdditionalPropertiesfield missing — Python'sFunctionInvocationContextexposes ametadata: dictfor middleware-to-middleware state sharing. .NET'sFunctionInvocationContext(fromMicrosoft.Extensions.AI) hasAdditionalProperties. Go'sFunctionInvocationContextcurrently omits this. This is a minor gap worth noting for a future iteration. -
sessionfield missing — Python'sFunctionInvocationContextcarries the currentAgentSession. Go has no equivalent field. This is a minor gap; no parity concern today since Go sessions are threaded differently. -
agentreference in .NET callback — .NET'sUsecallback receives theAIAgentinstance as a first parameter. Go'sFunctionInvocationMiddlewaredoes not. The PR's open design question aboutUse(...)builder-level convenience mirrors this; the currentConfig-wiring approach is functionally equivalent. No parity issue. -
FunctionInvocationMiddlewareplacement — Types live in theagentpackage and are wired viatoolautocall.Config, which mirrors Python'sAgent(middleware=[...])and is consistent with Go idioms. Thepublic-api-changelabel is correctly applied.
Conclusion
The PR brings Go to parity with Python and .NET for per-call function-invocation interception. No divergence in default behavior, enablement, or observable semantics was found. The parity-approved label is warranted pending resolution of the open design questions noted in the PR description.
Generated by Go API Consistency Review Agent · sonnet46 · 56.4 AIC · ⌖ 5.22 AIC · ⊞ 6K · ◷
What
Adds a per-tool-call interception point to the automatic tool-calling loop.
agent.FunctionInvocationContext(Function,Arguments,CallContent,Result,Terminate,Iteration) andagent.FunctionInvocationMiddlewareinagent/middleware.go.FunctionMiddleware []agent.FunctionInvocationMiddlewareontoolautocall.Config, threaded onto the autocall struct and composed aroundtool.CallinsideprocessFunctionCall.A middleware can:
Argumentsbeforenextruns (the tool sees the mutated args),next,Terminateto stop the loop after the current round of results is returned.The chain composes outermost-first, matching the run-level
runChaincomposition: the first configured middleware runs first and wraps the rest.Iterationcarries the zero-based tool-calling round.Why
The run-level
Middlewareonly wraps a wholeRunFunc; there was no way to intercept an individual tool invocation. This is a real gap versus the other SDKs:FunctionInvocationDelegatingAgent+AIAgentBuilder.Use(function callback)intercept each function call.FunctionMiddlewaredoes the same.This brings the Go port to parity for that per-call hook while keeping the change surgical (no behavior change when
FunctionMiddlewareis empty).How it's tested
TestFunctionInvoking_FunctionMiddlewarein the canonicalautocall_test.goregisters two middleware and, driving the existingagenttestharness, asserts:nextare what the tool observes,nextis what theFunctionResultContentcarries,Terminatestops the loop (the provider is called exactly twice; the extra turn is never consumed),Iterationincrements across rounds and the chain composes outermost-first.go build ./...,go vet ./agent/..., andgo test ./agent/...pass.Open design questions
Opening as a draft to align on the API before finalizing:
agentpackage (alongside run-levelMiddleware) and are wired only throughtoolautocall.Config. Should there also be an agent-builder-levelUse(...)convenience mirroring .NET'sAIAgentBuilder.Use, or is Config wiring sufficient?Resultis populated for inspection). .NET mutatescontext.Resultin place — should we standardize on one?AllowConcurrentInvocations: each call gets its ownFunctionInvocationContext, so per-call state is isolated, but user middleware sharing state across parallel calls must synchronize themselves. Do we want to document this contract, guarantee ordering, or serialize middleware regardless of the concurrency setting (as .NET's delegating agent effectively does)?Terminatestops the loop after the current round's results are streamed back. Should a terminating call instead short-circuit remaining calls in the same round?