diff --git a/go/googleadk/Makefile b/go/googleadk/Makefile index 22f1b0ab..159aba45 100644 --- a/go/googleadk/Makefile +++ b/go/googleadk/Makefile @@ -1 +1,35 @@ include ../_shared/go.mk + +# go.mod requires ADK v2.3.0, which calls a toolset's Tools once per agent +# run. ADK v2.6.0 and later call it before every model step, and the MCP +# toolset must list the same way with both. So sync and test also run the MCP +# tests with go.adk-v2.6.mod, which pins ADK v2.6.0. (ADK v2.7.0 needs Go +# 1.26.10, above this plugin's Go floor.) After changing go.mod, run +# `make adk-v2.6-mod` to rebuild that file; sync fails until it matches. +ADK_V2_6 = -modfile=go.adk-v2.6.mod +# -modfile does not work in workspace mode. +ADK_V2_6_GO = GOWORK=off $(GO) +# adk_v2_6_mod writes $(1).mod and $(1).sum: go.mod with ADK v2.6.0. +adk_v2_6_mod = cp go.mod $(1).mod && cp go.sum $(1).sum && \ + $(ADK_V2_6_GO) get -modfile=$(1).mod google.golang.org/adk/v2@v2.6.0 && \ + $(ADK_V2_6_GO) mod tidy -modfile=$(1).mod + +.PHONY: adk-v2.6-mod sync-adk-v2.6 test-adk-v2.6 +sync: sync-adk-v2.6 +test: test-adk-v2.6 + +adk-v2.6-mod: + $(call adk_v2_6_mod,go.adk-v2.6) + +sync-adk-v2.6: + $(ADK_V2_6_GO) mod download $(ADK_V2_6) + $(ADK_V2_6_GO) mod verify $(ADK_V2_6) + @d=$$(mktemp -d); \ + if ! ( $(call adk_v2_6_mod,$$d/go) ) > $$d/log 2>&1; then cat $$d/log; rm -rf $$d; exit 1; fi; \ + if ! cmp -s $$d/go.mod go.adk-v2.6.mod || ! cmp -s $$d/go.sum go.adk-v2.6.sum; then \ + rm -rf $$d; echo "go.adk-v2.6.mod is out of date with go.mod: run make adk-v2.6-mod"; exit 1; \ + fi; \ + rm -rf $$d + +test-adk-v2.6: + $(ADK_V2_6_GO) test $(ADK_V2_6) -count=1 -timeout=10m -run 'Mcp|MCP' $(TEST_ARGS) ./... diff --git a/go/googleadk/README.md b/go/googleadk/README.md index 31b869c4..f24e8ee7 100644 --- a/go/googleadk/README.md +++ b/go/googleadk/README.md @@ -215,14 +215,47 @@ outside Temporal. To use another model, build the agent with handler retried, wrap its cause with `%w` or return a retryable `temporal.ApplicationError`. - If an agent or a runner plugin has a tool-error callback, or an after-tool - callback that treats errors differently from results, don't run Workers of an - earlier version and this one on the same Task Queue. An earlier Workflow - Worker hands this version's tool error to ADK as a result and skips the - callback, so the run fails to replay on the other version or closes as - `Failed`. Deploy this version on a new Task Queue, or stop every earlier - Worker before you start this version's. If you roll back, keep runs that ran - on this version on its Workers until they close. + - The toolset lists its tools once per model step, at the same point with + every ADK release, so its Histories replay from ADK v2.3.0 to v2.7.0. A + toolset wrapped in another that does not forward `ProcessRequest`, such as + `tool.FilterToolset` or `tool.WithConfirmation`, lists each time ADK asks + for its tools: in every model step with ADK v2.6.0 and later, and only in + the first step of an agent run before. So a History in which an agent with + such a toolset took a second model step in one run does not replay across + ADK v2.6.0; one-step runs do. Use `MCPToolsetOptions.ToolFilter` instead of + `tool.FilterToolset`, and `mcptoolset`'s `RequireConfirmation` option + instead of `tool.WithConfirmation`. + - From the second model step of an agent run, a toolset in the agent's + `Toolsets` declares only the tools the server lists, and `ToolFilter` + allows, for that step. A model call to an MCP tool it no longer declares + gets ADK's tool-not-found error: no `CallMcpTool` Activity and no before- + or after-tool callbacks run, but on-tool-error callbacks do. In the first + step, and with a wrapped toolset on ADK v2.5.0 and earlier, such a call + still runs `CallMcpTool`; for a tool the server no longer lists, it fails + with a non-retryable `McpError`. + - From the second model step, your own `Tools` calls on such a toolset, for + example from an `InstructionProvider`, return the agent run's first list, + so they don't see changes the server or `ToolFilter` makes during the run. + To read the current list, call `Tools` on a second `NewMCPToolset` that is + not in the agent's `Toolsets`. + + To upgrade to this version: + - A run that used `NewMCPToolset` directly in an agent's `Toolsets` before + you deploy this version fails replay on it with `[TMPRL1100]`, with or + without a tool call, and so does such a run on this version after a + rollback. Let those runs finish first, or keep them on the earlier version + with Worker Versioning. Runs whose MCP toolsets are all wrapped replay and + continue across the upgrade and a rollback, with the ADK release they ran + on. + - If an agent or a runner plugin has a tool-error callback, or an after-tool + callback that treats errors differently from results, don't run Workers of + an earlier version and this one on the same Task Queue. An earlier Workflow + Worker hands this version's tool error to ADK as a result and skips the + callback, so the run fails to replay on the other version or closes as + `Failed`. Deploy this version on a new Task Queue or, once the runs in the + item above have finished, stop every earlier Worker before you start this + version's. If you roll back, keep runs that ran on this version on its + Workers until they close. - **Remote A2A agents.** Build ADK's remote agent (`remoteagent.NewA2A`) with `AgentCardProvider: googleadk.NewA2AAgentCardProvider(cardURL, activityOptions)` and `ClientProvider: googleadk.NewA2AClientProvider(activityOptions)`. The card diff --git a/go/googleadk/context.go b/go/googleadk/context.go index a1b12120..014af9fa 100644 --- a/go/googleadk/context.go +++ b/go/googleadk/context.go @@ -69,6 +69,8 @@ func NewContext(ctx workflow.Context, opts ...ContextOption) context.Context { base = platform.WithTimeProvider(base, func() time.Time { return workflow.Now(ctx) }) base = platform.WithUUIDProvider(base, newDeterministicUUIDProvider(ctx)) base = platform.WithTaskRunner(base, newWorkflowTaskRunner(ctx, cfg.sequentialToolFanout)) + // Lets each MCP toolset list its tools once per model step. + base = context.WithValue(base, mcpListingsKey{}, &mcpListings{}) return base } diff --git a/go/googleadk/doc.go b/go/googleadk/doc.go index fbde2850..190e9f59 100644 --- a/go/googleadk/doc.go +++ b/go/googleadk/doc.go @@ -34,7 +34,10 @@ // This package targets Google ADK for Go v2 (google.golang.org/adk/v2). It // depends on the deterministic platform seams (platform.WithTimeProvider, // platform.WithUUIDProvider and platform.WithTaskRunner) and the public -// tool-declaration packer (tool/toolutils.PackTool). See go.mod. +// tool-declaration packer (tool/toolutils.PackTool). See go.mod. MCP toolsets +// also read unexported fields of ADK's context types, in its agent and +// internal/context packages, to tell agent runs apart; when an ADK release +// changes them, TestMcpAgentRunOfReadsOnlyADKContexts fails. package googleadk // PluginName identifies this integration for Temporal telemetry and usage diff --git a/go/googleadk/go.adk-v2.6.mod b/go/googleadk/go.adk-v2.6.mod new file mode 100644 index 00000000..381af74b --- /dev/null +++ b/go/googleadk/go.adk-v2.6.mod @@ -0,0 +1,80 @@ +module go.temporal.io/googleadk + +go 1.26.6 + +require ( + github.com/a2aproject/a2a-go/v2 v2.6.0 + github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.8.0 + github.com/nexus-rpc/sdk-go v0.7.0 + github.com/stretchr/testify v1.12.1 + go.opentelemetry.io/otel v1.47.0 + go.opentelemetry.io/otel/log v1.47.0 + go.opentelemetry.io/otel/metric v1.47.0 + go.opentelemetry.io/otel/sdk v1.47.0 + go.opentelemetry.io/otel/sdk/log v1.47.0 + go.opentelemetry.io/otel/sdk/metric v1.47.0 + go.opentelemetry.io/otel/trace v1.47.0 + go.temporal.io/api v1.63.4 + go.temporal.io/sdk v1.48.0 + go.temporal.io/sdk/contrib/workflowstreams v0.1.1 + google.golang.org/adk/v2 v2.6.0 + google.golang.org/genai v1.74.0 + google.golang.org/grpc v1.84.0 +) + +require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.24.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.3.0 // indirect + cloud.google.com/go/compute/metadata v0.10.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.15 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect + github.com/google/s2a-go v0.1.10 // indirect + github.com/google/safehtml v0.1.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.22 // indirect + github.com/googleapis/gax-go/v2 v2.26.2 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect + github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect + github.com/openai/openai-go/v3 v3.71.1 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/stretchr/objx v0.5.3 // indirect + github.com/tidwall/gjson v1.19.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.37.0 // indirect + golang.org/x/sync v0.24.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/time v0.16.0 // indirect + google.golang.org/api v0.300.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/protobuf v1.36.12 // indirect + rsc.io/omap v1.2.0 // indirect + rsc.io/ordered v1.1.1 // indirect +) + +// ADK v2.3.0 supports the patched OpenTelemetry log API. Its platform +// provider seams are available since v2.1.0; request-order confirmation +// resume, required for replay-stable multi-decision resumes, since v2.2.0. diff --git a/go/googleadk/go.adk-v2.6.sum b/go/googleadk/go.adk-v2.6.sum new file mode 100644 index 00000000..7b4b9522 --- /dev/null +++ b/go/googleadk/go.adk-v2.6.sum @@ -0,0 +1,194 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.24.0 h1:UYMbF8otPZnLAkNJ5/LYQYOq0ARcJS1P4JqTeMKbCYU= +cloud.google.com/go/auth v0.24.0/go.mod h1:IFG/AMA1VWfuTrdbieEsB2GcpJyJV/phGAvogkOoPR4= +cloud.google.com/go/auth/oauth2adapt v0.3.0 h1:FY8oSZpCYoUNv6QxVODuMjQz4IlSOVeiQtZ08vLPz88= +cloud.google.com/go/auth/oauth2adapt v0.3.0/go.mod h1:7+2uCm7++XFO+/lN06c2HXpDXb/NMNn2/UwyBPbTnkk= +cloud.google.com/go/compute/metadata v0.10.0 h1:pyKMUQSwchgkIBBJGdILqQbs/BNJXqwSA7Ej6LAvvtY= +cloud.google.com/go/compute/metadata v0.10.0/go.mod h1:rGFHRrIif570kSibjFTMbt6/4/tzgJWFGI/HVol4GIk= +github.com/a2aproject/a2a-go/v2 v2.6.0 h1:QflB0gHxOP0o5quvA/h9EbYgfOsmRsSmFkeBYtKdwwk= +github.com/a2aproject/a2a-go/v2 v2.6.0/go.mod h1:rvFIb0sBJl2qmJRYU2fQxI6hhIQksmGGMEhT/uRwWww= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw= +github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.10 h1:EMp+aOuXN6l8cE/gjF5Bt+vyZxsUuyCWe9chDWR/+uU= +github.com/google/s2a-go v0.1.10/go.mod h1:pz4tyvwXvJLLbyrkh6FW1eS2zPUXMaTmyNhYtyP2tNw= +github.com/google/safehtml v0.1.0 h1:EwLKo8qawTKfsi0orxcQAZzu07cICaBeFMegAU9eaT8= +github.com/google/safehtml v0.1.0/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.22 h1:NU4XpII6jD+Dxcot94fqjE+AfJoE/lQP9q3faYGzC/c= +github.com/googleapis/enterprise-certificate-proxy v0.3.22/go.mod h1:L3D/IQExI6LqEjBdXcZQ1WluSgigQmSwBboFstVPM4w= +github.com/googleapis/gax-go/v2 v2.26.2 h1:ydkmNXxj7bEmmeK5AihkKnWxyOyBR9TDebvp5L5izk8= +github.com/googleapis/gax-go/v2 v2.26.2/go.mod h1:sMKqnMesnKH+3wiRJROcttA+cJoZoGbZl1vDQ8XYtGk= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4= +github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= +github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80= +github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y= +github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI= +github.com/nexus-rpc/sdk-go v0.7.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= +github.com/openai/openai-go/v3 v3.71.1 h1:s+gWLEUvnV3sZqtZ+mlWZ3ePTtfuIV5EBY+fsnb8Njo= +github.com/openai/openai-go/v3 v3.71.1/go.mod h1:+dSPa+nbX+dNoXg1jecMnVpgRP+E/5IBA6Jiz9Pc8WM= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= +github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= +go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= +go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= +go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= +go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= +go.opentelemetry.io/otel/sdk/log v1.47.0 h1:W4N/ZfgpkRRnmARnqYoQ6BrWA0mrj8IzVDNLlpmUJZY= +go.opentelemetry.io/otel/sdk/log v1.47.0/go.mod h1:Wu74Z2EmOKXi2FleSNAb0Hyau2G+r5kubSEMJw5aAMs= +go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= +go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= +go.temporal.io/api v1.63.4 h1:p4dVIAP3dJop0MfcyH9QSzjU7+V/ttLDhxFhSRUar58= +go.temporal.io/api v1.63.4/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ= +go.temporal.io/sdk v1.48.0 h1:WDctKDVuh0Z8Nf7euAyqs/EwcPg1JTIIq1Fut8Tq118= +go.temporal.io/sdk v1.48.0/go.mod h1:SHv3+fLzD0GGZAwf0xNSvu8UmO1nFgG9WBSYoowApIk= +go.temporal.io/sdk/contrib/workflowstreams v0.1.1 h1:nX/pBWt5ED7J4tneJKuv9I15Exve5voPUlYg3eXYx4k= +go.temporal.io/sdk/contrib/workflowstreams v0.1.1/go.mod h1:G1g8aaA6gQw7ClunFLQKjDzIsmU9dR7FamCsDnKStAc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/adk/v2 v2.6.0 h1:ZgMfKWzzB1H3qQ/6JV3pGkm7rcfNZFRMkgvOLBtmJTk= +google.golang.org/adk/v2 v2.6.0/go.mod h1:Z3nLJKDFE1UzbEs3JXiTnAtWjS92kXo4cI8S8FFLRrc= +google.golang.org/api v0.300.0 h1:2rvPV2bqnPuHOaF4gGOBiT1IIc6JVXYyHCkZeqdzjNk= +google.golang.org/api v0.300.0/go.mod h1:tKfTSDfK+0FlOVl8N30VL5fU5TuaEkJjvdyTIKNwzPg= +google.golang.org/genai v1.74.0 h1:n7lBIax9PkdrmfxsNilfFPOwo1ahelozeZj2Ady0lgw= +google.golang.org/genai v1.74.0/go.mod h1:2j40fGpqPPIZNjaDKmjGGjYwCJi6Nm2ofmyP65GnqtY= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= +google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= +google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/omap v1.2.0 h1:c1M8jchnHbzmJALzGLclfH3xDWXrPxSUHXzH5C+8Kdw= +rsc.io/omap v1.2.0/go.mod h1:C8pkI0AWexHopQtZX+qiUeJGzvc8HkdgnsWK4/mAa00= +rsc.io/ordered v1.1.1 h1:1kZM6RkTmceJgsFH/8DLQvkCVEYomVDJfBRLT595Uak= +rsc.io/ordered v1.1.1/go.mod h1:evAi8739bWVBRG9aaufsjVc202+6okf8u2QeVL84BCM= diff --git a/go/googleadk/mcp.go b/go/googleadk/mcp.go index bc739858..6d622c06 100644 --- a/go/googleadk/mcp.go +++ b/go/googleadk/mcp.go @@ -4,6 +4,9 @@ import ( "context" "errors" "fmt" + "reflect" + "sync" + "weak" "go.temporal.io/sdk/activity" "go.temporal.io/sdk/workflow" @@ -23,7 +26,12 @@ type MCPToolsetOptions struct { // Config.MCPToolsets so the worker-side factory can be found. Name string // ToolFilter optionally restricts which advertised MCP tools are exposed to - // the model. It runs on the workflow side over declaration-only proxy tools. + // the model. It runs on the workflow side over declaration-only proxy tools, + // each time the toolset lists: in every Tools call before the toolset's + // first ProcessRequest of an agent run, and in every ProcessRequest. With + // only ADK's own calls, that is twice in the first model step and once in + // each later one. ADK resumes confirmed tool calls from the first result and + // draws its generated IDs for it. ToolFilter tool.Predicate // ActivityOptions overrides the per-call Activity options for the ListMcpTools // and CallMcpTool Activities. A zero StartToCloseTimeout falls back to the @@ -58,9 +66,75 @@ type mcpToolset struct { func (m *mcpToolset) Name() string { return m.name } -// Tools lists the remote MCP tools via the ListMcpTools Activity and wraps each -// as a declaration-only proxy tool. +// Tools returns the toolset's declaration-only proxy tools. Until the +// toolset's ProcessRequest has run in the agent run, it lists them via the +// ListMcpTools Activity, applies ToolFilter, and keeps the list for the +// ProcessRequest call that follows in the same model step. After that, +// ProcessRequest lists, and Tools returns the list the first Tools call of the +// agent run returned, without an Activity and without running the filter. See +// ProcessRequest. +// +// So the first Tools call of each agent run lists and filters. ADK resumes a +// confirmed tool call from this list before any ProcessRequest of the run, so +// a tool the filter no longer allows does not run. The later calls, which ADK +// v2.6.0 and later make before every model step, issue no Workflow commands, +// so the toolset and its filter issue them at the same points on every ADK +// release. They return the list ADK v2.5.0 and earlier keep for the whole +// agent run, even when the MCP server, the filter or another Tools call, such +// as one from an InstructionProvider, has changed it since: ADK draws a UUID +// from the workflow's random stream for each tool it keeps before every model +// step, so the IDs it generates, such as tool confirmation IDs, stay the same +// on every release. From the second model step, the proxy tools from that +// list no longer pack themselves, so ProcessRequest alone decides what the +// model request declares and dispatches. In the first step ADK packs them +// before ProcessRequest runs, so that request also declares the tools of the +// first list. +// +// One case still keeps different lists on different releases: an agent that +// reaches the same toolset more than once, for example directly and through +// tool.FilterToolset, when the MCP server changes its tools between those +// listings in the first step. ADK v2.5.0 and earlier keep each list; with +// v2.6.0 the later Tools calls return the first list for all of them. +// +// Agent and model callbacks get contexts that wrap the agent run's context, +// not its invocation, so their Tools calls are not part of the run: each one +// lists and filters, on every release. +// +// A toolset wrapped in a toolset without ProcessRequest, such as +// tool.FilterToolset, lists and filters on every call, because nothing else +// refreshes its list. func (m *mcpToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + l := listingsFrom(ctx) + run := l.startRun(m, ctx) + if tools, ok := l.processedList(run); ok { + return tools, nil + } + tools, err := m.list(ctx) + if err != nil { + return nil, err + } + allowed := m.allowed(ctx, tools) + l.keepPending(run, tools, allowed) + return allowed, nil +} + +// allowed returns the tools the toolset's ToolFilter allows for ctx. +func (m *mcpToolset) allowed(ctx agent.ReadonlyContext, tools []tool.Tool) []tool.Tool { + if m.filter == nil { + return tools + } + out := make([]tool.Tool, 0, len(tools)) + for _, t := range tools { + if m.filter(ctx, t) { + out = append(out, t) + } + } + return out +} + +// list runs the ListMcpTools Activity and wraps each declaration as a proxy +// tool. It does not apply ToolFilter; see allowed. +func (m *mcpToolset) list(ctx agent.ReadonlyContext) ([]tool.Tool, error) { wfCtx, ok := workflowContext(ctx) if !ok { return nil, errMissingContext @@ -72,22 +146,39 @@ func (m *mcpToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { } tools := make([]tool.Tool, 0, len(decls)) for _, d := range decls { - pt := &mcpProxyTool{toolset: m.name, decl: d, activityOptions: m.activityOptions} - if m.filter == nil || m.filter(ctx, pt) { - tools = append(tools, pt) - } + tools = append(tools, &mcpProxyTool{owner: m, toolset: m.name, decl: d, activityOptions: m.activityOptions}) } return tools, nil } -// ProcessRequest packs every advertised proxy tool's declaration into the model -// request. ADK calls this during toolset preprocessing. +// ProcessRequest packs the toolset's current proxy tools into the model +// request. ADK calls it before each model call, after the request processors +// (instructions and the others) have run. +// +// ADK v2.5.0 and earlier call Tools in the first model step of an agent run, +// before the request processors, and in later steps only while the agent has +// no tools at all. v2.6.0 and later call Tools before the request processors +// in every step. To schedule the same commands in the same order on every ADK +// release, the toolset lists in Tools only until its first ProcessRequest of +// the agent run, and in ProcessRequest from then on. With only ADK's own +// calls, each model step lists once: the first through Tools, with +// ProcessRequest reusing that list, and every later step here. Other Tools +// calls before the first ProcessRequest, such as an InstructionProvider's, +// list too. From the second step, the request carries this list +// alone, so a tool the MCP server no longer advertises is neither declared nor +// dispatched, whatever list ADK kept from an earlier Tools call. func (m *mcpToolset) ProcessRequest(ctx agent.Context, req *model.LLMRequest) error { - tools, err := m.Tools(ctx) - if err != nil { - return err + l := listingsFrom(ctx) + run := l.current(m, ctx) + tools, ok := l.takePending(run) + if !ok { + var err error + if tools, err = m.list(ctx); err != nil { + return err + } } - for _, t := range tools { + l.processed(run) + for _, t := range m.allowed(ctx, tools) { if err := packTool(req, t.(*mcpProxyTool)); err != nil { return err } @@ -95,11 +186,277 @@ func (m *mcpToolset) ProcessRequest(ctx agent.Context, req *model.LLMRequest) er return nil } +// mcpListingsKey is the private context key for the mcpListings NewContext +// installs. +type mcpListingsKey struct{} + +// mcpListings records, for each MCP toolset and agent run, the lists the +// toolset took, so that Tools and ProcessRequest list once per model step at +// the same point on every ADK release. +type mcpListings struct { + mu sync.Mutex + runs map[mcpRunKey]*mcpRunState + // warned reports that fallBack has logged. + warned bool +} + +// mcpRunKey identifies a toolset in one agent run: one agent in one execution +// of a runner.Run iterator. +type mcpRunKey struct { + toolset *mcpToolset + // run points weakly to the invocation ADK creates each time an agent runs: + // in each execution of a runner.Run iterator, a resume, a retry or another + // pass over a kept iterator included, and in each of the concurrent runs + // of one agent that share an invocation ID, such as a custom agent running + // it on several branches. See agentRunOf. The pointer is weak so that the + // store does not keep a finished run's invocation, and the session events + // it holds, alive; once the garbage collector reclaims it, startRun drops + // the state. + run weak.Pointer[byte] +} + +// mcpRunState is the state of a toolset in one agent run. +type mcpRunState struct { + // pending is the list Tools took for the ProcessRequest call that follows + // in the same model step, when hasPending is set. + pending []tool.Tool + hasPending bool + // processed reports that the toolset's ProcessRequest has run in the agent + // run. From then on ProcessRequest lists, and Tools does not. + processed bool + // returned is the list the first Tools call of the agent run returned, + // when hasReturned is set. Tools returns it again once processed. + returned []tool.Tool + hasReturned bool +} + +// listingsFrom returns the mcpListings NewContext installed, or nil. +func listingsFrom(ctx context.Context) *mcpListings { + l, _ := ctx.Value(mcpListingsKey{}).(*mcpListings) + return l +} + +// agentRunOf returns a weak pointer to the invocation of the agent run ctx +// belongs to, and whether ctx tells it. ADK runs each agent on a new +// invocation, which it passes, wrapped, both to the toolset's Tools and to the +// ProcessRequest calls of the toolset and its tools: +// +// - Tools gets a ReadonlyContext whose exported InvocationContext field holds +// the agent run's context. +// - ProcessRequest gets a tool context, whose unexported context field holds +// a copy of that context. +// +// The agent run's context, and its copy, hold the invocation in an unexported +// invocationContext field, or are the invocation themselves. It is not the +// invocation ID, agent name or RunConfig: concurrent runs of one agent can +// share all three. +// +// It reads only ADK's own context types, and only their direct fields. Any +// other context, such as one a caller wraps around ADK's, does not tell its +// agent run. +// +// Weak pointers made from the same invocation are equal, and stay distinct +// from those made from any other, even after it is reclaimed. +func agentRunOf(ctx context.Context) (weak.Pointer[byte], bool) { + v := reflect.ValueOf(ctx) + for depth := range 4 { + if v.Kind() == reflect.Interface { + v = v.Elem() + } + if !isADKContext(v) { + return weak.Pointer[byte]{}, false + } + if ic, ok := interfaceField(v, "invocationContext"); ok { + return weakTo(ic.Elem()) + } + next, ok := interfaceField(v, "InvocationContext") + if !ok { + next, ok = interfaceField(v, "context") + } + if !ok { + if depth == 0 { + return weak.Pointer[byte]{}, false + } + return weakTo(v) + } + v = next + } + return weak.Pointer[byte]{}, false +} + +// isADKContext reports whether v points to a struct of one of the ADK +// packages that define its contexts. +func isADKContext(v reflect.Value) bool { + if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct { + return false + } + switch v.Elem().Type().PkgPath() { + case "google.golang.org/adk/v2/agent", "google.golang.org/adk/v2/internal/context": + return true + } + return false +} + +// weakTo returns a weak pointer to the ADK context v points to. +func weakTo(v reflect.Value) (weak.Pointer[byte], bool) { + if !isADKContext(v) { + return weak.Pointer[byte]{}, false + } + return weak.Make((*byte)(v.UnsafePointer())), true +} + +// interfaceField returns the non-nil interface field name of the struct v +// points to, if it is a direct field, not one promoted from an embedded +// struct. +func interfaceField(v reflect.Value, name string) (reflect.Value, bool) { + sf, ok := v.Elem().Type().FieldByName(name) + if !ok || len(sf.Index) != 1 { + return reflect.Value{}, false + } + f := v.Elem().FieldByIndex(sf.Index) + if f.Kind() != reflect.Interface || f.IsNil() { + return reflect.Value{}, false + } + return f, true +} + +// startRun returns the state of the toolset in ctx's agent run, for Tools, +// and starts it if needed. When ctx does not tell its agent run, it returns +// nil. +// +// Without a state, the toolset lists and filters on every Tools and +// ProcessRequest call, and its proxy tools pack themselves. +// +// When it starts a state, it also drops the states of finished agent runs, +// whose invocation the garbage collector has reclaimed. No later call can +// belong to such a run, so a long conversation on one NewContext keeps only +// the states of runs that may still call the toolset. +func (l *mcpListings) startRun(m *mcpToolset, ctx agent.ReadonlyContext) *mcpRunState { + if l == nil { + return nil + } + run, ok := agentRunOf(ctx) + if !ok { + l.fallBack(m, ctx) + return nil + } + k := mcpRunKey{m, run} + l.mu.Lock() + defer l.mu.Unlock() + if s, ok := l.runs[k]; ok { + return s + } + for key := range l.runs { + if key.run.Value() == nil { + delete(l.runs, key) + } + } + if l.runs == nil { + l.runs = map[mcpRunKey]*mcpRunState{} + } + s := &mcpRunState{} + l.runs[k] = s + return s +} + +// current returns the state the toolset's Tools call kept for ctx's agent run, +// or nil. ADK calls Tools before ProcessRequest in the first model step of +// every agent run. +func (l *mcpListings) current(m *mcpToolset, ctx context.Context) *mcpRunState { + if l == nil { + return nil + } + run, ok := agentRunOf(ctx) + if !ok { + l.fallBack(m, ctx) + return nil + } + l.mu.Lock() + defer l.mu.Unlock() + return l.runs[mcpRunKey{m, run}] +} + +// fallBack logs a warning, once per NewContext and not during replay, that +// the toolset cannot tell ctx's agent run. This happens when ADK changes the +// contexts agentRunOf reads, or when the caller wraps them. +func (l *mcpListings) fallBack(m *mcpToolset, ctx context.Context) { + wfCtx, ok := workflowContext(ctx) + if !ok || workflow.IsReplaying(wfCtx) { + return + } + l.mu.Lock() + warned := l.warned + l.warned = true + l.mu.Unlock() + if !warned { + workflow.GetLogger(wfCtx).Warn("MCP toolset cannot tell which agent run called it, so it lists on every call; its Histories may not replay with another ADK release", + "toolset", m.name) + } +} + +// processedList returns the list the first Tools call of the run returned, +// once the toolset's ProcessRequest has run there. +func (l *mcpListings) processedList(s *mcpRunState) ([]tool.Tool, bool) { + if s == nil { + return nil, false + } + l.mu.Lock() + defer l.mu.Unlock() + return s.returned, s.processed +} + +// keepPending keeps a list Tools took for the ProcessRequest call that +// follows, and, in the first Tools call of the agent run, the list Tools +// returns. Other Tools calls can come before that ProcessRequest, such as one +// from an InstructionProvider; they replace the pending list but not the +// returned one, which ADK v2.5.0 and earlier keep. +func (l *mcpListings) keepPending(s *mcpRunState, tools, returned []tool.Tool) { + if s == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + s.pending, s.hasPending = tools, true + if !s.hasReturned { + s.returned, s.hasReturned = returned, true + } +} + +// takePending returns and drops the list Tools kept, if any. +func (l *mcpListings) takePending(s *mcpRunState) ([]tool.Tool, bool) { + if s == nil { + return nil, false + } + l.mu.Lock() + defer l.mu.Unlock() + tools, ok := s.pending, s.hasPending + s.pending, s.hasPending = nil, false + return tools, ok +} + +// processed marks the toolset's ProcessRequest as run in the agent run. +func (l *mcpListings) processed(s *mcpRunState) { + if s == nil { + return + } + l.mu.Lock() + defer l.mu.Unlock() + s.processed = true +} + +// packedByToolset reports whether the toolset's ProcessRequest has run in the +// agent run. Its proxy tools then leave packing to it. +func (l *mcpListings) packedByToolset(m *mcpToolset, ctx agent.ReadonlyContext) bool { + _, ok := l.processedList(l.current(m, ctx)) + return ok +} + // mcpProxyTool is a declaration-only stand-in for a remote MCP tool. The model // sees its declaration; when the model calls it, Run (invoked in-workflow by // ADK) dispatches the CallMcpTool Activity so the live, stateful MCP session // runs worker-side, never in the workflow. type mcpProxyTool struct { + owner *mcpToolset toolset string decl *genai.FunctionDeclaration activityOptions workflow.ActivityOptions @@ -110,7 +467,14 @@ func (t *mcpProxyTool) Description() string { return t.decl. func (t *mcpProxyTool) IsLongRunning() bool { return false } func (t *mcpProxyTool) Declaration() *genai.FunctionDeclaration { return t.decl } +// ProcessRequest packs the proxy tool. ADK calls it for each tool it kept from +// a Tools call. Once the owning toolset's ProcessRequest packs the current list +// in the agent run, it does nothing: the tool may come from an older list, which +// ADK v2.5.0 and earlier keep for the whole agent run. func (t *mcpProxyTool) ProcessRequest(ctx agent.Context, req *model.LLMRequest) error { + if listingsFrom(ctx).packedByToolset(t.owner, ctx) { + return nil + } return packTool(req, t) } @@ -348,9 +712,9 @@ func (a *Activities) mcpToolset(ctx context.Context, name string) (tool.Toolset, // req.Tools (json:"-", so it never crosses the wire) so ADK can later resolve it // to dispatch. It is idempotent: ADK runs both toolPreprocess (each expanded // toolset tool's ProcessRequest) and toolsetPreprocess (the toolset's own -// ProcessRequest), so a toolset tool is packed twice with identical -// declarations. A name already present is skipped, rather than erroring as -// toolutils.PackTool does on a duplicate. +// ProcessRequest), so in the first model step of an agent run a toolset tool +// is packed twice with identical declarations. A name already present is +// skipped, rather than erroring as toolutils.PackTool does on a duplicate. func packTool(req *model.LLMRequest, t toolutils.Tool) error { if req.Tools != nil { if _, ok := req.Tools[t.Name()]; ok { diff --git a/go/googleadk/mcp_hitl_test.go b/go/googleadk/mcp_hitl_test.go index 995662a9..25d57a0a 100644 --- a/go/googleadk/mcp_hitl_test.go +++ b/go/googleadk/mcp_hitl_test.go @@ -2,6 +2,8 @@ package googleadk_test import ( "context" + "errors" + "fmt" "sync/atomic" "testing" @@ -15,6 +17,7 @@ import ( "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/agent/workflowagents/sequentialagent" "google.golang.org/adk/v2/runner" "google.golang.org/adk/v2/session" "google.golang.org/adk/v2/tool" @@ -52,6 +55,21 @@ func guardedMCPFactory() googleadk.MCPFactory { } } +// flakyToolset is a toolset without tools whose Tools call fails while fail is +// set, the way a toolset fails to reach its server. +type flakyToolset struct { + fail *bool +} + +func (flakyToolset) Name() string { return "flaky" } + +func (f flakyToolset) Tools(agent.ReadonlyContext) ([]tool.Tool, error) { + if *f.fail { + return nil, errors.New("flaky toolset unavailable") + } + return nil, nil +} + // mcpHitlResult is the serializable output of mcpHitlWorkflow. type mcpHitlResult struct { // Paused reports that the first Run pass paused on a pending confirmation. @@ -82,17 +100,34 @@ type mcpHitlResult struct { // ADK re-dispatches the original call with the decision attached; the // snapshot carries it to the worker-side tool, which executes on // approval or rejects on denial. -func mcpHitlWorkflow(ctx workflow.Context, confirm bool) (mcpHitlResult, error) { - root, err := llmagent.New(llmagent.Config{ +// +// See mcpHitlCase for the variations. +func mcpHitlWorkflow(ctx workflow.Context, c mcpHitlCase) (mcpHitlResult, error) { + excluded, flaky := false, false + assistant, err := llmagent.New(llmagent.Config{ Name: "assistant", Description: "root assistant", Model: googleadk.NewModel("fake-model"), Instruction: "be helpful", - Toolsets: []tool.Toolset{googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{Name: "guarded-server"})}, + Toolsets: []tool.Toolset{googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{ + Name: "guarded-server", + ToolFilter: func(_ agent.ReadonlyContext, t tool.Tool) bool { + return !excluded || t.Name() != "guarded" + }, + }), flakyToolset{&flaky}}, }) if err != nil { return mcpHitlResult{}, err } + root := assistant + if c.SequentialRoot { + if root, err = sequentialagent.New(sequentialagent.Config{AgentConfig: agent.Config{ + Name: "pipeline", + SubAgents: []agent.Agent{assistant}, + }}); err != nil { + return mcpHitlResult{}, err + } + } r, err := runner.New(runner.Config{ AppName: "test-app", Agent: root, @@ -108,7 +143,8 @@ func mcpHitlWorkflow(ctx workflow.Context, confirm bool) (mcpHitlResult, error) // --- Pass 1: run until the agent pauses on a confirmation. --- var pass1 []*session.Event msg := genai.NewContentFromText("run the guarded tool", genai.RoleUser) - for ev, rerr := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) { + run := r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) + for ev, rerr := range run { if rerr != nil { return mcpHitlResult{}, rerr } @@ -131,9 +167,31 @@ func mcpHitlWorkflow(ctx workflow.Context, confirm bool) (mcpHitlResult, error) // --- Pass 2: simulate the human's decision, then resume. --- resume := googleadk.ConfirmationResponse(googleadk.ConfirmationDecision{ FunctionCallID: pending[0].FunctionCallID, - Confirmed: confirm, + Confirmed: c.Confirm, }) - for ev, rerr := range r.Run(adkCtx, "user-1", "session-1", resume, agent.RunConfig{}) { + if c.ReuseMessage || c.ReuseIterator { + *msg = *resume + resume = msg + } + if c.RetryResume { + flaky = true + var failed error + for _, rerr := range r.Run(adkCtx, "user-1", "session-1", resume, agent.RunConfig{}) { + if rerr != nil { + failed = rerr + break + } + } + if failed == nil { + return res, errors.New("the first resume did not fail") + } + flaky = false + } + excluded = c.ExcludeBeforeResume + if !c.ReuseIterator { + run = r.Run(adkCtx, "user-1", "session-1", resume, agent.RunConfig{}) + } + for ev, rerr := range run { if rerr != nil { return res, rerr } @@ -161,6 +219,31 @@ func mcpHitlWorkflow(ctx workflow.Context, confirm bool) (mcpHitlResult, error) return res, nil } +// mcpHitlCase selects the variations of mcpHitlWorkflow. +type mcpHitlCase struct { + // Confirm is the human's decision. + Confirm bool + // ExcludeBeforeResume makes the toolset's ToolFilter stop allowing the + // guarded tool after pass 1, so the approved call must not run on resume. + ExcludeBeforeResume bool + // ReuseMessage makes pass 2 overwrite pass 1's message with the decision + // and resume with the same *genai.Content, the way a caller that receives + // each message into one variable does. + ReuseMessage bool + // RetryResume makes the first resume fail because another toolset cannot + // list its tools, after the MCP toolset listed and filtered. The filter + // changes only then, and the workflow retries the resume with the same + // message. + RetryResume bool + // SequentialRoot runs the assistant under a sequential agent, so the + // runner's root is not an LLM agent. + SequentialRoot bool + // ReuseIterator makes pass 2 overwrite pass 1's message with the decision + // and iterate pass 1's runner.Run iterator again instead of calling + // runner.Run. + ReuseIterator bool +} + // newMcpHitlEnv builds a test environment with mcpHitlWorkflow registered and // the guarded MCP toolset wired. func newMcpHitlEnv(t *testing.T, s *testsuite.WorkflowTestSuite) (*testsuite.TestWorkflowEnvironment, *activityCounter) { @@ -185,25 +268,35 @@ func newMcpHitlEnv(t *testing.T, s *testsuite.WorkflowTestSuite) (*testsuite.Tes // surfaces it, with the original call attached for the human), and the approval // flows back through the snapshot so the tool executes exactly once. func TestMCPToolConfirmationPausesAndResumes(t *testing.T) { - mcpGuardedRuns.Store(0) + for _, tc := range []mcpHitlCase{ + {}, + {ReuseMessage: true}, + {ReuseMessage: true, RetryResume: true}, + {SequentialRoot: true, ReuseIterator: true}, + } { + tc.Confirm = true + t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) { + mcpGuardedRuns.Store(0) - var s testsuite.WorkflowTestSuite - env, counter := newMcpHitlEnv(t, &s) - env.ExecuteWorkflow(mcpHitlWorkflow, true) + var s testsuite.WorkflowTestSuite + env, counter := newMcpHitlEnv(t, &s) + env.ExecuteWorkflow(mcpHitlWorkflow, tc) - require.True(t, env.IsWorkflowCompleted()) - require.NoError(t, env.GetWorkflowError()) + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) - var res mcpHitlResult - require.NoError(t, env.GetWorkflowResult(&res)) + var res mcpHitlResult + require.NoError(t, env.GetWorkflowResult(&res)) - require.True(t, res.Paused, "agent must pause on a pending confirmation") - assert.Equal(t, "guarded", res.OriginalCallName, "the human must see the original tool call") - assert.NotEmpty(t, res.PendingHint) - assert.EqualValues(t, 1, mcpGuardedRuns.Load(), "guarded MCP tool must run exactly once, only after approval") - assert.Contains(t, res.ResumedToolResponses, "guarded") - assert.Equal(t, 2, counter.get(googleadk.CallMcpToolActivityName), - "one pending pass, one approved execution") + require.True(t, res.Paused, "agent must pause on a pending confirmation") + assert.Equal(t, "guarded", res.OriginalCallName, "the human must see the original tool call") + assert.NotEmpty(t, res.PendingHint) + assert.EqualValues(t, 1, mcpGuardedRuns.Load(), "guarded MCP tool must run exactly once, only after approval") + assert.Contains(t, res.ResumedToolResponses, "guarded") + assert.Equal(t, 2, counter.get(googleadk.CallMcpToolActivityName), + "one pending pass, one approved execution") + }) + } } // TestMCPToolConfirmationDenialBlocksTool proves the denial path: the human's @@ -215,7 +308,7 @@ func TestMCPToolConfirmationDenialBlocksTool(t *testing.T) { var s testsuite.WorkflowTestSuite env, counter := newMcpHitlEnv(t, &s) - env.ExecuteWorkflow(mcpHitlWorkflow, false) + env.ExecuteWorkflow(mcpHitlWorkflow, mcpHitlCase{}) require.True(t, env.IsWorkflowCompleted()) require.NoError(t, env.GetWorkflowError()) @@ -230,3 +323,45 @@ func TestMCPToolConfirmationDenialBlocksTool(t *testing.T) { assert.Equal(t, 2, counter.get(googleadk.CallMcpToolActivityName), "the denial is delivered to the worker-side tool, which rejects without running the handler") } + +// TestMCPToolConfirmationResumeHonorsCurrentToolFilter proves that a tool the +// ToolFilter excludes while its confirmation is pending does not run when the +// human approves: ADK resumes the call from the toolset's current Tools list, +// which the filter governs at that point, not when the tools were listed. The +// resume keeps the paused run's invocation ID, and with ReuseMessage also its +// *genai.Content. With RetryResume, a failed resume already listed and +// filtered before the filter changed, and the retry passes the same message. +// With ReuseIterator under a sequential root, ADK runs the kept iterator again +// with the same RunConfig. +func TestMCPToolConfirmationResumeHonorsCurrentToolFilter(t *testing.T) { + for _, tc := range []mcpHitlCase{ + {}, + {ReuseMessage: true}, + {RetryResume: true}, + {ReuseMessage: true, RetryResume: true}, + {SequentialRoot: true, ReuseMessage: true}, + {SequentialRoot: true, ReuseIterator: true}, + } { + tc.Confirm, tc.ExcludeBeforeResume = true, true + t.Run(fmt.Sprintf("%+v", tc), func(t *testing.T) { + mcpGuardedRuns.Store(0) + + var s testsuite.WorkflowTestSuite + env, counter := newMcpHitlEnv(t, &s) + env.ExecuteWorkflow(mcpHitlWorkflow, tc) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var res mcpHitlResult + require.NoError(t, env.GetWorkflowResult(&res)) + + require.True(t, res.Paused, "agent must pause on a pending confirmation") + assert.EqualValues(t, 0, mcpGuardedRuns.Load(), "an excluded tool must not run after approval") + assert.Contains(t, res.ResumedGuardedError, "not found", + "ADK must not find the excluded tool when it resumes the call") + assert.Equal(t, 1, counter.get(googleadk.CallMcpToolActivityName), + "only the pending pass reaches the worker") + }) + } +} diff --git a/go/googleadk/mcp_listings_internal_test.go b/go/googleadk/mcp_listings_internal_test.go new file mode 100644 index 00000000..4e0ddc7f --- /dev/null +++ b/go/googleadk/mcp_listings_internal_test.go @@ -0,0 +1,327 @@ +package googleadk + +// White-box tests for the MCP listing store: mcpListings and agentRunOf are +// unexported. + +import ( + "context" + "fmt" + "reflect" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "weak" + + "github.com/nexus-rpc/sdk-go/nexus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/converter" + "go.temporal.io/sdk/testsuite" + "go.temporal.io/sdk/workflow" + "google.golang.org/genai" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" +) + +type listingsTestRegistry struct { + *testsuite.TestWorkflowEnvironment +} + +func (listingsTestRegistry) RegisterNexusService(*nexus.Service) {} + +// listingsAfterTurns is what listingsLifetimeWorkflow saw in the store after +// each turn and a garbage collection. +type listingsAfterTurns struct { + Entries []int + Live []int +} + +// listingsLifetimeWorkflow runs turns turns of an MCP agent on one NewContext, +// and records the store's entries, and those whose agent run is still +// reachable, after each turn and a garbage collection. +func listingsLifetimeWorkflow(ctx workflow.Context, turns int) (listingsAfterTurns, error) { + var out listingsAfterTurns + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Model: NewModel("fake-model"), + Toolsets: []tool.Toolset{NewMCPToolset(MCPToolsetOptions{Name: "search-server"})}, + }) + if err != nil { + return out, err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return out, err + } + adkCtx := NewContext(ctx) + l := listingsFrom(adkCtx) + for i := range turns { + msg := genai.NewContentFromText(fmt.Sprintf("turn %d", i), genai.RoleUser) + for _, rerr := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) { + if rerr != nil { + return out, rerr + } + } + runtime.GC() + l.mu.Lock() + live := 0 + for k := range l.runs { + if k.run.Value() != nil { + live++ + } + } + out.Entries = append(out.Entries, len(l.runs)) + out.Live = append(out.Live, live) + l.mu.Unlock() + } + return out, nil +} + +// TestMcpListingsReleaseFinishedRuns proves the listing store does not keep +// finished turns: each turn on one NewContext is a new invocation, and its +// state, with the InvocationContext and the session events it holds, must go +// once the turn ends. Only the last turn's entry may remain, unreachable, until +// the next agent run starts and drops it. +func TestMcpListingsReleaseFinishedRuns(t *testing.T) { + const turns = 20 + server := NewFakeMCPServer("search-server").AddTool("search", "search the web", nil, + func(map[string]any) (map[string]any, error) { return map[string]any{"hits": 1}, nil }) + fm := NewFakeModel( + FunctionCallResponse("c1", "search", map[string]any{"query": "temporal"}), + TextResponse("done"), + ) + acts, err := NewActivities(Config{ + Models: map[string]ModelFactory{"fake-model": func(context.Context, string) (model.LLM, error) { return fm, nil }}, + MCPToolsets: map[string]MCPFactory{"search-server": server.Factory()}, + }) + require.NoError(t, err) + + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + acts.Register(listingsTestRegistry{env}) + env.RegisterWorkflow(listingsLifetimeWorkflow) + env.ExecuteWorkflow(listingsLifetimeWorkflow, turns) + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var got listingsAfterTurns + require.NoError(t, env.GetWorkflowResult(&got)) + require.Len(t, got.Entries, turns) + for i := range turns { + assert.LessOrEqual(t, got.Entries[i], 1, "entries after turn %d", i) + assert.Zero(t, got.Live[i], "reachable agent runs after turn %d", i) + } +} + +// runProbe is a toolset that records what agentRunOf tells for the contexts +// ADK passes to its Tools and ProcessRequest. +type runProbe struct { + mu sync.Mutex + calls []probeCall +} + +type probeCall struct { + method string + run weak.Pointer[byte] + ok bool +} + +func (*runProbe) Name() string { return "probe" } + +func (p *runProbe) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + p.record("Tools", ctx) + return nil, nil +} + +func (p *runProbe) ProcessRequest(ctx agent.Context, _ *model.LLMRequest) error { + p.record("ProcessRequest", ctx) + return nil +} + +func (p *runProbe) record(method string, ctx context.Context) { + run, ok := agentRunOf(ctx) + p.mu.Lock() + defer p.mu.Unlock() + p.calls = append(p.calls, probeCall{method, run, ok}) +} + +// decoratorContext is a caller's context that keeps its parent in a field +// named context, like ADK's tool context does. +type decoratorContext struct { + context.Context + context context.Context +} + +type contextHolder struct { + context context.Context +} + +// embeddingContext embeds a pointer to a struct with a context field. +type embeddingContext struct { + context.Context + *contextHolder +} + +type probeKey struct{} + +// TestMcpAgentRunOfReadsOnlyADKContexts proves agentRunOf tells the agent run +// from the contexts ADK passes to a toolset's Tools and ProcessRequest, and +// that it does not read other contexts. If an ADK release renamed a field it +// reads, the first case would fail here rather than as a listing count. +func TestMcpAgentRunOfReadsOnlyADKContexts(t *testing.T) { + t.Run("ADK contexts", func(t *testing.T) { + p := &runProbe{} + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Model: NewFakeModel(TextResponse("done")), + Toolsets: []tool.Toolset{p}, + }) + require.NoError(t, err) + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + require.NoError(t, err) + for _, rerr := range r.Run(context.Background(), "user-1", "session-1", genai.NewContentFromText("hi", genai.RoleUser), agent.RunConfig{}) { + require.NoError(t, rerr) + } + + methods := map[string]bool{} + for _, c := range p.calls { + methods[c.method] = true + assert.True(t, c.ok, "%s: agentRunOf did not tell the agent run", c.method) + assert.True(t, c.run == p.calls[0].run, "%s: another agent run", c.method) + } + assert.Equal(t, map[string]bool{"Tools": true, "ProcessRequest": true}, methods) + }) + + t.Run("other contexts", func(t *testing.T) { + parent := context.WithValue(context.Background(), probeKey{}, 1) + for name, ctx := range map[string]context.Context{ + "decorator": &decoratorContext{Context: parent, context: parent}, + "nil embedded struct": &embeddingContext{Context: parent}, + } { + _, ok := agentRunOf(ctx) + assert.False(t, ok, name) + } + // interfaceField reads only direct fields, so it does not go through + // the nil embedded pointer to the promoted context field. + _, ok := interfaceField(reflect.ValueOf(&embeddingContext{Context: parent}), "context") + assert.False(t, ok, "promoted field") + }) +} + +// callerReadonlyContext and callerContext are a caller's own contexts around +// ADK's, which agentRunOf does not read. +type callerReadonlyContext struct{ agent.ReadonlyContext } +type callerContext struct{ agent.Context } + +// callerContextToolset passes its own contexts around ADK's to an MCP +// toolset's Tools and ProcessRequest, and counts those calls. +type callerContextToolset struct { + ts *mcpToolset + calls *int +} + +func (c callerContextToolset) Name() string { return c.ts.Name() } + +func (c callerContextToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + *c.calls++ + return c.ts.Tools(&callerReadonlyContext{ctx}) +} + +func (c callerContextToolset) ProcessRequest(ctx agent.Context, req *model.LLMRequest) error { + *c.calls++ + return c.ts.ProcessRequest(&callerContext{ctx}, req) +} + +// callerContextWorkflow runs one MCP turn through callerContextToolset and +// returns how many Tools and ProcessRequest calls the MCP toolset got. +func callerContextWorkflow(ctx workflow.Context) (int, error) { + calls := 0 + ts := callerContextToolset{NewMCPToolset(MCPToolsetOptions{Name: "search-server"}).(*mcpToolset), &calls} + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Model: NewModel("fake-model"), + Toolsets: []tool.Toolset{ts}, + }) + if err != nil { + return 0, err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return 0, err + } + for _, rerr := range r.Run(NewContext(ctx), "user-1", "session-1", genai.NewContentFromText("search", genai.RoleUser), agent.RunConfig{}) { + if rerr != nil { + return 0, rerr + } + } + return calls, nil +} + +// warningLog keeps the messages of the warnings logged to it. +type warningLog struct { + mu sync.Mutex + warns []string +} + +func (*warningLog) Debug(string, ...any) {} +func (*warningLog) Info(string, ...any) {} +func (*warningLog) Error(string, ...any) {} + +func (l *warningLog) Warn(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.warns = append(l.warns, msg) +} + +// TestMcpFallbackListsOnEveryCall proves that a toolset that cannot tell the +// agent run, here because a caller wraps ADK's contexts, lists on every Tools +// and ProcessRequest call and logs one warning for the whole NewContext. +func TestMcpFallbackListsOnEveryCall(t *testing.T) { + server := NewFakeMCPServer("search-server").AddTool("search", "search the web", nil, + func(map[string]any) (map[string]any, error) { return map[string]any{"hits": 1}, nil }) + fm := NewFakeModel( + FunctionCallResponse("c1", "search", map[string]any{"query": "temporal"}), + TextResponse("done"), + ) + acts, err := NewActivities(Config{ + Models: map[string]ModelFactory{"fake-model": func(context.Context, string) (model.LLM, error) { return fm, nil }}, + MCPToolsets: map[string]MCPFactory{"search-server": server.Factory()}, + }) + require.NoError(t, err) + + var s testsuite.WorkflowTestSuite + logs := &warningLog{} + s.SetLogger(logs) + env := s.NewTestWorkflowEnvironment() + acts.Register(listingsTestRegistry{env}) + var listings atomic.Int32 + env.SetOnActivityStartedListener(func(info *activity.Info, _ context.Context, _ converter.EncodedValues) { + if info.ActivityType.Name == ListMcpToolsActivityName { + listings.Add(1) + } + }) + env.RegisterWorkflow(callerContextWorkflow) + env.ExecuteWorkflow(callerContextWorkflow) + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var calls int + require.NoError(t, env.GetWorkflowResult(&calls)) + assert.GreaterOrEqual(t, calls, 3, "one Tools call and two ProcessRequest calls at least") + assert.Equal(t, calls, int(listings.Load()), "listings") + warned := 0 + for _, msg := range logs.warns { + if strings.HasPrefix(msg, "MCP toolset cannot tell which agent run called it") { + warned++ + } + } + assert.Equal(t, 1, warned, "warnings: %q", logs.warns) +} diff --git a/go/googleadk/mcp_test.go b/go/googleadk/mcp_test.go index 781ccfb8..ca292dc8 100644 --- a/go/googleadk/mcp_test.go +++ b/go/googleadk/mcp_test.go @@ -2,18 +2,34 @@ package googleadk_test import ( "context" + "errors" + "fmt" "iter" + "slices" + "strings" "sync" + "sync/atomic" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/converter" "go.temporal.io/sdk/testsuite" + "go.temporal.io/sdk/workflow" "google.golang.org/genai" + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/platform" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/agenttool" "go.temporal.io/googleadk" ) @@ -121,8 +137,12 @@ func TestMcpListToolsCarriesParameters(t *testing.T) { require.NotNil(t, decl.Parameters, "MCP tool declaration carried no parameters") assert.Contains(t, decl.Parameters.Properties, "query") - // The tools were listed (>=1, packed each turn) and called once. - assert.GreaterOrEqual(t, counter.get(googleadk.ListMcpToolsActivityName), 1) + // The tools were listed once per model step, on every ADK release, and + // called once. ADK v2.6.0 asks the toolset for its tools twice per step; + // a second listing would add an Activity, and Histories recorded with + // another ADK release would fail replay. + assert.Equal(t, 2, counter.get(googleadk.InvokeModelActivityName)) + assert.Equal(t, 2, counter.get(googleadk.ListMcpToolsActivityName), "one listing per model step") assert.Equal(t, 1, counter.get(googleadk.CallMcpToolActivityName)) } @@ -174,3 +194,750 @@ func TestMcpToolCallRoundTrip(t *testing.T) { assert.Equal(t, "meaning of life", gotArgs["query"]) mu.Unlock() } + +// ruleModel answers each request with the response its rule picks from the +// request, so concurrent runs that share it each get their own script. +type ruleModel struct { + name string + rule func(req *model.LLMRequest) *model.LLMResponse + + mu sync.Mutex + decls [][]string +} + +func (m *ruleModel) Name() string { return m.name } + +func (m *ruleModel) GenerateContent(_ context.Context, req *model.LLMRequest, _ bool) iter.Seq2[*model.LLMResponse, error] { + names := declaredNames(req) + m.mu.Lock() + m.decls = append(m.decls, names) + m.mu.Unlock() + resp := m.rule(req) + return func(yield func(*model.LLMResponse, error) bool) { yield(resp, nil) } +} + +func (m *ruleModel) factory() googleadk.ModelFactory { + return func(context.Context, string) (model.LLM, error) { return m, nil } +} + +// declaredNames returns the names of the tools a model request declares. +func declaredNames(req *model.LLMRequest) []string { + var names []string + if req.Config != nil { + for _, gt := range req.Config.Tools { + if gt != nil { + for _, d := range gt.FunctionDeclarations { + names = append(names, d.Name) + } + } + } + } + return names +} + +// declared returns the tool names each model request declared, in order. +func (m *ruleModel) declared() [][]string { + m.mu.Lock() + defer m.mu.Unlock() + return m.decls +} + +// functionResponses counts the function responses in a request's contents. +func functionResponses(req *model.LLMRequest) int { + n := 0 + for _, c := range req.Contents { + if c == nil { + continue + } + for _, p := range c.Parts { + if p.FunctionResponse != nil { + n++ + } + } + } + return n +} + +// lastIsFunctionResponse reports whether the request's last content carries a +// function response. +func lastIsFunctionResponse(req *model.LLMRequest) bool { + if len(req.Contents) == 0 { + return false + } + for _, p := range req.Contents[len(req.Contents)-1].Parts { + if p.FunctionResponse != nil { + return true + } + } + return false +} + +// lastUserText returns the text of the request's last user content. +func lastUserText(req *model.LLMRequest) string { + for i := len(req.Contents) - 1; i >= 0; i-- { + c := req.Contents[i] + if c == nil || c.Role != genai.RoleUser { + continue + } + for _, p := range c.Parts { + if p.Text != "" { + return p.Text + } + } + } + return "" +} + +// commandLog records the Activities and timers a test workflow schedules, in +// order. +type commandLog struct { + mu sync.Mutex + log []string +} + +func (l *commandLog) add(s string) { + l.mu.Lock() + defer l.mu.Unlock() + l.log = append(l.log, s) +} + +func (l *commandLog) get() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string(nil), l.log...) +} + +func (l *commandLog) count(s string) int { + n := 0 + for _, e := range l.get() { + if e == s { + n++ + } + } + return n +} + +// watchCommands records env's Activity starts and timers in order, each timer +// as "Timer ". It replaces the activityCounter listener newEnv +// installs. +func watchCommands(env *testsuite.TestWorkflowEnvironment) *commandLog { + l := &commandLog{} + env.SetOnActivityStartedListener(func(info *activity.Info, _ context.Context, _ converter.EncodedValues) { + l.add(info.ActivityType.Name) + }) + env.SetOnTimerScheduledListener(func(_ string, d time.Duration) { l.add("Timer " + d.String()) }) + return l +} + +// sleepingInstructionWorkflow runs two turns of an MCP agent whose +// InstructionProvider sleeps on a 1s workflow timer before each model call, the +// way an instruction that reads workflow state through a command would. With +// filterSleeps, the toolset's ToolFilter also sleeps, on a 2s timer. +func sleepingInstructionWorkflow(ctx workflow.Context, filterSleeps bool) error { + sleep := func(rctx agent.ReadonlyContext, d time.Duration) error { + wfCtx, ok := googleadk.WorkflowContext(rctx) + if !ok { + return errors.New("no workflow context") + } + return workflow.Sleep(wfCtx, d) + } + opts := googleadk.MCPToolsetOptions{Name: "search-server"} + if filterSleeps { + opts.ToolFilter = func(rctx agent.ReadonlyContext, _ tool.Tool) bool { + return sleep(rctx, 2*time.Second) == nil + } + } + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Model: googleadk.NewModel("fake-model"), + InstructionProvider: func(rctx agent.ReadonlyContext) (string, error) { + if err := sleep(rctx, time.Second); err != nil { + return "", err + } + return "be helpful", nil + }, + Toolsets: []tool.Toolset{googleadk.NewMCPToolset(opts)}, + }) + if err != nil { + return err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return err + } + adkCtx := googleadk.NewContext(ctx) + for _, text := range []string{"search", "thanks"} { + for _, rerr := range r.Run(adkCtx, "user-1", "session-1", genai.NewContentFromText(text, genai.RoleUser), agent.RunConfig{}) { + if rerr != nil { + return rerr + } + } + } + return nil +} + +// TestMcpListsAtTheSamePointInEveryStep proves the MCP toolset lists at a point +// that does not depend on the ADK release: in the first model step of each +// turn before the request processors (ADK calls Tools there on every release), +// and in every later step after them, in ProcessRequest. ADK v2.6.0 also calls +// Tools before the request processors of every step; a listing there would put +// ListMcpTools before the instruction's timer, where ADK v2.5.0 and earlier put +// it after, and Histories would not replay across releases. ToolFilter runs at +// the same points too: in the first Tools call of each turn and in each +// ProcessRequest, never in the later Tools calls only ADK v2.6.0 makes. +func TestMcpListsAtTheSamePointInEveryStep(t *testing.T) { + list, invoke, call := googleadk.ListMcpToolsActivityName, googleadk.InvokeModelActivityName, googleadk.CallMcpToolActivityName + instr, filter := "Timer 1s", "Timer 2s" + for _, tc := range []struct { + filterSleeps bool + want []string + }{ + {false, []string{ + // Turn 1, step 1: listed through Tools, before the instruction. + list, instr, invoke, call, + // Turn 1, step 2: listed in ProcessRequest, after the instruction. + instr, list, invoke, + // Turn 2: listed through Tools, before the instruction. + list, instr, invoke, + }}, + {true, []string{ + // Turn 1, step 1: Tools lists and filters, ProcessRequest filters. + list, filter, instr, filter, invoke, call, + // Turn 1, step 2: ProcessRequest lists and filters. + instr, list, filter, invoke, + // Turn 2: Tools lists and filters, ProcessRequest filters. + list, filter, instr, filter, invoke, + }}, + } { + t.Run(fmt.Sprintf("filterSleeps=%v", tc.filterSleeps), func(t *testing.T) { + m := &ruleModel{name: "fake-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + if lastUserText(req) == "search" && !lastIsFunctionResponse(req) { + return googleadk.FunctionCallResponse("c1", "search", map[string]any{"query": "temporal"}) + } + return googleadk.TextResponse("done") + }} + server := googleadk.NewFakeMCPServer("search-server").AddTool( + "search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{"hits": 1}, nil }, + ) + + var s testsuite.WorkflowTestSuite + env, _ := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"fake-model": m.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{"search-server": server.Factory()}, + }) + commands := watchCommands(env) + env.RegisterWorkflow(sleepingInstructionWorkflow) + env.ExecuteWorkflow(sleepingInstructionWorkflow, tc.filterSleeps) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + assert.Equal(t, tc.want, commands.get()) + for i, names := range m.declared() { + assert.Equal(t, []string{"search"}, names, "model request %d", i) + } + }) + } +} + +// hidingToolset hides the "search" tool of the wrapped MCP server once hide is +// set, the way a server drops a tool from its list. With calls set, it also +// hides it from Tools call number hideCall alone, counting from 1. +type hidingToolset struct { + tool.Toolset + hide *atomic.Bool + calls *atomic.Int32 + hideCall int32 +} + +func (h hidingToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + tools, err := h.Toolset.Tools(ctx) + hide := h.hide.Load() + if h.calls != nil && h.calls.Add(1) == h.hideCall { + hide = true + } + if err != nil || !hide { + return tools, err + } + var kept []tool.Tool + for _, t := range tools { + if t.Name() != "search" { + kept = append(kept, t) + } + } + return kept, nil +} + +// searchDropped tells catalogChangeWorkflow's ToolFilter to drop "search". +var searchDropped atomic.Bool + +// catalogChangeWorkflow runs one turn of an MCP agent and returns how many +// UUIDs ADK drew from the context. With filterDrops, the toolset's ToolFilter +// drops "search" once searchDropped is set. With instructionLists, the +// agent's InstructionProvider lists the toolset too, after ADK's own Tools +// call. +func catalogChangeWorkflow(ctx workflow.Context, filterDrops, instructionLists bool) (int, error) { + opts := googleadk.MCPToolsetOptions{Name: "search-server"} + if filterDrops { + opts.ToolFilter = func(_ agent.ReadonlyContext, t tool.Tool) bool { + return t.Name() != "search" || !searchDropped.Load() + } + } + ts := googleadk.NewMCPToolset(opts) + cfg := llmagent.Config{ + Name: "assistant", + Model: googleadk.NewModel("fake-model"), + Instruction: "be helpful", + Toolsets: []tool.Toolset{ts}, + } + if instructionLists { + cfg.Instruction = "" + cfg.InstructionProvider = func(rctx agent.ReadonlyContext) (string, error) { + if _, err := ts.Tools(rctx); err != nil { + return "", err + } + return "be helpful", nil + } + } + root, err := llmagent.New(cfg) + if err != nil { + return 0, err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return 0, err + } + draws := 0 + adkCtx := platform.WithUUIDProvider(googleadk.NewContext(ctx), func() string { + draws++ + return fmt.Sprintf("00000000-0000-0000-0000-%012d", draws) + }) + for _, rerr := range r.Run(adkCtx, "user-1", "session-1", genai.NewContentFromText("search, then fetch", genai.RoleUser), agent.RunConfig{}) { + if rerr != nil { + return 0, rerr + } + } + return draws, nil +} + +// TestMcpDroppedToolIsNeitherDeclaredNorCalled proves each model request +// declares and dispatches the toolset's current list. ADK v2.5.0 and earlier +// keep the tools from the first Tools call of an agent run; if their proxies +// still packed themselves, a tool the server dropped would stay declared and +// a call to it would run CallMcpTool there, but not with ADK v2.6.0. +// +// It also proves a change in the list does not change the IDs ADK generates: +// the turn draws the same number of UUIDs in every case, on every ADK +// release. ADK draws a UUID for each tool it kept from Tools before every +// model step: +// v2.5.0 and earlier keep the first list of the agent run, and v2.6.0 keeps +// what each step's Tools call returns. If those calls returned the current +// list, or the list of a later Tools call before the first ProcessRequest, +// such as an InstructionProvider's, a dropped tool would shift the IDs drawn +// after it, tool confirmation IDs included, on v2.6.0 only, and Histories +// would not replay across releases. +func TestMcpDroppedToolIsNeitherDeclaredNorCalled(t *testing.T) { + t.Run("not called", func(t *testing.T) { + var hide atomic.Bool + server := googleadk.NewFakeMCPServer("search-server"). + AddTool("search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { + hide.Store(true) + return map[string]any{"hits": 1}, nil + }). + AddTool("fetch", "fetch a page", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{}, nil }) + m := &ruleModel{name: "fake-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + if n := functionResponses(req); n < 2 { + return googleadk.FunctionCallResponse(fmt.Sprintf("c%d", n+1), "search", map[string]any{"query": "temporal"}) + } + return googleadk.TextResponse("done") + }} + + var s testsuite.WorkflowTestSuite + env, counter := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"fake-model": m.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{"search-server": func(context.Context) (tool.Toolset, error) { + return hidingToolset{Toolset: server, hide: &hide}, nil + }}, + }) + env.ExecuteWorkflow(agentRunWorkflow, runInput{ModelName: "fake-model", UserMessage: "search twice", MCPToolset: "search-server"}) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + assert.Equal(t, [][]string{{"search", "fetch"}, {"fetch"}, {"fetch"}}, m.declared()) + assert.Equal(t, 3, counter.get(googleadk.ListMcpToolsActivityName)) + assert.Equal(t, 1, counter.get(googleadk.CallMcpToolActivityName), "the dropped tool ran again") + }) + + t.Run("same generated IDs", func(t *testing.T) { + // The turn draws this many UUIDs with ADK v2.3.0 to v2.7.0. + const wantDraws = 19 + for _, tc := range []struct { + name string + drop, filterDrop, instructionLists bool + want [][]string + }{ + {"unchanged", false, false, false, [][]string{{"search", "fetch"}, {"search", "fetch"}, {"search", "fetch"}}}, + {"server drops search", true, false, false, [][]string{{"search", "fetch"}, {"fetch"}, {"fetch"}}}, + {"filter drops search", true, true, false, [][]string{{"search", "fetch"}, {"fetch"}, {"fetch"}}}, + {"server drops search before the instruction lists", true, false, true, [][]string{{"search", "fetch"}, {"fetch"}, {"fetch"}}}, + } { + searchDropped.Store(false) + server := googleadk.NewFakeMCPServer("search-server"). + AddTool("search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { + searchDropped.Store(tc.drop) + return map[string]any{"hits": 1}, nil + }). + AddTool("fetch", "fetch a page", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{}, nil }) + var ts tool.Toolset = server + if !tc.filterDrop { + h := hidingToolset{Toolset: server, hide: &searchDropped} + if tc.instructionLists { + // Hide "search" from the InstructionProvider's listing, the + // second, but not from the CallMcpTool lookup after it. + h.calls, h.hideCall = new(atomic.Int32), 2 + } + ts = h + } + m := &ruleModel{name: "fake-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + switch functionResponses(req) { + case 0: + return googleadk.FunctionCallResponse("c1", "search", map[string]any{"query": "temporal"}) + case 1: + return googleadk.FunctionCallResponse("c2", "fetch", map[string]any{"query": "temporal"}) + } + return googleadk.TextResponse("done") + }} + + var s testsuite.WorkflowTestSuite + env, counter := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"fake-model": m.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{"search-server": func(context.Context) (tool.Toolset, error) { + return ts, nil + }}, + }) + env.RegisterWorkflow(catalogChangeWorkflow) + env.ExecuteWorkflow(catalogChangeWorkflow, tc.filterDrop, tc.instructionLists) + + require.True(t, env.IsWorkflowCompleted(), tc.name) + require.NoError(t, env.GetWorkflowError(), tc.name) + var n int + require.NoError(t, env.GetWorkflowResult(&n), tc.name) + + assert.Equal(t, wantDraws, n, "UUIDs drawn: %s", tc.name) + assert.Equal(t, tc.want, m.declared(), tc.name) + assert.Equal(t, 2, counter.get(googleadk.CallMcpToolActivityName), tc.name) + } + searchDropped.Store(false) + }) +} + +// wrappedToolsLog records, for TestMcpWrappedToolsetListsOnEveryToolsCall, the +// tools each Tools call of the wrapped MCP toolset returns and the tools each +// model request declares, in order. +var wrappedToolsLog *commandLog + +// loggingToolset records in wrappedToolsLog the tools each Tools call returns. +// Like the ADK wrappers, it has no ProcessRequest. +type loggingToolset struct { + tool.Toolset +} + +func (l loggingToolset) Tools(ctx agent.ReadonlyContext) ([]tool.Tool, error) { + tools, err := l.Toolset.Tools(ctx) + names := make([]string, 0, len(tools)) + for _, t := range tools { + names = append(names, t.Name()) + } + wrappedToolsLog.add(fmt.Sprintf("tools %v", names)) + return tools, err +} + +// wrappedMCPWorkflow runs an agent whose MCP toolset is wrapped in the named +// ADK wrapper, which does not forward the toolset's ProcessRequest. +func wrappedMCPWorkflow(ctx workflow.Context, wrapper string) (runResult, error) { + ts := googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{Name: "search-server"}) + switch wrapper { + case "FilterToolset": + ts = tool.FilterToolset(ts, func(agent.ReadonlyContext, tool.Tool) bool { return true }) + case "WithConfirmation": + ts = tool.WithConfirmation(ts, false, nil) + default: + return runResult{}, fmt.Errorf("unknown wrapper %q", wrapper) + } + return runAgent(ctx, agentBuild{ + modelName: "fake-model", + userMessage: "search twice", + toolsets: []tool.Toolset{loggingToolset{ts}}, + }) +} + +// TestMcpWrappedToolsetListsOnEveryToolsCall proves that an MCP toolset whose +// ProcessRequest ADK never calls, because a wrapper hides it, lists on every +// Tools call, as before the toolset listed once per model step: once per agent +// run with ADK v2.5.0 and earlier, and once per model step with v2.6.0. Each +// model request declares what the latest Tools call returned, so with v2.6.0 a +// tool the server dropped is neither declared nor called again. +func TestMcpWrappedToolsetListsOnEveryToolsCall(t *testing.T) { + for _, wrapper := range []string{"FilterToolset", "WithConfirmation"} { + t.Run(wrapper, func(t *testing.T) { + wrappedToolsLog = &commandLog{} + var hide atomic.Bool + server := googleadk.NewFakeMCPServer("search-server"). + AddTool("search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { + hide.Store(true) + return map[string]any{"hits": 1}, nil + }). + AddTool("fetch", "fetch a page", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{}, nil }) + m := &ruleModel{name: "fake-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + wrappedToolsLog.add(fmt.Sprintf("model %v", declaredNames(req))) + if n := functionResponses(req); n < 2 { + return googleadk.FunctionCallResponse(fmt.Sprintf("c%d", n+1), "search", map[string]any{"query": "temporal"}) + } + return googleadk.TextResponse("done") + }} + + var s testsuite.WorkflowTestSuite + env, counter := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"fake-model": m.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{"search-server": func(context.Context) (tool.Toolset, error) { + return hidingToolset{Toolset: server, hide: &hide}, nil + }}, + }) + env.RegisterWorkflow(wrappedMCPWorkflow) + env.ExecuteWorkflow(wrappedMCPWorkflow, wrapper) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + toolsCalls, latest := 0, "" + for _, e := range wrappedToolsLog.get() { + if after, ok := strings.CutPrefix(e, "tools "); ok { + toolsCalls++ + latest = after + continue + } + assert.Equal(t, "model "+latest, e, "a model request declares the latest Tools list") + } + require.Positive(t, toolsCalls) + assert.Equal(t, toolsCalls, counter.get(googleadk.ListMcpToolsActivityName), "one listing per Tools call") + + declared := m.declared() + require.Len(t, declared, 3) + wantCalls := 0 + for _, names := range declared[:2] { + if slices.Contains(names, "search") { + wantCalls++ + } + } + assert.Equal(t, wantCalls, counter.get(googleadk.CallMcpToolActivityName), + "a search call runs CallMcpTool only when the request declared search") + if toolsCalls == len(declared) { + assert.Equal(t, [][]string{{"search", "fetch"}, {"fetch"}, {"fetch"}}, declared) + } + }) + } +} + +// sharedProxyWorkflow runs one turn of an agent that uses an MCP toolset +// directly, then one turn of another agent, named secondName, that uses the +// same toolset through tool.FilterToolset. The runners share the bridged +// context. +func sharedProxyWorkflow(ctx workflow.Context, secondName string) error { + ts := googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{Name: "search-server"}) + adkCtx := googleadk.NewContext(ctx) + for _, a := range []struct { + name string + toolset tool.Toolset + }{ + {"assistant", ts}, + {secondName, tool.FilterToolset(ts, func(agent.ReadonlyContext, tool.Tool) bool { return true })}, + } { + ag, err := llmagent.New(llmagent.Config{ + Name: a.name, + Model: googleadk.NewModel("fake-model"), + Toolsets: []tool.Toolset{a.toolset}, + }) + if err != nil { + return err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: ag, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return err + } + for _, rerr := range r.Run(adkCtx, "user-1", "session-1", genai.NewContentFromText("hello", genai.RoleUser), agent.RunConfig{}) { + if rerr != nil { + return rerr + } + } + } + return nil +} + +// TestMcpSameNamedAgentsKeepTheirOwnListings proves that the toolset's +// ProcessRequest running for one agent does not stand in for another agent +// with the same name, whose wrapper hides that ProcessRequest: the second +// agent's request still declares the toolset's tools. +func TestMcpSameNamedAgentsKeepTheirOwnListings(t *testing.T) { + for _, secondName := range []string{"assistant", "other"} { + t.Run(secondName, func(t *testing.T) { + m := &ruleModel{name: "fake-model", rule: func(*model.LLMRequest) *model.LLMResponse { + return googleadk.TextResponse("done") + }} + server := googleadk.NewFakeMCPServer("search-server").AddTool("search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{"hits": 1}, nil }) + + var s testsuite.WorkflowTestSuite + env, counter := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"fake-model": m.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{"search-server": server.Factory()}, + }) + env.RegisterWorkflow(sharedProxyWorkflow) + env.ExecuteWorkflow(sharedProxyWorkflow, secondName) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + assert.Equal(t, [][]string{{"search"}, {"search"}}, m.declared()) + assert.Equal(t, 2, counter.get(googleadk.ListMcpToolsActivityName), "one listing per agent run") + }) + } +} + +// searchFetchHelper returns a helper agent with two MCP toolsets. +func searchFetchHelper() (agent.Agent, error) { + return llmagent.New(llmagent.Config{ + Name: "helper", + Description: "searches", + Model: googleadk.NewModel("helper-model"), + Instruction: "search", + Toolsets: []tool.Toolset{ + googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{Name: "search-server"}), + googleadk.NewMCPToolset(googleadk.MCPToolsetOptions{Name: "fetch-server"}), + }, + }) +} + +// parallelHelperWorkflow runs one helper agent twice, in parallel unless +// sequential. Through an agent tool, the root agent calls it twice, and each +// call is its own invocation. With sharedInvocation, a custom root agent runs +// it on branches root.a and root.b through platform.RunTasks instead, so both +// runs share the root's invocation ID and the helper's name. +func parallelHelperWorkflow(ctx workflow.Context, sequential, sharedInvocation bool) (runResult, error) { + helper, err := searchFetchHelper() + if err != nil { + return runResult{}, err + } + var opts []googleadk.ContextOption + if sequential { + opts = append(opts, googleadk.WithSequentialToolFanout()) + } + if !sharedInvocation { + return runAgent(ctx, agentBuild{ + ctxOpts: opts, + modelName: "root-model", + userMessage: "ask the helper twice", + tools: []tool.Tool{agenttool.New(helper, nil)}, + }) + } + root, err := agent.New(agent.Config{ + Name: "root", + SubAgents: []agent.Agent{helper}, + Run: func(ic agent.InvocationContext) iter.Seq2[*session.Event, error] { + return func(yield func(*session.Event, error) bool) { + stopped := false + var tasks []func(context.Context) + for _, branch := range []string{"root.a", "root.b"} { + tasks = append(tasks, func(c context.Context) { + bctx := agent.PromoteWithDelta(ic, &agent.CommonContextDelta{ + InvocationContextDelta: &agent.InvocationContextDelta{Context: &c, Branch: &branch}, + }) + for ev, err := range helper.Run(bctx) { + if stopped || !yield(ev, err) { + stopped = true + return + } + } + }) + } + platform.RunTasks(ic, tasks) + } + }, + }) + if err != nil { + return runResult{}, err + } + r, err := runner.New(runner.Config{AppName: "test-app", Agent: root, SessionService: session.InMemoryService(), AutoCreateSession: true}) + if err != nil { + return runResult{}, err + } + for _, rerr := range r.Run(googleadk.NewContext(ctx, opts...), "user-1", "session-1", genai.NewContentFromText("ask the helper twice", genai.RoleUser), agent.RunConfig{}) { + if rerr != nil { + return runResult{}, rerr + } + } + return runResult{}, nil +} + +// TestMcpParallelInvocationsKeepTheirOwnListings proves concurrent runs of one +// agent each reuse the list their own Tools call took, whether they are +// separate invocations or share one invocation and agent name. Two helper runs +// of two model steps each, with two MCP toolsets, list 8 times, in parallel as +// in sequence. +func TestMcpParallelInvocationsKeepTheirOwnListings(t *testing.T) { + for _, sharedInvocation := range []bool{false, true} { + for _, sequential := range []bool{false, true} { + t.Run(fmt.Sprintf("sharedInvocation=%v/sequential=%v", sharedInvocation, sequential), func(t *testing.T) { + root := &ruleModel{name: "root-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + if functionResponses(req) == 0 { + return reproFnCalls("helper", "helper") + } + return googleadk.TextResponse("root done") + }} + helper := &ruleModel{name: "helper-model", rule: func(req *model.LLMRequest) *model.LLMResponse { + if lastIsFunctionResponse(req) { + return googleadk.TextResponse("helper done") + } + return googleadk.FunctionCallResponse("h1", "search", map[string]any{"query": "temporal"}) + }} + search := googleadk.NewFakeMCPServer("search-server").AddTool("search", "search the web", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{"hits": 1}, nil }) + fetch := googleadk.NewFakeMCPServer("fetch-server").AddTool("fetch", "fetch a page", searchSchema(), + func(map[string]any) (map[string]any, error) { return map[string]any{}, nil }) + + var s testsuite.WorkflowTestSuite + env, counter := newEnv(t, &s, googleadk.Config{ + Models: map[string]googleadk.ModelFactory{"root-model": root.factory(), "helper-model": helper.factory()}, + MCPToolsets: map[string]googleadk.MCPFactory{ + "search-server": search.Factory(), + "fetch-server": fetch.Factory(), + }, + }) + env.RegisterWorkflow(parallelHelperWorkflow) + env.ExecuteWorkflow(parallelHelperWorkflow, sequential, sharedInvocation) + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + rootCalls := 2 + if sharedInvocation { + rootCalls = 0 + } + assert.Equal(t, 4+rootCalls, counter.get(googleadk.InvokeModelActivityName)) + assert.Equal(t, 8, counter.get(googleadk.ListMcpToolsActivityName), "one listing per toolset per helper model step") + assert.Equal(t, 2, counter.get(googleadk.CallMcpToolActivityName)) + assert.Len(t, helper.declared(), 4) + for i, names := range helper.declared() { + assert.ElementsMatch(t, []string{"search", "fetch"}, names, "helper request %d", i) + } + }) + } + } +}