Skip to content

[dotnet-port-api] Allow function middleware to replace tools - #1178

Open
Michelle Clayton (michelle-clayton-work) wants to merge 2 commits into
mainfrom
dotnet-port-api-function-middleware-replace-20260923-2bed435020dcd1c9
Open

Michelle Clayton (michelle-clayton-work) wants to merge 2 commits into
mainfrom
dotnet-port-api-function-middleware-replace-20260923-2bed435020dcd1c9

Conversation

@michelle-clayton-work

Copy link
Copy Markdown
Contributor

Tip

Your pull request is ready to create! 🎉 ✅

Everything is OK—the changes have been pushed to branch dotnet-port-api-function-middleware-replace-20260923-2bed435020dcd1c9. Please review the changes, including any protected files, before creating the pull request.

Create the pull request

The original pull request description is below.


Summary

Allow agent.FunctionInvocationMiddleware to replace FunctionInvocationContext.Function before calling next, so middleware can redirect an invocation to a different tool.FuncTool while preserving the already-surfaced tool metadata and approval flow. The Go port also adds explicit errors for invalid next(nil) / nil-function continuations and covers both tool auto-calling and provider-managed tool execution with replacement tests.

This aligns the Go SDK with the recent upstream .NET function-middleware capability added in upstream commit 173978ee93e0ffa5ef4ebdbfe2e94cd6f8e8a996 from microsoft/agent-framework#8615.

Ported .NET PRs

Breaking Changes

No.

Tests and Examples

  • go test ./agent/...
  • Added function-replacement coverage to agent/agent_test.go
  • Added provider-managed execution replacement coverage to agent/middleware_test.go

Notes

  • The port keeps tool metadata and approval decisions bound to the originally surfaced tool; replacement only changes the function invoked by the middleware continuation.
  • I found no matching existing local port-tracking branch for this function-middleware replacement work during the duplicate check.

Closes #1169

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings September 24, 2026 19:27
@github-actions github-actions Bot added area:agent Changes files in the agent area size:large At most 300 changed lines across at most 10 files labels Sep 24, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Continuation validation can still permit panics, and replacement semantics conflict with the cited upstream contract.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds function replacement support to invocation middleware for auto-called and provider-managed tools.

Changes:

  • Invokes middleware-selected replacement tools.
  • Adds invalid-continuation errors.
  • Adds replacement-path tests.
File Description
agent/​middleware.go Implements replacement dispatch and validation.
agent/​agent_test.go Tests auto-called replacement tools.
agent/​middleware_test.go Tests provider-managed replacement tools.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread agent/middleware.go Outdated
Comment on lines +140 to +144
if invocation == nil {
return nil, errors.New("agent: function invocation middleware called next with nil invocation")
}
if invocation.Function == nil {
return nil, errors.New("agent: function invocation middleware called next with nil function")
@github-actions github-actions Bot added kind:code Changes production behavior or code kind:tests Changes tests, fixtures, or test infrastructure labels Sep 24, 2026
@github-actions

This comment has been minimized.

@github-actions github-actions Bot added the public-api-change Pull Request changes public APIs label Sep 24, 2026

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Go API Consistency Review Agent for #1178 · copilot · auto · 117.9 AIC · ⌖ 6.43 AIC · ⊞ 12K

Comments that could not be inline-anchored

agent/middleware.go:154

The Go port always runs every registered FunctionInvocationMiddleware after a replacement, unlike upstream .NET's default-skip behavior for pending callbacks.

Upstream .NET (FunctionInvocationDelegatingAgent.CoreLogicAsync, FunctionInvocationDelegatingAgent.cs#L293-L317) invokes the replacement function directly (`aw…

@michelle-clayton-work

Copy link
Copy Markdown
Contributor Author

Copilot address PR feedback

…terminal

Co-authored-by: michelle-clayton-work <262183035+michelle-clayton-work@users.noreply.github.com>

Copilot AI commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Copilot address PR feedback

Addressed in 2d445b3: every generated continuation now validates the invocation/function before invoking the next middleware, not just the terminal continuation, so an outer middleware calling next(ctx, nil) (or clearing Function) can no longer reach an inner middleware. Added a two-middleware regression test covering both cases.

@github-actions

Copy link
Copy Markdown
Contributor

API consistency review

Result Scope
findings reported public API / user-visible behavior

Changed Go contract

  • agent.FunctionInvocationContext.Function field: doc comment changed from read-only to writable ("Middleware may replace it before calling next to redirect the invocation"), agent/middleware.go:46-51.
  • functionInvocationTool.Call now reads invocation.Function (the possibly-replaced value) instead of always calling the original t.FuncTool, agent/middleware.go:138-140.
  • New unexported validateContinuation helper rejects a nil invocation or a nil replaced Function at every middleware layer (not just the terminal call), returning an error instead of panicking, agent/middleware.go:151-164.

Upstream evidence reviewed

Implementation Source Contract checked
.NET FunctionInvocationDelegatingAgent.cs (commit 173978e) context.Function replacement, reference-equality gated redirect, restoration of targetBeforeCallback after each callback, WrapWithPendingMiddleware opt-in for later-middleware visibility
.NET FunctionInvocationContextExtensions.cs Public WrapWithPendingMiddleware API and its null/no-active-invocation guards
.NET FunctionInvocationDelegatingAgentTests.cs (RunAsync_FirstMiddlewareReplacesFunction_LaterMiddlewareNotInvokedAsync, RunAsync_MiddlewareReplacesFunction_RequestedFunctionRestoredAsync) Default bypass-later-middleware and restore-after-callback semantics
Go agent/middleware_test.go:286-343 (TestProviderConfig_ManagesToolExecution_FunctionReplacement) Confirms Go's replacement is visible to all later middleware by default, with no restoration

Assessment

  1. Replacement propagation direction inverted from upstream default. Upstream .NET only invokes a replaced function directly (bypassing later middleware layers) unless the middleware explicitly opts in via WrapWithPendingMiddleware, and it restores context.Function to the pre-callback value once each layer's next returns so later middleware/telemetry see the original request. The Go port instead always cascades the replacement forward to every subsequent middleware layer with no restoration, per the passing test at agent/middleware_test.go:311-313. This is a genuine behavior divergence for chains with more than one function-invocation middleware; see the inline comment on agent/middleware.go:148 for a suggested fix (bypass-by-default plus restoration, or an explicit documented divergence).

The nil-invocation/nil-function validation added at every middleware layer (validateContinuation) has no exact upstream analog to compare against (.NET does not defensively null-check a callback-supplied context.Function at each layer) but is a reasonable, additive safety improvement that does not conflict with upstream semantics, so it is not flagged as a parity issue.

Generated by Go API Consistency Review Agent for #1178 · copilot · auto · 142.5 AIC · ⌖ 8.12 AIC · ⊞ 12K · ◷

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by Go API Consistency Review Agent for #1178 · copilot · auto · 142.5 AIC · ⌖ 8.12 AIC · ⊞ 12K

Comment thread agent/middleware.go
}
}
return next(ctx, invocation)
return validateContinuation(next)(ctx, invocation)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parity gap: replaced Function bypasses/mutates behavior differently from upstream WrapWithPendingMiddleware semantics.

Go's invocation.Function replacement here (and the corresponding FunctionInvocationContext.Function doc, agent/middleware.go:46-51) makes the replacement permanently visible to every subsequent middleware layer, in both their "before" and "after" phases, with no restoration after next returns. agent/middleware_test.go:311-313 explicitly asserts that a later registered middleware (second) observes the replacement function as invocation.Function in its own "before" hook — i.e. replacing the function always propagates forward through the whole chain by default.

Upstream .NET (dotnet/src/Microsoft.Agents.AI/FunctionInvocationDelegatingAgent.cs, commit 173978ee93e0ffa5ef4ebdbfe2e94cd6f8e8a996) does the opposite by default:

  • targetBeforeCallback (line 268) captures the function the callback saw on entry.
  • The continuation compares the replacement by reference (ReferenceEquals(target, targetBeforeCallback), line 327): if a callback replaced the function, the replacement is invoked directly, bypassing all remaining middleware layers in the chain (test RunAsync_FirstMiddlewareReplacesFunction_LaterMiddlewareNotInvokedAsync).
  • After each callback's finally block runs, context.Function is explicitly restored to targetBeforeCallback (line 301), so callers and telemetry that read context.Function after next returns still see the originally requested function, not the replacement (test RunAsync_MiddlewareReplacesFunction_RequestedFunctionRestoredAsync).
  • Making later middleware observe/wrap the replacement is an explicit opt-in, done by calling context.WrapWithPendingMiddleware(replacementFunction) (dotnet/src/Microsoft.Agents.AI/FunctionInvocationContextExtensions.cs) before assigning it — it is not the default.

The Go port inverts this default: replacement always cascades to later middleware and there is no restoration for middleware/telemetry running after next returns. This is an observable behavior divergence for any Go middleware chain with more than one function-invocation middleware registered (a common configuration demonstrated in this same test file).

Suggested resolution: either (a) make replacement visible only to the continuation actually invoked (bypassing later middleware by default, mirroring ReferenceEquals gating) and restore invocation.Function to the pre-callback value once each middleware layer's next call returns, or (b) if the Go design intentionally always propagates replacements forward (a reasonable simplification given Go's synchronous, non-ambient-context middleware chain), document this explicit divergence from the upstream default in the Function field doc comment so callers relying on cross-language parity are not surprised.

@qmuntal

Copy link
Copy Markdown
Member

Michelle Clayton (@michelle-clayton-work) see the latest parity findings.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:agent Changes files in the agent area kind:code Changes production behavior or code kind:tests Changes tests, fixtures, or test infrastructure public-api-change Pull Request changes public APIs size:large At most 300 changed lines across at most 10 files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[dotnet-port-api] Allow function middleware to replace tools

4 participants