Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions go/googleadk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,15 @@ the model SDK's own retries (see below), or override the fallbacks.
toolset name — the toolset is cached and shared across calls — and the plugin
closes any cached toolset that implements `Close() error` at worker stop
automatically; with manual wiring, call `Activities.Close` yourself after
`worker.Run` returns.
`worker.Run` returns. A tool's own error, such as an MCP `isError` result,
reaches the model once, as it does without Temporal; `CallMcpTool` succeeds
and is not retried. The plugin counts an error as the tool's own when it
wraps no other error and is not an `ApplicationError`. ADK's MCP tools wrap
the error of a failed MCP request, so that error fails `CallMcpTool` with a
retryable `googleadk.McpError`, as does an error returned after the Worker
stops the call. To have a failure in your own toolset or a `FakeMCPServer`
handler retried, wrap its cause with `%w` or return a retryable
`temporal.ApplicationError`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (non-blocking): the deployment restriction for applications with tool-error callbacks is only in the PR body, so users will not see it. Release notes are generated from commit subjects: release-notes reads only --format=%h%x1f%s (release_tool.py:456). The restriction is the one thing such an application must do differently. Without it, a rolling deploy can close a run as Failed or leave it stuck on a [TMPRL1100] Workflow Task failure, as in the reruns in the review body. This repository keeps such notes in the README: the remote A2A paragraph in this README, from #152, and the Strands, LangGraph and OpenAI Agents READMEs. Suggested, at the end of this bullet:

If an agent or a runner plugin has a tool-error callback, or an after-tool callback that treats errors differently from results, do not 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.

- **Remote A2A agents.** Build ADK's remote agent (`remoteagent.NewA2A`) with
`AgentCardProvider: googleadk.NewA2AAgentCardProvider(cardURL, activityOptions)`
and `ClientProvider: googleadk.NewA2AClientProvider(activityOptions)`. The card
Expand Down Expand Up @@ -249,7 +257,9 @@ the model SDK's own retries (see below), or override the fallbacks.
serial fallback.
- **Typed failures.** Model / tool / MCP / A2A failures surface as Temporal
`ApplicationError`s tagged `googleadk.ModelError` / `.ToolError` / `.McpError`
/ `.A2AError`.
/ `.A2AError`. An MCP tool's own error is not one of these: ADK, its
tool-error callbacks and the model get the tool's error, as without

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (non-blocking): callbacks get an error that carries the tool's message, not the error the tool returned, so "get the tool's error, as without Temporal" holds only for ADK's own mcptoolset. CallMcpTool keeps only runErr.Error() (mcp.go:281), and the proxy rebuilds it with errors.New (mcp.go:153). For ADK's isError errors nothing is lost, because ADK also builds them with errors.New. A custom toolset or a FakeMCPServer handler that returns a sentinel or a typed error does lose something. Probe, with the same OnToolErrorCallback with and without Temporal:

  • For a sentinel, errors.Is is true with ADK alone and false through the plugin.
  • For a *NotFound type, errors.As is true with ADK alone and false through the plugin, where %T is *errors.errorString.
  • The model's response is the same.

A callback written to the body's upgrade note ("match the tool's error, as they would without Temporal") with errors.Is or errors.As never matches, and nothing reports it. The model then gets the error instead of the fallback. ADK's retryandreflect plugin also reports a different error_type, because it takes it from %T. Suggested wording: "get an error with the tool's message, as without Temporal. Its Go type and identity do not cross the Activity boundary, so match on the message." Make the same change in the proxy comment at mcp.go:150-152 and in the body's upgrade note. Main could not match these either, because there callbacks got an ActivityError, so this is a wording fix, not a regression.

Temporal, not a `googleadk.McpError` (see **MCP, statelessly** above).
Classify with `IsNonRetryable(err)`; never string-match. Upstream HTTP status
drives retryability (`408`/`409`/`429`/`5xx` retryable, other `4xx` not).
- **Disable model-SDK retries in your `ModelFactory`.** `InvokeModel` already runs
Expand Down
6 changes: 5 additions & 1 deletion go/googleadk/activities.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ type ModelFactory func(ctx context.Context, modelName string) (model.LLM, error)
// inside a factory. ctx is only valid for the duration of the constructing
// call; mcptoolset.New ignores it — its MCP session opens lazily with each
// request's own context. If the returned toolset implements `Close() error`,
// Activities.Close closes it at worker shutdown.
// Activities.Close closes it at worker shutdown. An error a toolset's tool
// returns that wraps no other error and is not an ApplicationError is the
// tool's own answer: CallMcpTool hands it to the model and does not retry it.
// To have a failure retried, wrap its cause with %w or return a retryable
// temporal.ApplicationError.
type MCPFactory func(ctx context.Context) (tool.Toolset, error)

// Config declares the worker-side registries the Activities consult when a
Expand Down
6 changes: 6 additions & 0 deletions go/googleadk/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.26.6
require (
github.com/a2aproject/a2a-go/v2 v2.5.0
github.com/google/uuid v1.6.0
github.com/modelcontextprotocol/go-sdk v1.7.0
github.com/nexus-rpc/sdk-go v0.7.0
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.45.0
Expand All @@ -23,6 +24,7 @@ require (
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.23.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
Expand All @@ -44,13 +46,17 @@ require (
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.2 // 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.68.0 // indirect
go.temporal.io/api v1.63.4 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
18 changes: 18 additions & 0 deletions go/googleadk/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ 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.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54=
cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
github.com/a2aproject/a2a-go/v2 v2.5.0 h1:ZdcFoxv+nZTUV0i2ue5hES76YCANFPG9vjqd7vK8yWM=
Expand All @@ -21,6 +23,8 @@ 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=
Expand Down Expand Up @@ -51,6 +55,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44=
github.com/modelcontextprotocol/go-sdk v1.7.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=
Expand All @@ -61,15 +67,23 @@ github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.45.0 h1:pdrWmLHofpubmArBv1LgFSv1Z0Ie/ppdZzu+kUN5EeU=
Expand Down Expand Up @@ -113,6 +127,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
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=
Expand All @@ -139,6 +155,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
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.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
Expand Down
44 changes: 43 additions & 1 deletion go/googleadk/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ func (t *mcpProxyTool) Run(ctx agent.Context, args any) (map[string]any, error)
}
return nil, fmt.Errorf("error tool %q %w", t.Name(), tool.ErrConfirmationRequired)
}
if out.Error != nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (tests, non-blocking): nothing in the suite exercises this branch, the Workflow-side half of the change. With lines 149-154 deleted, the full suite still passes, because the model gets the same {"error": …} through the Result copy. Without the branch, tool-error callbacks never run and after-tool callbacks get no error, which undoes the PR's main breaking change. My dev-server runs show the branch working at this head: a callback that runs a fallback Activity ran once, and one that matches googleadk.McpError no longer matched. No test would catch its removal, though. Likewise, with len(e.Unwrap()) == 0 at line 304 replaced by true, the suite still passes, so the errors.Join and multi-%w case is untested. Suggested:

  • A variant of mcpLookupWorkflow with an OnToolErrorCallbacks entry that records the error it gets and returns a fallback. Assert that it ran once with Tool execution failed. Details: order A-1 not found and that the fallback reached the model.
  • A FakeMCPServer case that returns errors.Join(errA, errB) and expects two CallMcpTool attempts.

// The tool's own error, such as an MCP isError result. Give ADK the
// error the tool returned, so the model sees it as it does without
// Temporal.
return nil, errors.New(*out.Error)
}
if out.Result == nil {
return map[string]any{}, nil
}
Expand All @@ -168,10 +174,14 @@ type mcpCallInput struct {
// mcpCallOutput is the serializable result of CallMcpTool. Confirmation is
// non-nil when the tool paused on ADK's tool.ErrConfirmationRequired protocol
// instead of running; the workflow-side proxy re-records it so the runner
// surfaces the pause exactly like an in-workflow tool.
// surfaces the pause exactly like an in-workflow tool. Error is the message of
// the tool's own error, which the proxy hands to ADK; see CallMcpTool. Result
// then holds {"error": message}, the response ADK builds from a tool error, so
// a workflow worker without the Error field gives the model the same response.
type mcpCallOutput struct {
Result map[string]any
Confirmation *toolconfirmation.ToolConfirmation
Error *string

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P1: during a rolling upgrade, a Workflow Worker of the current release reads a tool error returned by an upgraded Worker's CallMcpTool as an empty result. A run whose error handling issues commands then fails when an upgraded Worker replays it, although the body says 'Open runs need no draining for the upgrade'.

main's mcpCallOutput has no Error field, so the JSON converter drops it and the old proxy returns map[string]any{} (mcp.go:149 at ff14931). With sticky execution, a run's Workflow Tasks stay on the Worker that last ran them, while CallMcpTool goes to any Worker on the queue. During a rollout, old Workflows therefore routinely consume new Activities' results.

Reproduced on a dev server with this setup:

  • a head Worker serving only the MCP Activities (the toolset's TaskQueue set to its own queue to make the placement deterministic);
  • a ff14931 Worker running the Workflow and InvokeModel;
  • a FakeMCPServer handler returning errors.New("order A-1 not found"), MaximumAttempts: 2, and a signal wait after turn 1.

(a) No callback. History records CallMcpTool completed with {"Result":null,"Confirmation":null,"Error":"order A-1 not found"}, and in turn 1 the model got that tool response as null. After the old Worker stopped and a head Worker took the signal, in turn 2 the model got {"error":"order A-1 not found"} for that same call.

(b) An OnToolErrorCallbacks callback that runs a fallback Activity on a tool error. This is the body's own example, and also what its upgrade advice produces once a callback matches the tool's error. The old Worker scheduled no fallback. The head Worker's replay scheduled it, and the shifted Activity results failed the run with process request by toolset "orders-err": payload item 0: unable to decode: json: cannot unmarshal object into Go value of type []*genai.FunctionDeclaration. The Execution closed as Failed: a live Worker skips the history check once the workflow function returns (SDK v1.48.0 internal/internal_task_handlers.go:1286). worker.NewWorkflowReplayer on the saved history reports [TMPRL1100] nondeterministic workflow: history event is ActivityTaskScheduled for googleadk.ListMcpTools (ActivityId 29) against a replay command that schedules the fallback Activity. Histories recorded on a single version replay cleanly, so this is the body's rollback hazard, occurring on the way up.

Fix: make the new output opt-in per call. The head proxy sets a field in mcpCallInput (say ToolErrorInOutput: true), and CallMcpTool returns Error only when that field is set; otherwise it fails with the retryable McpError. A Workflow of the current release never sets the field, so every reader in a mixed deployment agrees. Replay compares only Activity ID and type (internal_task_handlers.go:1653-1663), so the new input field is replay-safe. The cost is that a call scheduled by the old code keeps retrying after the upgrade, since its input is fixed, so runs that are already stuck need a reset; the body should say so. A cheaper partial step is to also return Result: map[string]any{"error": msg}, so an old reader gives the model the JSON ADK builds for an error; callbacks still diverge, though. Either way, replace 'Open runs need no draining for the upgrade' with the rollback section's advice for deployments whose callbacks or Workflow code act on tool errors: use Worker Versioning, or let runs that may record a tool error close first.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks for reproducing this end to end. Fixed the null part in 737b6ea: CallMcpTool now also sets Result: {"error": msg}, so a Workflow Worker of the current release gives the model the same {"error": …} response as this one. TestMcpToolErrorReachesModel decodes the output the way the old proxy does and checks it.

I didn't add the opt-in field, for two reasons:

  • A call scheduled by the current release keeps its input, so runs stuck retrying today would stay stuck after the upgrade and need a reset. Letting those runs finish is the main thing [Bug] googleadk (Go): an MCP tool's error result is retried instead of reaching the model #325 fixes.
  • It still wouldn't make a plain rolling upgrade safe for apps with tool-error callbacks. During the rollout, a run can move from a new Workflow Worker back to an old one (a sticky timeout, a restart) after a new Activity recorded the error, and the old Worker then skips the callback. Those apps would need the restriction anyway.

For 1.0 we're also not adding compatibility switches to these fixes; we document the deployment steps instead. The body now limits "open runs need no draining" to apps without a tool-error callback (or an after-tool callback that treats errors differently). Apps with one keep each run on one version: a new Task Queue, stopping every old Worker before starting new ones, or Worker Versioning (Pinned), and they roll back only new runs. I also added your Failed outcome (the unable to decode error) next to the stalled one.

}

// ListMcpTools returns the full declarations (name + description + parameters)
Expand Down Expand Up @@ -211,6 +221,15 @@ func (a *Activities) ListMcpTools(ctx context.Context, in mcpListInput) ([]*gena
// confirmation protocol (e.g. mcptoolset's RequireConfirmation option) the
// Activity succeeds with the pending confirmation in its output rather than
// failing, so the workflow can pause the agent for the human.
//
// A tool's own error, one that wraps no other error and is not an
// ApplicationError, also goes in the output, so the model sees it once, as it
// does without Temporal. ADK's MCP tools return an isError result (and, before
// ADK v2.4.0, a result with no text) that way. They wrap the error of a failed
// MCP request, which still fails the Activity with a retryable McpError, as
// does an error returned after the Activity's context is done, such as when
// the Worker stops. An ApplicationError fails the Activity with its own type
// and retryability.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3 (comment, non-blocking): "An ApplicationError fails the Activity with its own type and retryability" holds only when the tool returns the ApplicationError as is. classifyToolError passes a wrapped one through unchanged (tool.go:79-85). The SDK's failure converter then encodes the outer *fmt.wrapError as a retryable wrapError failure. Probe, with a FakeMCPServer handler and MaximumAttempts: 2:

  • temporal.NewNonRetryableApplicationError("order A-1 not found", "NotFound", nil) gives one CallMcpTool attempt.
  • fmt.Errorf("lookup: %w", <that error>) gives two attempts and two tool runs. Under the default options, it would be retried without limit.

ff14931 behaves the same way, so only the new sentence is wrong. Suggested: "An ApplicationError returned as is fails the Activity with its own type and retryability. Wrapped in another error, it fails as a retryable wrapError." Alternatively, when err is not itself an *ApplicationError, classifyToolError could build newApplicationError(errType, !appErr.NonRetryable(), err, …). That would be a separate behaviour change.

func (a *Activities) CallMcpTool(ctx context.Context, in mcpCallInput) (*mcpCallOutput, error) {
log := activity.GetLogger(ctx)
// No-op unless the user set a HeartbeatTimeout on the toolset's Activity
Expand Down Expand Up @@ -256,6 +275,12 @@ func (a *Activities) CallMcpTool(ctx context.Context, in mcpCallInput) (*mcpCall
return nil, newApplicationError(ErrorTypeMCP, false, runErr,
"tool %q failed: %v", in.Tool, runErr)
}
// An error after ctx is done, such as ctx.Err() when the Worker
// stops, is not the tool's answer, whatever it wraps.
if ctx.Err() == nil && isToolAnswer(runErr) {
msg := runErr.Error()
return &mcpCallOutput{Result: map[string]any{"error": msg}, Error: &msg}, nil
}
return nil, classifyToolError(ErrorTypeMCP, in.Tool, runErr)
}
return &mcpCallOutput{Result: res}, nil
Expand All @@ -264,6 +289,23 @@ func (a *Activities) CallMcpTool(ctx context.Context, in mcpCallInput) (*mcpCall
"tool %q not found in MCP toolset %q", in.Tool, in.Toolset)
}

// isToolAnswer reports whether err, returned by an MCP tool's Run, is the
// tool's own answer: an error that wraps no other error and is not an
// ApplicationError.
func isToolAnswer(err error) bool {
var appErr interface{ NonRetryable() bool }
if errors.As(err, &appErr) {
return false
}
switch e := err.(type) {
case interface{ Unwrap() error }:
return e.Unwrap() == nil
case interface{ Unwrap() []error }:
return len(e.Unwrap()) == 0
}
return true
}

// mcpToolset resolves the named toolset, running its registered factory at most
// once and caching the result for the life of the Activities value. The cache
// is what bounds the resource cost: a live MCP toolset lazily opens and retains
Expand Down
Loading
Loading