From 4c1657308a00ccf85fb6d07d874ddea267c7d14a Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 7 Jul 2026 18:16:25 -0500 Subject: [PATCH 1/6] Add Google ADK agent sample Demonstrates running a Google ADK (adk-go) agent durably with the go.temporal.io/sdk/contrib/googleadk integration: the agent loop runs in a Workflow, the model call runs as a Temporal Activity via googleadk.NewModel, and a get_weather tool is exposed as a durable Activity via googleadk.ActivityAsTool. Includes a worker, starter, README, and a FakeModel-driven test that needs no live LLM. The contrib/googleadk package is not yet a tagged release, so go.mod uses a temporary local replace to a sibling temporalio/sdk-go checkout; this must be swapped for a real release before merge (see the comment in go.mod). --- README.md | 5 ++ go.mod | 62 ++++++++++++++------ go.sum | 116 ++++++++++++++++++++++++------------- googleadk/README.md | 55 ++++++++++++++++++ googleadk/starter/main.go | 40 +++++++++++++ googleadk/worker/main.go | 55 ++++++++++++++++++ googleadk/workflow.go | 111 +++++++++++++++++++++++++++++++++++ googleadk/workflow_test.go | 53 +++++++++++++++++ 8 files changed, 438 insertions(+), 59 deletions(-) create mode 100644 googleadk/README.md create mode 100644 googleadk/starter/main.go create mode 100644 googleadk/worker/main.go create mode 100644 googleadk/workflow.go create mode 100644 googleadk/workflow_test.go diff --git a/README.md b/README.md index a22962cc..4c88633f 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,11 @@ with an external configuration file, like TOML, decoupling connection settings f reconnecting subscriber, an external publisher, a truncating ticker, and LLM token streaming. +- [**Google ADK agent**](./googleadk): Demonstrates running a Google ADK (`adk-go`) agent + durably using the `googleadk` contrib integration: the agent loop runs in a Workflow, the + model call runs as an Activity, and a tool is exposed as a durable Activity via + `ActivityAsTool`. + ### Dynamic Workflow logic examples These samples demonstrate some common control flow patterns using Temporal's Go SDK API. diff --git a/go.mod b/go.mod index 61d767a4..50cd2383 100644 --- a/go.mod +++ b/go.mod @@ -21,10 +21,10 @@ require ( github.com/stretchr/testify v1.11.1 github.com/uber-go/tally/v4 v4.1.7 github.com/uber/jaeger-client-go v2.30.0+incompatible - go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel v1.43.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.16.0 - go.opentelemetry.io/otel/sdk v1.42.0 - go.opentelemetry.io/otel/trace v1.42.0 + go.opentelemetry.io/otel/sdk v1.43.0 + go.opentelemetry.io/otel/trace v1.43.0 go.temporal.io/api v1.62.12 go.temporal.io/sdk v1.45.0 go.temporal.io/sdk/contrib/aws/lambdaworker v0.1.1 @@ -33,13 +33,16 @@ require ( go.temporal.io/sdk/contrib/aws/s3driver/awssdkv2 v0.2.0 go.temporal.io/sdk/contrib/datadog v0.5.0 go.temporal.io/sdk/contrib/envconfig v1.0.1 + go.temporal.io/sdk/contrib/googleadk v0.0.0-00010101000000-000000000000 go.temporal.io/sdk/contrib/opentelemetry v0.7.0 go.temporal.io/sdk/contrib/opentracing v0.3.0 go.temporal.io/sdk/contrib/tally v0.2.0 go.temporal.io/sdk/contrib/workflowstreams v0.1.1 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 - google.golang.org/grpc v1.79.3 + google.golang.org/adk/v2 v2.0.1-0.20260707195420-2a04f92f1776 + google.golang.org/genai v1.62.0 + google.golang.org/grpc v1.81.0 google.golang.org/protobuf v1.36.11 gopkg.in/DataDog/dd-trace-go.v1 v1.59.0 gopkg.in/square/go-jose.v2 v2.6.0 @@ -47,6 +50,9 @@ require ( ) require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.20.0 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/BurntSushi/toml v1.4.0 // indirect github.com/DataDog/appsec-internal-go v1.4.0 // indirect github.com/DataDog/datadog-agent/comp/core/tagger/origindetection v0.71.0 // indirect @@ -92,12 +98,20 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.8.4 // indirect github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/safehtml v0.1.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.15 // indirect + github.com/googleapis/gax-go/v2 v2.22.0 // 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.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -143,31 +157,41 @@ require ( go.opentelemetry.io/collector/internal/telemetry v0.133.0 // indirect go.opentelemetry.io/collector/pdata v1.39.0 // indirect go.opentelemetry.io/contrib/bridges/otelzap v0.12.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/contrib/propagators/aws v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect - go.opentelemetry.io/otel/log v0.13.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.42.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.opentelemetry.io/otel/log v0.19.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/intern v0.0.0-20230525184215-6c62f75575cb // indirect go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect - golang.org/x/crypto v0.48.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.41.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect + google.golang.org/api v0.279.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 // indirect gopkg.in/ini.v1 v1.67.0 // indirect inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a // indirect + rsc.io/omap v1.2.0 // indirect + rsc.io/ordered v1.1.1 // indirect ) + +// TEMPORARY (draft): go.temporal.io/sdk/contrib/googleadk is not yet a tagged +// release, so it is consumed from a local sibling checkout of temporalio/sdk-go. +// Before this PR is marked ready, drop this replace and pin a real +// contrib/googleadk/vX.Y.Z release. +replace go.temporal.io/sdk/contrib/googleadk => ../sdk-go/contrib/googleadk diff --git a/go.sum b/go.sum index 072fede1..13c137c6 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,11 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +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.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA= +cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= @@ -141,6 +147,8 @@ github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 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.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -205,13 +213,25 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +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.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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.15 h1:xolVQTEXusUcAA5UgtyRLjelpFFHWlPQ4XfWGc7MBas= +github.com/googleapis/enterprise-certificate-proxy v0.3.15/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.22.0 h1:PjIWBpgGIVKGoCXuiCoP64altEJCj3/Ei+kSU5vlZD4= +github.com/googleapis/gax-go/v2 v2.22.0/go.mod h1:irWBbALSr0Sk3qlqb9SyJ1h68WjgeFuiOzI4Rqw5+aY= +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 v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= 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= @@ -441,33 +461,37 @@ go.opentelemetry.io/collector/processor/xprocessor v0.133.0 h1:V5YMrXUgClh3awWOd go.opentelemetry.io/collector/processor/xprocessor v0.133.0/go.mod h1:5gDFI+pGIzoFQeBUM4QZ4E0B+SaU0e+2V7Td+ONoU4M= go.opentelemetry.io/contrib/bridges/otelzap v0.12.0 h1:FGre0nZh5BSw7G73VpT3xs38HchsfPsa2aZtMp0NPOs= go.opentelemetry.io/contrib/bridges/otelzap v0.12.0/go.mod h1:X2PYPViI2wTPIMIOBjG17KNybTzsrATnvPJ02kkz7LM= +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/contrib/propagators/aws v1.42.0 h1:Kbr3xDxs6kcxp5ThXTKWK2OtwLhNoXBVtqguNYcsZL0= go.opentelemetry.io/contrib/propagators/aws v1.42.0/go.mod h1:Jzw9hZHtxdpCN7x8S17UH59X/EiFivp6VXLs9bdM1OQ= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.16.0 h1:+XWJd3jf75RXJq29mxbuXhCXFDG3S3R4vBUeSI2P7tE= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.16.0/go.mod h1:hqgzBPTf4yONMFgdZvL/bK42R/iinTyVQtiWihs3SZc= -go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= -go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= +go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4= +go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk= go.opentelemetry.io/otel/log/logtest v0.13.0 h1:xxaIcgoEEtnwdgj6D6Uo9K/Dynz9jqIxSDu2YObJ69Q= go.opentelemetry.io/otel/log/logtest v0.13.0/go.mod h1:+OrkmsAH38b+ygyag1tLjSFMYiES5UHggzrtY1IIEA8= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= -go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= -go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= -go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= -go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko= +go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= -go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= -go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.opentelemetry.io/proto/slim/otlp v1.7.1 h1:lZ11gEokjIWYM3JWOUrIILr2wcf6RX+rq5SPObV9oyc= go.opentelemetry.io/proto/slim/otlp v1.7.1/go.mod h1:uZ6LJWa49eNM/EXnnvJGTTu8miokU8RQdnO980LJ57g= go.opentelemetry.io/proto/slim/otlp/collector/profiles/v1development v0.0.1 h1:Tr/eXq6N7ZFjN+THBF/BtGLUz8dciA7cuzGRsCEkZ88= @@ -530,8 +554,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -554,8 +578,8 @@ 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.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -572,8 +596,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.0.0-20210913180222-943fd674d43e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -586,8 +610,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -615,8 +639,8 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -624,11 +648,11 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -645,8 +669,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= 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= @@ -655,14 +679,20 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.8.2/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +google.golang.org/adk/v2 v2.0.1-0.20260707195420-2a04f92f1776 h1:/Vrjt6Sxj1EbNhvADSHF4WiF6SHihEybMaso9y5lacQ= +google.golang.org/adk/v2 v2.0.1-0.20260707195420-2a04f92f1776/go.mod h1:fPuMPT5s3LsWu97mdeFjTPZu/02tIALWRWeqHL2FWKE= +google.golang.org/api v0.279.0 h1:hsx2M2OaRcaKtVYK6vXEUnQvdjnend7ZYES+lYaot74= +google.golang.org/api v0.279.0/go.mod h1:B9TqLBwJqVjp1mtt7WeoQwWRwvu/400y5lETOql+giQ= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= +google.golang.org/genai v1.62.0 h1:PaBju84orf4Vbcc6OfHe4vxhxhjwulKTgOpEc3iIc00= +google.golang.org/genai v1.62.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= @@ -670,10 +700,12 @@ google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0= -google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= +google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60 h1:seT2EwLWM78plQ7wcDfuWBc/4FAEAXDDiaSol4ku4qo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260511170946-3700d4141b60/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.12.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -683,8 +715,8 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= -google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= -google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw= +google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -734,4 +766,8 @@ inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a h1:1XCVEdxrvL6c0TGOhecLuB7U9z inet.af/netaddr v0.0.0-20230525184311-b8eac61e914a/go.mod h1:e83i32mAQOW1LAqEIweALsuK2Uw4mhQadA5r7b0Wobo= k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U= k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +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= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/googleadk/README.md b/googleadk/README.md new file mode 100644 index 00000000..2b320c74 --- /dev/null +++ b/googleadk/README.md @@ -0,0 +1,55 @@ +### Google ADK agent + +Run a [Google ADK](https://google.github.io/adk-docs/) (`adk-go`) agent **durably +on Temporal** using the [`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) +contrib integration. The agent's orchestration loop runs inside a Workflow; the +model call runs as a Temporal Activity (via `googleadk.NewModel`), and the +`get_weather` tool is an ordinary Temporal activity exposed to the agent with +`googleadk.ActivityAsTool` — so both are retried, timed-out, and visible in the +Temporal UI, and the whole run is replayable. + +The agent is built the native ADK way (`llmagent.New` + `runner.New`); the only +Temporal-specific pieces are `googleadk.NewModel(...)` as the agent's model, +`googleadk.NewContext(ctx)` passed to `Run`, and the worker-side +`googleadk.NewActivities(...)` registry that holds the real Gemini client. + +### Prerequisites + +- A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) + (e.g. `temporal server start-dev`). +- A Gemini API key from , exported worker-side: + ```bash + export GEMINI_API_KEY=... + ``` + +### Steps to run this sample + +1) Start a Temporal server (see prerequisites). + +2) In a second terminal, start the worker (blocks until Ctrl+C): +```bash +export GEMINI_API_KEY=... +go run googleadk/worker/main.go +``` + +3) In a third terminal, run the starter: +```bash +go run googleadk/starter/main.go +``` + +The starter asks "What's the weather in San Francisco?"; the agent calls the +`get_weather` tool and answers. You should see a final log line similar to: +```bash +2025/12/22 15:07:25 Agent answer: It's currently sunny and about 72°F in San Francisco. +``` + +The exact wording comes from the model and will vary. In the Temporal UI you will +see the run's `googleadk.InvokeModel` and `get_weather` Activities. + +### Test without a live LLM + +`workflow_test.go` drives the workflow through a scripted `FakeModel` (from the +`googleadk` contrib package), so it needs no API key or network: +```bash +go test ./googleadk/... +``` diff --git a/googleadk/starter/main.go b/googleadk/starter/main.go new file mode 100644 index 00000000..aff758e1 --- /dev/null +++ b/googleadk/starter/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + + adk "github.com/temporalio/samples-go/googleadk" +) + +func main() { + // The client is a heavyweight object that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + workflowOptions := client.StartWorkflowOptions{ + ID: "google-adk_workflowID", + TaskQueue: adk.TaskQueue, + } + + question := "What's the weather in San Francisco?" + we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, adk.AgentWorkflow, question) + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + + log.Println("Started workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID()) + + // Synchronously wait for the workflow completion. + var answer string + if err := we.Get(context.Background(), &answer); err != nil { + log.Fatalln("Unable to get workflow result", err) + } + log.Println("Agent answer:", answer) +} diff --git a/googleadk/worker/main.go b/googleadk/worker/main.go new file mode 100644 index 00000000..bd2f0680 --- /dev/null +++ b/googleadk/worker/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/worker" + + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/model/gemini" + + "go.temporal.io/sdk/contrib/googleadk" + + adk "github.com/temporalio/samples-go/googleadk" +) + +func main() { + // The client and worker are heavyweight objects that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, adk.TaskQueue, worker.Options{}) + + w.RegisterWorkflow(adk.AgentWorkflow) + // Register GetWeather under the tool name the ActivityAsTool dispatches, so the + // agent's get_weather call resolves to this activity. + w.RegisterActivityWithOptions(adk.GetWeather, activity.RegisterOptions{Name: adk.WeatherToolName}) + + // Register the integration's model Activity. The real Gemini model lives here, + // behind the Activity boundary; the API key is read worker-side from the env + // and never crosses into the workflow. Disable the model SDK's own retries so + // Temporal's RetryPolicy is the single source of truth. + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + adk.ModelName: func(ctx context.Context, name string) (model.LLM, error) { + // nil config reads GEMINI_API_KEY / GOOGLE_API_KEY from the env. + return gemini.NewModel(ctx, name, nil) + }, + }, + }) + if err != nil { + log.Fatalln("Unable to build googleadk activities", err) + } + acts.Register(w) + + if err := w.Run(worker.InterruptCh()); err != nil { + log.Fatalln("Unable to start worker", err) + } +} diff --git a/googleadk/workflow.go b/googleadk/workflow.go new file mode 100644 index 00000000..ce6ba1d0 --- /dev/null +++ b/googleadk/workflow.go @@ -0,0 +1,111 @@ +// Package adk demonstrates running a Google ADK (adk-go) agent durably on +// Temporal with the go.temporal.io/sdk/contrib/googleadk integration. The agent's +// orchestration loop runs inside the workflow; the model call runs as a Temporal +// Activity, and the get_weather tool is an existing Temporal activity exposed to +// the agent via googleadk.ActivityAsTool. +package adk + +import ( + "context" + + "go.temporal.io/sdk/workflow" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/genai" + + "go.temporal.io/sdk/contrib/googleadk" +) + +const ( + // TaskQueue is the task queue the worker listens on and the starter targets. + TaskQueue = "google-adk" + + // ModelName is the Gemini model the agent uses. The workflow only ships this + // name; the real model is reconstructed worker-side by the ModelFactory the + // worker registers (see worker/main.go). + ModelName = "gemini-2.0-flash" + + // WeatherToolName is both the tool name the model sees and the Activity name + // the tool dispatches, so the worker must register GetWeather under this name. + WeatherToolName = "get_weather" +) + +// GetWeatherInput is the argument schema the model fills in for the weather tool. +type GetWeatherInput struct { + City string `json:"city"` +} + +// GetWeatherOutput is the weather tool's result, handed back to the model. +type GetWeatherOutput struct { + City string `json:"city"` + Conditions string `json:"conditions"` +} + +// GetWeather is an ordinary Temporal activity. Exposed to the agent as a tool via +// googleadk.ActivityAsTool, it runs durably worker-side (retried, timed-out, +// visible in the UI) whenever the model calls it — the recommended pattern for a +// tool that does I/O. A real implementation would call a weather API here. +func GetWeather(ctx context.Context, in GetWeatherInput) (GetWeatherOutput, error) { + return GetWeatherOutput{City: in.City, Conditions: "sunny, 72°F"}, nil +} + +// AgentWorkflow runs a native ADK agent durably. The model call inside r.Run is +// dispatched to the InvokeModel Activity by googleadk.NewModel, and the +// get_weather tool call is dispatched to the GetWeather Activity by +// googleadk.ActivityAsTool. +func AgentWorkflow(ctx workflow.Context, question string) (string, error) { + weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{ + Name: WeatherToolName, + Description: "Get the current weather for a city.", + }) + if err != nil { + return "", err + } + + // Build the agent the ordinary ADK way. NewModel is a model.LLM that carries + // only the model name in-workflow; the real Gemini client lives worker-side. + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Description: "a helpful weather assistant", + Model: googleadk.NewModel(ModelName), + Instruction: "Answer the user's question. Use the get_weather tool when asked about the weather.", + Tools: []tool.Tool{weatherTool}, + }) + if err != nil { + return "", err + } + + r, err := runner.New(runner.Config{ + AppName: "weather", + Agent: root, + SessionService: session.InMemoryService(), + AutoCreateSession: true, + }) + if err != nil { + return "", err + } + + // NewContext bridges the workflow.Context into the context ADK reads its + // determinism/executor seams from. Pass it straight to Run. + adkCtx := googleadk.NewContext(ctx) + msg := genai.NewContentFromText(question, genai.RoleUser) + + var answer string + for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) { + if err != nil { + return "", err + } + if ev != nil && ev.Content != nil { + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + answer = p.Text + } + } + } + } + return answer, nil +} diff --git a/googleadk/workflow_test.go b/googleadk/workflow_test.go new file mode 100644 index 00000000..28cbfab9 --- /dev/null +++ b/googleadk/workflow_test.go @@ -0,0 +1,53 @@ +package adk_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/testsuite" + + "google.golang.org/adk/v2/model" + + "go.temporal.io/sdk/contrib/googleadk" + + adk "github.com/temporalio/samples-go/googleadk" +) + +// TestAgentWorkflow drives AgentWorkflow through a scripted FakeModel — no live +// LLM. The model first calls the get_weather tool, then answers with text; the +// test asserts the tool ran (as a real activity) and its result reached the +// answer. +func TestAgentWorkflow(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + + env.RegisterWorkflow(adk.AgentWorkflow) + env.RegisterActivityWithOptions(adk.GetWeather, activity.RegisterOptions{Name: adk.WeatherToolName}) + + // Scripted model: turn 1 requests get_weather, turn 2 produces the final text. + fm := googleadk.NewFakeModel( + googleadk.FunctionCallResponse("call-1", adk.WeatherToolName, map[string]any{"city": "San Francisco"}), + googleadk.TextResponse("It's sunny, 72°F in San Francisco."), + ) + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + adk.ModelName: func(context.Context, string) (model.LLM, error) { return fm, nil }, + }, + }) + require.NoError(t, err) + // Register the integration's InvokeModel activity by its stable name. + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + env.ExecuteWorkflow(adk.AgentWorkflow, "What's the weather in San Francisco?") + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var answer string + require.NoError(t, env.GetWorkflowResult(&answer)) + assert.Contains(t, answer, "sunny") +} From 35f5e8135f9a8b08a152382062f3e9691437ce03 Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 7 Jul 2026 21:11:05 -0500 Subject: [PATCH 2/6] Add multi-agent, human-in-the-loop, and continue-as-new ADK samples Three scenario subdirectories under googleadk/, each with a workflow, worker, starter, README, and a FakeModel-driven test (no live LLM): - multiagent: a coordinator agent that delegates to weather/jokes SubAgents via ADK's in-workflow transfer_to_agent. - humanintheloop: a sensitive delete_resource tool whose workflow durably blocks on a Temporal signal for the human's approve/deny decision before the tool runs. - chat: a long-lived, signal-driven conversation on one ADK session that continues-as-new (exporting/importing the session) to keep history bounded. Also turns googleadk/README.md into an index of the basic agent plus the three scenarios. --- README.md | 6 +- googleadk/README.md | 51 +++++-- googleadk/chat/README.md | 56 ++++++++ googleadk/chat/starter/main.go | 59 ++++++++ googleadk/chat/worker/main.go | 48 +++++++ googleadk/chat/workflow.go | 166 ++++++++++++++++++++++ googleadk/chat/workflow_test.go | 131 +++++++++++++++++ googleadk/humanintheloop/README.md | 60 ++++++++ googleadk/humanintheloop/starter/main.go | 55 +++++++ googleadk/humanintheloop/worker/main.go | 50 +++++++ googleadk/humanintheloop/workflow.go | 148 +++++++++++++++++++ googleadk/humanintheloop/workflow_test.go | 101 +++++++++++++ googleadk/multiagent/README.md | 55 +++++++ googleadk/multiagent/starter/main.go | 40 ++++++ googleadk/multiagent/worker/main.go | 58 ++++++++ googleadk/multiagent/workflow.go | 143 +++++++++++++++++++ googleadk/multiagent/workflow_test.go | 82 +++++++++++ 17 files changed, 1293 insertions(+), 16 deletions(-) create mode 100644 googleadk/chat/README.md create mode 100644 googleadk/chat/starter/main.go create mode 100644 googleadk/chat/worker/main.go create mode 100644 googleadk/chat/workflow.go create mode 100644 googleadk/chat/workflow_test.go create mode 100644 googleadk/humanintheloop/README.md create mode 100644 googleadk/humanintheloop/starter/main.go create mode 100644 googleadk/humanintheloop/worker/main.go create mode 100644 googleadk/humanintheloop/workflow.go create mode 100644 googleadk/humanintheloop/workflow_test.go create mode 100644 googleadk/multiagent/README.md create mode 100644 googleadk/multiagent/starter/main.go create mode 100644 googleadk/multiagent/worker/main.go create mode 100644 googleadk/multiagent/workflow.go create mode 100644 googleadk/multiagent/workflow_test.go diff --git a/README.md b/README.md index 4c88633f..bddf1116 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,12 @@ with an external configuration file, like TOML, decoupling connection settings f reconnecting subscriber, an external publisher, a truncating ticker, and LLM token streaming. -- [**Google ADK agent**](./googleadk): Demonstrates running a Google ADK (`adk-go`) agent +- [**Google ADK agent**](./googleadk): Demonstrates running Google ADK (`adk-go`) agents durably using the `googleadk` contrib integration: the agent loop runs in a Workflow, the model call runs as an Activity, and a tool is exposed as a durable Activity via - `ActivityAsTool`. + `ActivityAsTool`. Includes scenario subdirectories for a [multi-agent](./googleadk/multiagent) + system, [human-in-the-loop](./googleadk/humanintheloop) tool approval (durable wait on a + signal), and a [continue-as-new chat](./googleadk/chat). ### Dynamic Workflow logic examples diff --git a/googleadk/README.md b/googleadk/README.md index 2b320c74..1e8d8301 100644 --- a/googleadk/README.md +++ b/googleadk/README.md @@ -1,19 +1,39 @@ -### Google ADK agent +## Google ADK on Temporal -Run a [Google ADK](https://google.github.io/adk-docs/) (`adk-go`) agent **durably -on Temporal** using the [`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) -contrib integration. The agent's orchestration loop runs inside a Workflow; the -model call runs as a Temporal Activity (via `googleadk.NewModel`), and the -`get_weather` tool is an ordinary Temporal activity exposed to the agent with -`googleadk.ActivityAsTool` — so both are retried, timed-out, and visible in the -Temporal UI, and the whole run is replayable. +Run [Google ADK](https://google.github.io/adk-docs/) (`adk-go`) agents **durably on +Temporal** using the [`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) +contrib integration. The agent's orchestration loop runs inside a Workflow; each +model call runs as a Temporal Activity (via `googleadk.NewModel`), and tools can be +ordinary Temporal activities exposed to the agent with `googleadk.ActivityAsTool` +— so model calls and tools are retried, timed-out, and visible in the Temporal UI, +and the whole run is replayable. -The agent is built the native ADK way (`llmagent.New` + `runner.New`); the only +Agents are built the native ADK way (`llmagent.New` + `runner.New`); the only Temporal-specific pieces are `googleadk.NewModel(...)` as the agent's model, `googleadk.NewContext(ctx)` passed to `Run`, and the worker-side -`googleadk.NewActivities(...)` registry that holds the real Gemini client. +`googleadk.NewActivities(...)` registry that holds the real Gemini client (so the +API key stays worker-side, never crossing into the workflow). -### Prerequisites +Every sample runs against a scripted `FakeModel` in its `*_test.go`, so +`go test ./googleadk/...` needs no API key or network. + +### Samples + +- **Basic agent** — the root files in this directory + ([`workflow.go`](workflow.go), [`worker/`](worker), [`starter/`](starter)): a + single agent that answers a question, calling a `get_weather` tool implemented as + a Temporal activity via `googleadk.ActivityAsTool`. +- **[multiagent/](multiagent)** — a `coordinator` root agent that delegates to + `weather` and `jokes` specialist SubAgents via ADK's in-workflow + `transfer_to_agent`. +- **[humanintheloop/](humanintheloop)** — an agent with a sensitive + `delete_resource` tool whose workflow **durably waits on a Temporal signal** for a + human's approval before the tool runs. +- **[chat/](chat)** — a long-lived, signal-driven conversation on one ADK session + that **continues-as-new** (exporting/importing the session) to keep history + bounded. + +### Prerequisites (for running against a live model) - A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) (e.g. `temporal server start-dev`). @@ -22,7 +42,7 @@ Temporal-specific pieces are `googleadk.NewModel(...)` as the agent's model, export GEMINI_API_KEY=... ``` -### Steps to run this sample +### Running the basic agent 1) Start a Temporal server (see prerequisites). @@ -46,10 +66,13 @@ The starter asks "What's the weather in San Francisco?"; the agent calls the The exact wording comes from the model and will vary. In the Temporal UI you will see the run's `googleadk.InvokeModel` and `get_weather` Activities. +Each scenario subdirectory has its own README with run and test instructions. + ### Test without a live LLM -`workflow_test.go` drives the workflow through a scripted `FakeModel` (from the -`googleadk` contrib package), so it needs no API key or network: +`workflow_test.go` (and each scenario's `*_test.go`) drives the workflow through a +scripted `FakeModel` from the `googleadk` contrib package, so it needs no API key +or network: ```bash go test ./googleadk/... ``` diff --git a/googleadk/chat/README.md b/googleadk/chat/README.md new file mode 100644 index 00000000..5f37d2d3 --- /dev/null +++ b/googleadk/chat/README.md @@ -0,0 +1,56 @@ +### Google ADK long-lived chat (bounded history via continue-as-new) + +A **long-lived, signal-driven** Google ADK chat running durably on Temporal with +the [`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) contrib +integration. A single `ChatWorkflow` serves an ongoing conversation: + +- each user message arrives as a Temporal signal (`user-message`); +- the agent answers it on the **same** ADK session, so conversation history + accumulates and later turns have full context; +- the latest answer is readable at any time via a query (`latest-answer`). + +To keep a conversation from growing unbounded in one workflow run, the workflow +**continues-as-new** once Temporal suggests it +(`workflow.GetInfo(ctx).GetContinueAsNewSuggested()`) — or, for the demo, after a +small `MaxTurns` cap. Before continuing it calls `googleadk.ExportSession` to +capture the session (identity, session-scoped state, and event history) into a +serializable `googleadk.SessionSnapshot`, passes it in the `ChatInput` of the next +run, and the next run calls `googleadk.ImportSession` to rebuild the session before +serving the next message. The conversation therefore survives the boundary while +each run's history stays bounded. + +### Prerequisites + +- A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) + (e.g. `temporal server start-dev`). +- A Gemini API key from , exported worker-side. + +### Steps to run this sample + +1) Start a Temporal server (see prerequisites). + +2) In a second terminal, start the worker: +```bash +export GEMINI_API_KEY=... +go run googleadk/chat/worker/main.go +``` + +3) In a third terminal, run the starter: +```bash +go run googleadk/chat/starter/main.go +``` + +The starter starts the chat (with a small `MaxTurns` so continue-as-new fires +quickly), sends a couple of message signals, and queries the latest answer after +each. The workflow keeps running (continuing-as-new to bound history); terminate it +from the Temporal UI when you're done. + +### Test without a live LLM + +`workflow_test.go` drives two messages via signals and asserts the second turn's +model request carried prior history (proving the session persisted across signals), +and exercises the continue-as-new path with `MaxTurns=1`. No API key or network +needed: +```bash +go test ./googleadk/chat/... +``` diff --git a/googleadk/chat/starter/main.go b/googleadk/chat/starter/main.go new file mode 100644 index 00000000..1c764dfd --- /dev/null +++ b/googleadk/chat/starter/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "log" + "time" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + + chat "github.com/temporalio/samples-go/googleadk/chat" +) + +func main() { + // The client is a heavyweight object that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + workflowID := "google-adk-chat_workflowID" + workflowOptions := client.StartWorkflowOptions{ + ID: workflowID, + TaskQueue: chat.TaskQueue, + } + + // A small MaxTurns forces the continue-as-new boundary quickly for the demo. + we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, chat.ChatWorkflow, chat.ChatInput{MaxTurns: 3}) + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + log.Println("Started chat workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID()) + + messages := []string{ + "Hi! My name is David.", + "What's a fun fact about durable execution?", + } + for _, m := range messages { + if err := c.SignalWorkflow(context.Background(), workflowID, "", chat.UserMessageSignalName, m); err != nil { + log.Fatalln("Unable to send message signal", err) + } + log.Printf("Sent message: %q", m) + // Give the agent time to answer before querying. + time.Sleep(2 * time.Second) + + resp, err := c.QueryWorkflow(context.Background(), workflowID, "", chat.LatestAnswerQueryType) + if err != nil { + log.Fatalln("Unable to query latest answer", err) + } + var answer string + if err := resp.Get(&answer); err != nil { + log.Fatalln("Unable to decode query result", err) + } + log.Printf("Assistant: %q", answer) + } + + log.Println("Done. The chat workflow keeps running (continuing-as-new to bound history); terminate it from the UI when finished.") +} diff --git a/googleadk/chat/worker/main.go b/googleadk/chat/worker/main.go new file mode 100644 index 00000000..0532212d --- /dev/null +++ b/googleadk/chat/worker/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/worker" + + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/model/gemini" + + "go.temporal.io/sdk/contrib/googleadk" + + chat "github.com/temporalio/samples-go/googleadk/chat" +) + +func main() { + // The client and worker are heavyweight objects that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, chat.TaskQueue, worker.Options{}) + + w.RegisterWorkflow(chat.ChatWorkflow) + // The chat agent has no tools, so only the model Activity is registered. + + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + chat.ModelName: func(ctx context.Context, name string) (model.LLM, error) { + // nil config reads GEMINI_API_KEY / GOOGLE_API_KEY from the env. + return gemini.NewModel(ctx, name, nil) + }, + }, + }) + if err != nil { + log.Fatalln("Unable to build googleadk activities", err) + } + acts.Register(w) + + if err := w.Run(worker.InterruptCh()); err != nil { + log.Fatalln("Unable to start worker", err) + } +} diff --git a/googleadk/chat/workflow.go b/googleadk/chat/workflow.go new file mode 100644 index 00000000..6e6af7aa --- /dev/null +++ b/googleadk/chat/workflow.go @@ -0,0 +1,166 @@ +// Package chat demonstrates a long-lived, signal-driven Google ADK (adk-go) chat +// running durably on Temporal with the go.temporal.io/sdk/contrib/googleadk +// integration. A single Workflow serves an ongoing conversation: each user message +// arrives as a Temporal signal, the agent answers it on the SAME ADK session (so +// history accumulates), and the latest answer is readable via a query. +// +// To keep history bounded, the Workflow continues-as-new once Temporal suggests it +// (or after a demo turn cap): it exports the session with googleadk.ExportSession, +// then re-imports it at the top of the next run with googleadk.ImportSession — so +// the conversation carries across the continue-as-new boundary without the history +// growing unbounded in a single run. +package chat + +import ( + "go.temporal.io/sdk/workflow" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/genai" + + "go.temporal.io/sdk/contrib/googleadk" +) + +const ( + // TaskQueue is the task queue the worker listens on and the starter targets. + TaskQueue = "google-adk-chat" + + // ModelName is the Gemini model name the agent ships in-workflow. + ModelName = "gemini-2.0-flash" + + // UserMessageSignalName delivers a new user message to the chat workflow. + UserMessageSignalName = "user-message" + + // LatestAnswerQueryType reads the most recent agent answer. + LatestAnswerQueryType = "latest-answer" + + // AppName / UserID / SessionID identify the single conversation session. + AppName = "chat" + UserID = "user-1" + SessionID = "session-1" +) + +// ChatInput is the workflow argument. On first start Snapshot is nil; on a +// continue-as-new it carries the exported session so the conversation resumes. +type ChatInput struct { + // Snapshot, when non-nil, is the session exported by the previous run. + Snapshot *googleadk.SessionSnapshot + // MaxTurns caps the number of messages served before continuing-as-new, so + // the demo can force the boundary without waiting for Temporal's suggestion. + // Zero means "only continue-as-new when Temporal suggests it". + MaxTurns int +} + +// ChatWorkflow serves a long-lived conversation. It imports any prior session, +// then loops receiving user-message signals, running the agent per message on the +// shared session, and recording each answer (readable via a query). When Temporal +// suggests continue-as-new (or MaxTurns is reached) it exports the session and +// continues-as-new carrying the snapshot forward. +func ChatWorkflow(ctx workflow.Context, in ChatInput) error { + // A fresh in-memory session service, kept in a local so we can Export it later. + svc := session.InMemoryService() + + adkCtx := googleadk.NewContext(ctx) + + // Resume a prior conversation if this run was continued-as-new. + if in.Snapshot != nil { + if _, err := googleadk.ImportSession(adkCtx, svc, in.Snapshot); err != nil { + return err + } + } + + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Description: "a friendly conversational assistant", + Model: googleadk.NewModel(ModelName), + Instruction: "You are a helpful assistant. Answer the user, using the conversation history for context.", + }) + if err != nil { + return err + } + + r, err := runner.New(runner.Config{ + AppName: AppName, + Agent: root, + // AutoCreateSession creates the session on first use when we did not import + // one; when we imported, the session already exists. + SessionService: svc, + AutoCreateSession: true, + }) + if err != nil { + return err + } + + // latestAnswer is served by the query handler. + var latestAnswer string + if err := workflow.SetQueryHandler(ctx, LatestAnswerQueryType, func() (string, error) { + return latestAnswer, nil + }); err != nil { + return err + } + + msgCh := workflow.GetSignalChannel(ctx, UserMessageSignalName) + + turns := 0 + for { + // Wait for the next user message. + var text string + msgCh.Receive(ctx, &text) + turns++ + + msg := genai.NewContentFromText(text, genai.RoleUser) + for ev, err := range r.Run(adkCtx, UserID, SessionID, msg, agent.RunConfig{}) { + if err != nil { + return err + } + if ev == nil || ev.Content == nil { + continue + } + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + latestAnswer = p.Text + } + } + } + + // Continue-as-new to keep history bounded: either when Temporal suggests it + // (history/event count getting large) or after the demo turn cap. + suggested := workflow.GetInfo(ctx).GetContinueAsNewSuggested() + capReached := in.MaxTurns > 0 && turns >= in.MaxTurns + if suggested || capReached { + // Drain any messages that arrived while we were serving this turn, so + // they aren't lost across the continue-as-new boundary. + for { + var pending string + if !msgCh.ReceiveAsync(&pending) { + break + } + pmsg := genai.NewContentFromText(pending, genai.RoleUser) + for ev, err := range r.Run(adkCtx, UserID, SessionID, pmsg, agent.RunConfig{}) { + if err != nil { + return err + } + if ev == nil || ev.Content == nil { + continue + } + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + latestAnswer = p.Text + } + } + } + } + + snap, err := googleadk.ExportSession(adkCtx, svc, AppName, UserID, SessionID) + if err != nil { + return err + } + return workflow.NewContinueAsNewError(ctx, ChatWorkflow, ChatInput{ + Snapshot: snap, + MaxTurns: in.MaxTurns, + }) + } + } +} diff --git a/googleadk/chat/workflow_test.go b/googleadk/chat/workflow_test.go new file mode 100644 index 00000000..f6473012 --- /dev/null +++ b/googleadk/chat/workflow_test.go @@ -0,0 +1,131 @@ +package chat_test + +import ( + "context" + "iter" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/testsuite" + "go.temporal.io/sdk/workflow" + + "google.golang.org/adk/v2/model" + + "go.temporal.io/sdk/contrib/googleadk" + + chat "github.com/temporalio/samples-go/googleadk/chat" +) + +// recordingModel wraps a FakeModel and records the number of Contents in each +// request it serves, so a test can prove that a later turn's request carried the +// prior conversation history (proving the session persisted across signals). +type recordingModel struct { + inner *googleadk.FakeModel + + mu sync.Mutex + requestContents []int +} + +func (m *recordingModel) Name() string { return "recording-model" } + +func (m *recordingModel) GenerateContent(ctx context.Context, req *model.LLMRequest, stream bool) iter.Seq2[*model.LLMResponse, error] { + if req != nil { + m.mu.Lock() + m.requestContents = append(m.requestContents, len(req.Contents)) + m.mu.Unlock() + } + return m.inner.GenerateContent(ctx, req, stream) +} + +func (m *recordingModel) contentsAt(turn int) int { + m.mu.Lock() + defer m.mu.Unlock() + if turn < 0 || turn >= len(m.requestContents) { + return -1 + } + return m.requestContents[turn] +} + +// TestChatCarriesHistory drives two user messages through the chat workflow via +// signals and asserts the second turn's model request carried more Contents than +// the first — proving the two messages ran on the SAME ADK session, so history +// accumulated. MaxTurns is high enough that no continue-as-new fires here. +func TestChatCarriesHistory(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + env.RegisterWorkflow(chat.ChatWorkflow) + + rec := &recordingModel{inner: googleadk.NewFakeModel( + googleadk.TextResponse("Hi David, nice to meet you!"), + googleadk.TextResponse("Durable execution means your program's state survives crashes."), + )} + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + chat.ModelName: func(context.Context, string) (model.LLM, error) { return rec, nil }, + }, + }) + require.NoError(t, err) + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + // Send two messages, spaced so each is served before the next arrives. Use a + // high MaxTurns so this test does not hit the continue-as-new path. + env.RegisterDelayedCallback(func() { + env.SignalWorkflow(chat.UserMessageSignalName, "Hi! My name is David.") + }, time.Second) + env.RegisterDelayedCallback(func() { + env.SignalWorkflow(chat.UserMessageSignalName, "What is durable execution?") + }, 5*time.Second) + // End the (otherwise infinite) workflow by cancelling after both are served. + env.RegisterDelayedCallback(func() { + env.CancelWorkflow() + }, 10*time.Second) + + env.ExecuteWorkflow(chat.ChatWorkflow, chat.ChatInput{MaxTurns: 100}) + + require.True(t, env.IsWorkflowCompleted()) + + // Turn 1's request had just the first user message. Turn 2's request also + // carried turn 1's user message + the model's reply — strictly more Contents. + first := rec.contentsAt(0) + second := rec.contentsAt(1) + require.GreaterOrEqual(t, first, 1, "the first turn must have served at least one request") + require.GreaterOrEqual(t, second, 1, "the second turn must have served at least one request") + assert.Greater(t, second, first, "the second turn's request must carry prior history (same session)") +} + +// TestChatContinueAsNew exercises the bounded-history path: with MaxTurns=1 the +// workflow serves one message and then continues-as-new, exporting the session. In +// the test environment a continue-as-new surfaces as a workflow error of type +// *ContinueAsNewError, which is the assertion here. +func TestChatContinueAsNew(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + env.RegisterWorkflow(chat.ChatWorkflow) + + fm := googleadk.NewFakeModel(googleadk.TextResponse("Hello!")) + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + chat.ModelName: func(context.Context, string) (model.LLM, error) { return fm, nil }, + }, + }) + require.NoError(t, err) + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + env.RegisterDelayedCallback(func() { + env.SignalWorkflow(chat.UserMessageSignalName, "Hi there!") + }, time.Second) + + env.ExecuteWorkflow(chat.ChatWorkflow, chat.ChatInput{MaxTurns: 1}) + + require.True(t, env.IsWorkflowCompleted()) + // After serving the single allowed turn, the workflow must continue-as-new. + err = env.GetWorkflowError() + require.Error(t, err, "MaxTurns=1 must trigger a continue-as-new") + var canErr *workflow.ContinueAsNewError + require.ErrorAs(t, err, &canErr, "the workflow must end by continuing-as-new to bound history") +} diff --git a/googleadk/humanintheloop/README.md b/googleadk/humanintheloop/README.md new file mode 100644 index 00000000..ba562e50 --- /dev/null +++ b/googleadk/humanintheloop/README.md @@ -0,0 +1,60 @@ +### Google ADK human-in-the-loop (durable approval) + +A **human-in-the-loop** Google ADK agent running durably on Temporal with the +[`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) contrib +integration. The agent has a sensitive `delete_resource` function tool that must +not run without a human's approval. + +How it works: + +1. The model calls `delete_resource`. On its **first** invocation the tool sees + `ctx.ToolConfirmation() == nil`, calls `ctx.RequestConfirmation("Delete …?", nil)`, + and returns without doing the delete — so ADK pauses the agent. +2. `ApprovalWorkflow` detects the pause via `googleadk.PendingConfirmations` and + **durably blocks on a Temporal signal** (`googleadk.ConfirmationSignalName`, + read with `workflow.GetSignalChannel`) carrying a + `googleadk.ConfirmationDecision`. +3. When the decision arrives, the workflow resumes the run with + `googleadk.ConfirmationResponse(decision)`. ADK re-dispatches the original + tool call, which now sees a confirmation and performs the delete (or is blocked + if denied). + +**This is the differentiator: the wait for the human is durable.** The workflow +can sit blocked for minutes or days and survive worker restarts — no state is lost. +When the approval signal finally arrives, the agent resumes exactly where it paused. + +### Prerequisites + +- A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) + (e.g. `temporal server start-dev`). +- A Gemini API key from , exported worker-side. + +### Steps to run this sample + +1) Start a Temporal server (see prerequisites). + +2) In a second terminal, start the worker: +```bash +export GEMINI_API_KEY=... +go run googleadk/humanintheloop/worker/main.go +``` + +3) In a third terminal, run the starter: +```bash +go run googleadk/humanintheloop/starter/main.go +``` + +The starter asks the agent to delete a resource; the workflow pauses awaiting +approval, and the starter then sends an approval signal (via +`client.SignalWorkflow`) to demonstrate the resume. In a real system the signal +would come from an operator clicking "approve" in a UI, possibly much later. + +### Test without a live LLM + +`workflow_test.go` scripts the model to call `delete_resource`, uses +`env.RegisterDelayedCallback` to deliver the approval through the **real** Temporal +signal, and asserts the delete completes only after approval (plus a denial case). +No API key or network needed: +```bash +go test ./googleadk/humanintheloop/... +``` diff --git a/googleadk/humanintheloop/starter/main.go b/googleadk/humanintheloop/starter/main.go new file mode 100644 index 00000000..eef35692 --- /dev/null +++ b/googleadk/humanintheloop/starter/main.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "log" + "time" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + + "go.temporal.io/sdk/contrib/googleadk" + + humanintheloop "github.com/temporalio/samples-go/googleadk/humanintheloop" +) + +func main() { + // The client is a heavyweight object that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + workflowOptions := client.StartWorkflowOptions{ + ID: "google-adk-hitl_workflowID", + TaskQueue: humanintheloop.TaskQueue, + } + + request := "Please delete the resource named prod-db." + we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, humanintheloop.ApprovalWorkflow, request) + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + log.Println("Started workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID()) + + // The agent will call delete_resource, which pauses awaiting human approval. + // The workflow is now durably blocked on the confirmation signal — it would + // stay blocked indefinitely (surviving worker restarts) until a decision + // arrives. Here we simulate a human approving after a short delay. + log.Println("Waiting for the agent to pause on confirmation, then approving...") + time.Sleep(3 * time.Second) + + decision := googleadk.ConfirmationDecision{Confirmed: true} + if err := c.SignalWorkflow(context.Background(), we.GetID(), we.GetRunID(), googleadk.ConfirmationSignalName, decision); err != nil { + log.Fatalln("Unable to signal approval", err) + } + log.Println("Sent approval signal") + + // Synchronously wait for the workflow completion. + var res humanintheloop.Result + if err := we.Get(context.Background(), &res); err != nil { + log.Fatalln("Unable to get workflow result", err) + } + log.Printf("Approved=%v answer=%q", res.Approved, res.Answer) +} diff --git a/googleadk/humanintheloop/worker/main.go b/googleadk/humanintheloop/worker/main.go new file mode 100644 index 00000000..4b1360e3 --- /dev/null +++ b/googleadk/humanintheloop/worker/main.go @@ -0,0 +1,50 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/worker" + + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/model/gemini" + + "go.temporal.io/sdk/contrib/googleadk" + + humanintheloop "github.com/temporalio/samples-go/googleadk/humanintheloop" +) + +func main() { + // The client and worker are heavyweight objects that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, humanintheloop.TaskQueue, worker.Options{}) + + w.RegisterWorkflow(humanintheloop.ApprovalWorkflow) + // The delete_resource tool is an in-workflow function tool (not an + // ActivityAsTool), so there is no tool activity to register here — only the + // model Activity below. + + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + humanintheloop.ModelName: func(ctx context.Context, name string) (model.LLM, error) { + // nil config reads GEMINI_API_KEY / GOOGLE_API_KEY from the env. + return gemini.NewModel(ctx, name, nil) + }, + }, + }) + if err != nil { + log.Fatalln("Unable to build googleadk activities", err) + } + acts.Register(w) + + if err := w.Run(worker.InterruptCh()); err != nil { + log.Fatalln("Unable to start worker", err) + } +} diff --git a/googleadk/humanintheloop/workflow.go b/googleadk/humanintheloop/workflow.go new file mode 100644 index 00000000..54161058 --- /dev/null +++ b/googleadk/humanintheloop/workflow.go @@ -0,0 +1,148 @@ +// Package humanintheloop demonstrates a human-in-the-loop (HITL) Google ADK +// (adk-go) agent running durably on Temporal with the +// go.temporal.io/sdk/contrib/googleadk integration. The agent has a sensitive +// delete_resource tool that pauses for human approval; the Workflow durably waits +// for a Temporal signal carrying the human's decision before letting the tool run. +// +// This is the key differentiator over a plain agent loop: the wait for the human +// is durable. The Workflow can be idle for days and survive worker restarts — when +// the approval signal finally arrives, the agent resumes exactly where it paused. +package humanintheloop + +import ( + "go.temporal.io/sdk/workflow" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/functiontool" + "google.golang.org/genai" + + "go.temporal.io/sdk/contrib/googleadk" +) + +const ( + // TaskQueue is the task queue the worker listens on and the starter targets. + TaskQueue = "google-adk-hitl" + + // ModelName is the Gemini model name the agent ships in-workflow. + ModelName = "gemini-2.0-flash" + + // DeleteToolName is the name of the sensitive function tool the model calls. + DeleteToolName = "delete_resource" +) + +// DeleteArgs is the argument schema the model fills in for the delete tool. +type DeleteArgs struct { + Resource string `json:"resource"` +} + +// Result is the serializable output of ApprovalWorkflow. +type Result struct { + // Approved reports the human's decision. + Approved bool + // Answer is the agent's final text after the decision was applied. + Answer string +} + +// deleteResource is the sensitive function tool. On its first invocation there is +// no confirmation yet, so it requests one (via ctx.RequestConfirmation) and +// returns without doing the work — this pauses the agent. On the resumed +// invocation ADK supplies a ToolConfirmation, so the delete proceeds. +func deleteResource(tctx agent.Context, args DeleteArgs) (map[string]any, error) { + if tctx.ToolConfirmation() == nil { + if err := tctx.RequestConfirmation("Delete "+args.Resource+"?", nil); err != nil { + return nil, err + } + return map[string]any{"status": "awaiting confirmation"}, nil + } + return map[string]any{"status": "deleted", "resource": args.Resource}, nil +} + +// ApprovalWorkflow runs the agent and, when the sensitive tool pauses awaiting a +// human decision, durably blocks on a Temporal signal named +// googleadk.ConfirmationSignalName carrying a googleadk.ConfirmationDecision. Once +// the decision arrives it resumes the agent with googleadk.ConfirmationResponse, +// so the tool runs (or is blocked) according to the human's choice. +func ApprovalWorkflow(ctx workflow.Context, request string) (Result, error) { + delTool, err := functiontool.New[DeleteArgs, map[string]any]( + functiontool.Config{ + Name: DeleteToolName, + Description: "Delete a named resource. Requires human confirmation before it runs.", + }, + deleteResource, + ) + if err != nil { + return Result{}, err + } + + root, err := llmagent.New(llmagent.Config{ + Name: "assistant", + Description: "an assistant that can delete resources with human approval", + Model: googleadk.NewModel(ModelName), + Instruction: "Use the delete_resource tool when the user asks to delete something.", + Tools: []tool.Tool{delTool}, + }) + if err != nil { + return Result{}, err + } + + r, err := runner.New(runner.Config{ + AppName: "hitl", + Agent: root, + SessionService: session.InMemoryService(), + AutoCreateSession: true, + }) + if err != nil { + return Result{}, err + } + + adkCtx := googleadk.NewContext(ctx) + msg := genai.NewContentFromText(request, genai.RoleUser) + + var res Result + // Drive the run in passes: each Run call is one pass over the same session. A + // pass either completes (no pending confirmation) or pauses awaiting a human. + for { + var events []*session.Event + for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) { + if err != nil { + return Result{}, err + } + if ev == nil { + continue + } + events = append(events, ev) + if ev.Content != nil { + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + res.Answer = p.Text + } + } + } + } + + pending := googleadk.PendingConfirmations(events) + if len(pending) == 0 { + // The agent finished without (further) confirmations needed. + return res, nil + } + + // The agent paused. Durably wait for the human's decision to arrive as a + // Temporal signal. This is the whole point: the workflow can sit here for + // as long as it takes — across worker restarts — without losing state. + var decision googleadk.ConfirmationDecision + workflow.GetSignalChannel(ctx, googleadk.ConfirmationSignalName).Receive(ctx, &decision) + res.Approved = decision.Confirmed + + // Match the decision to the pending confirmation and resume the run with + // it as the next message. ADK re-dispatches (or blocks) the original tool + // call based on Confirmed. + if decision.FunctionCallID == "" { + decision.FunctionCallID = pending[0].FunctionCallID + } + msg = googleadk.ConfirmationResponse(decision) + } +} diff --git a/googleadk/humanintheloop/workflow_test.go b/googleadk/humanintheloop/workflow_test.go new file mode 100644 index 00000000..1a74b7e7 --- /dev/null +++ b/googleadk/humanintheloop/workflow_test.go @@ -0,0 +1,101 @@ +package humanintheloop_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/testsuite" + + "google.golang.org/adk/v2/model" + + "go.temporal.io/sdk/contrib/googleadk" + + humanintheloop "github.com/temporalio/samples-go/googleadk/humanintheloop" +) + +// scriptedModelFactory returns a ModelFactory yielding a single shared FakeModel +// so its scripted responses advance turn by turn across Activity invocations. +func scriptedModelFactory(responses ...*model.LLMResponse) googleadk.ModelFactory { + fm := googleadk.NewFakeModel(responses...) + return func(context.Context, string) (model.LLM, error) { return fm, nil } +} + +// TestApprovalWorkflow proves the durable human-in-the-loop wait: the agent calls +// the sensitive delete_resource tool, which pauses the workflow awaiting a human +// decision. A delayed callback delivers an approval via the real Temporal signal +// (googleadk.ConfirmationSignalName), after which the workflow resumes and the +// delete runs. This exercises the actual signal round-trip, not a two-pass loop. +func TestApprovalWorkflow(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + + env.RegisterWorkflow(humanintheloop.ApprovalWorkflow) + + // Scripted model: turn 1 (before approval) calls delete_resource; turn 2 + // (after the resume) produces the final text confirming the delete. + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + humanintheloop.ModelName: scriptedModelFactory( + googleadk.FunctionCallResponse("call-1", humanintheloop.DeleteToolName, map[string]any{"resource": "prod-db"}), + googleadk.TextResponse("Deleted prod-db."), + ), + }, + }) + require.NoError(t, err) + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + // Approve after a delay, via the real signal the workflow blocks on. Until this + // fires the workflow is durably waiting; the delete cannot have run yet. + env.RegisterDelayedCallback(func() { + env.SignalWorkflow(googleadk.ConfirmationSignalName, googleadk.ConfirmationDecision{Confirmed: true}) + }, time.Second) + + env.ExecuteWorkflow(humanintheloop.ApprovalWorkflow, "Please delete the resource named prod-db.") + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var res humanintheloop.Result + require.NoError(t, env.GetWorkflowResult(&res)) + assert.True(t, res.Approved, "the workflow must record the human's approval") + // The final answer is produced only on the resumed pass — after approval. + assert.Contains(t, res.Answer, "Deleted") +} + +// TestApprovalWorkflowDenied proves denial also flows through: the human denies, +// the workflow records it, and the delete does not report success. +func TestApprovalWorkflowDenied(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + + env.RegisterWorkflow(humanintheloop.ApprovalWorkflow) + + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + humanintheloop.ModelName: scriptedModelFactory( + googleadk.FunctionCallResponse("call-1", humanintheloop.DeleteToolName, map[string]any{"resource": "prod-db"}), + googleadk.TextResponse("Okay, I did not delete prod-db."), + ), + }, + }) + require.NoError(t, err) + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + env.RegisterDelayedCallback(func() { + env.SignalWorkflow(googleadk.ConfirmationSignalName, googleadk.ConfirmationDecision{Confirmed: false}) + }, time.Second) + + env.ExecuteWorkflow(humanintheloop.ApprovalWorkflow, "Please delete the resource named prod-db.") + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var res humanintheloop.Result + require.NoError(t, env.GetWorkflowResult(&res)) + assert.False(t, res.Approved, "the workflow must record the human's denial") +} diff --git a/googleadk/multiagent/README.md b/googleadk/multiagent/README.md new file mode 100644 index 00000000..0d7e37a2 --- /dev/null +++ b/googleadk/multiagent/README.md @@ -0,0 +1,55 @@ +### Google ADK multi-agent (coordinator + specialists) + +A **multi-agent** Google ADK system running durably on Temporal with the +[`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) contrib +integration. A `coordinator` root agent delegates the user's request to one of two +specialist **SubAgents**: + +- a `weather` specialist that owns the `get_weather` tool (an ordinary Temporal + activity exposed via `googleadk.ActivityAsTool`), and +- a `jokes` specialist. + +The coordinator picks a specialist by emitting ADK's built-in `transfer_to_agent` +call. **That transfer is resolved in-workflow** — it is not a separate Temporal +workflow or a network round-trip — so the whole delegation is deterministic and +replayable. Only the per-agent model calls and the `get_weather` tool cross the +Activity boundary. + +Each agent is given a **distinct model name** (`gemini-2.0-flash-coordinator`, +`-weather`, `-jokes`) so tests can register and script a separate `FakeModel` per +agent; in production all three names resolve to the same real Gemini model behind +`googleadk.InvokeModel`. + +### Prerequisites + +- A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) + (e.g. `temporal server start-dev`). +- A Gemini API key from , exported worker-side. + +### Steps to run this sample + +1) Start a Temporal server (see prerequisites). + +2) In a second terminal, start the worker: +```bash +export GEMINI_API_KEY=... +go run googleadk/multiagent/worker/main.go +``` + +3) In a third terminal, run the starter: +```bash +go run googleadk/multiagent/starter/main.go +``` + +The starter asks a weather question; the coordinator transfers to the `weather` +specialist, which calls `get_weather` and answers. In the Temporal UI you will see +one `googleadk.InvokeModel` Activity per agent turn plus the `get_weather` Activity. + +### Test without a live LLM + +`workflow_test.go` scripts each agent's `FakeModel` (coordinator transfers to +`weather`; the weather specialist calls `get_weather` then answers) and asserts the +transfer took effect, so it needs no API key or network: +```bash +go test ./googleadk/multiagent/... +``` diff --git a/googleadk/multiagent/starter/main.go b/googleadk/multiagent/starter/main.go new file mode 100644 index 00000000..c6df4c6e --- /dev/null +++ b/googleadk/multiagent/starter/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + + multiagent "github.com/temporalio/samples-go/googleadk/multiagent" +) + +func main() { + // The client is a heavyweight object that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + workflowOptions := client.StartWorkflowOptions{ + ID: "google-adk-multiagent_workflowID", + TaskQueue: multiagent.TaskQueue, + } + + question := "What's the weather in San Francisco?" + we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, multiagent.MultiAgentWorkflow, question) + if err != nil { + log.Fatalln("Unable to execute workflow", err) + } + + log.Println("Started workflow", "WorkflowID", we.GetID(), "RunID", we.GetRunID()) + + // Synchronously wait for the workflow completion. + var answer string + if err := we.Get(context.Background(), &answer); err != nil { + log.Fatalln("Unable to get workflow result", err) + } + log.Println("Agent answer:", answer) +} diff --git a/googleadk/multiagent/worker/main.go b/googleadk/multiagent/worker/main.go new file mode 100644 index 00000000..9ee1a97b --- /dev/null +++ b/googleadk/multiagent/worker/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "log" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" + "go.temporal.io/sdk/worker" + + "google.golang.org/adk/v2/model" + "google.golang.org/adk/v2/model/gemini" + + "go.temporal.io/sdk/contrib/googleadk" + + multiagent "github.com/temporalio/samples-go/googleadk/multiagent" +) + +func main() { + // The client and worker are heavyweight objects that should be created once per process. + c, err := client.Dial(envconfig.MustLoadDefaultClientOptions()) + if err != nil { + log.Fatalln("Unable to create client", err) + } + defer c.Close() + + w := worker.New(c, multiagent.TaskQueue, worker.Options{}) + + w.RegisterWorkflow(multiagent.MultiAgentWorkflow) + // Register GetWeather under the tool name the ActivityAsTool dispatches, so the + // weather specialist's get_weather call resolves to this activity. + w.RegisterActivityWithOptions(multiagent.GetWeather, activity.RegisterOptions{Name: multiagent.WeatherToolName}) + + // Register the integration's model Activity. Every agent in the tree uses a + // distinct model name so they can be scripted independently in tests; here + // they all resolve to the same real Gemini model. The API key is read + // worker-side and never crosses into the workflow. + gemModel := func(ctx context.Context, name string) (model.LLM, error) { + // nil config reads GEMINI_API_KEY / GOOGLE_API_KEY from the env. + return gemini.NewModel(ctx, "gemini-2.0-flash", nil) + } + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + multiagent.CoordinatorModelName: gemModel, + multiagent.WeatherModelName: gemModel, + multiagent.JokesModelName: gemModel, + }, + }) + if err != nil { + log.Fatalln("Unable to build googleadk activities", err) + } + acts.Register(w) + + if err := w.Run(worker.InterruptCh()); err != nil { + log.Fatalln("Unable to start worker", err) + } +} diff --git a/googleadk/multiagent/workflow.go b/googleadk/multiagent/workflow.go new file mode 100644 index 00000000..e1234ce2 --- /dev/null +++ b/googleadk/multiagent/workflow.go @@ -0,0 +1,143 @@ +// Package multiagent demonstrates a multi-agent Google ADK (adk-go) system +// running durably on Temporal with the go.temporal.io/sdk/contrib/googleadk +// integration. A "coordinator" root agent delegates to one of two specialist +// SubAgents — a weather specialist (which owns the get_weather ActivityAsTool) +// and a jokes specialist — via ADK's built-in transfer_to_agent mechanism. +// +// The entire multi-agent orchestration, including the transfer_to_agent hop, runs +// inside the Workflow; only the model calls (one per agent turn) and the +// get_weather tool run as Temporal Activities. +package multiagent + +import ( + "context" + + "go.temporal.io/sdk/workflow" + + "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/agent/llmagent" + "google.golang.org/adk/v2/runner" + "google.golang.org/adk/v2/session" + "google.golang.org/adk/v2/tool" + "google.golang.org/genai" + + "go.temporal.io/sdk/contrib/googleadk" +) + +const ( + // TaskQueue is the task queue the worker listens on and the starter targets. + TaskQueue = "google-adk-multiagent" + + // CoordinatorModelName, WeatherModelName, and JokesModelName are distinct + // model names so the worker (and the test) can register a separate + // ModelFactory per agent and script each one independently. In production + // they all resolve to the same Gemini model behind the InvokeModel Activity. + CoordinatorModelName = "gemini-2.0-flash-coordinator" + WeatherModelName = "gemini-2.0-flash-weather" + JokesModelName = "gemini-2.0-flash-jokes" + + // WeatherToolName is both the tool name the model sees and the Activity name + // the tool dispatches, so the worker must register GetWeather under this name. + WeatherToolName = "get_weather" +) + +// GetWeatherInput is the argument schema the model fills in for the weather tool. +type GetWeatherInput struct { + City string `json:"city"` +} + +// GetWeatherOutput is the weather tool's result, handed back to the model. +type GetWeatherOutput struct { + City string `json:"city"` + Conditions string `json:"conditions"` +} + +// GetWeather is an ordinary Temporal activity exposed to the weather specialist +// as a tool via googleadk.ActivityAsTool. It runs durably worker-side whenever +// the specialist calls it. A real implementation would call a weather API here. +func GetWeather(ctx context.Context, in GetWeatherInput) (GetWeatherOutput, error) { + return GetWeatherOutput{City: in.City, Conditions: "sunny, 72°F"}, nil +} + +// MultiAgentWorkflow builds a coordinator agent with two specialist SubAgents and +// runs the question through the tree. The coordinator decides which specialist to +// delegate to (emitting a transfer_to_agent call, which ADK resolves in-workflow); +// the chosen specialist then answers. The workflow returns the final text. +func MultiAgentWorkflow(ctx workflow.Context, question string) (string, error) { + weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{ + Name: WeatherToolName, + Description: "Get the current weather for a city.", + }) + if err != nil { + return "", err + } + + // The weather specialist owns the get_weather tool. + weather, err := llmagent.New(llmagent.Config{ + Name: "weather", + Description: "answers questions about the current weather in a city", + Model: googleadk.NewModel(WeatherModelName), + Instruction: "You are a weather specialist. Use the get_weather tool to answer weather questions.", + Tools: []tool.Tool{weatherTool}, + }) + if err != nil { + return "", err + } + + // The jokes specialist just tells jokes. + jokes, err := llmagent.New(llmagent.Config{ + Name: "jokes", + Description: "tells a light-hearted joke", + Model: googleadk.NewModel(JokesModelName), + Instruction: "You are a comedian. Respond with a short, friendly joke.", + }) + if err != nil { + return "", err + } + + // The coordinator delegates to whichever specialist fits the question. ADK + // wires the parent/child relationship from SubAgents and exposes the built-in + // transfer_to_agent tool automatically. + coordinator, err := llmagent.New(llmagent.Config{ + Name: "coordinator", + Description: "routes the user's request to the right specialist", + Model: googleadk.NewModel(CoordinatorModelName), + Instruction: "You are a router. Delegate weather questions to the weather agent " + + "and requests for a joke to the jokes agent. Do not answer directly.", + SubAgents: []agent.Agent{weather, jokes}, + }) + if err != nil { + return "", err + } + + r, err := runner.New(runner.Config{ + AppName: "multiagent", + Agent: coordinator, + SessionService: session.InMemoryService(), + AutoCreateSession: true, + }) + if err != nil { + return "", err + } + + adkCtx := googleadk.NewContext(ctx) + msg := genai.NewContentFromText(question, genai.RoleUser) + + var answer string + for ev, err := range r.Run(adkCtx, "user-1", "session-1", msg, agent.RunConfig{}) { + if err != nil { + return "", err + } + if ev == nil || ev.Content == nil { + continue + } + // Keep the last non-empty text produced by any agent in the tree; after a + // transfer_to_agent hop this is the specialist's answer. + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + answer = p.Text + } + } + } + return answer, nil +} diff --git a/googleadk/multiagent/workflow_test.go b/googleadk/multiagent/workflow_test.go new file mode 100644 index 00000000..739e3127 --- /dev/null +++ b/googleadk/multiagent/workflow_test.go @@ -0,0 +1,82 @@ +package multiagent_test + +import ( + "context" + "testing" + + "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" + + "google.golang.org/adk/v2/model" + + "go.temporal.io/sdk/contrib/googleadk" + + multiagent "github.com/temporalio/samples-go/googleadk/multiagent" +) + +// scriptedModelFactory returns a ModelFactory yielding a single shared FakeModel +// so its scripted responses advance turn by turn across Activity invocations +// (turn 1 = first response, turn 2 = second, ...). +func scriptedModelFactory(responses ...*model.LLMResponse) googleadk.ModelFactory { + fm := googleadk.NewFakeModel(responses...) + return func(context.Context, string) (model.LLM, error) { return fm, nil } +} + +// TestMultiAgentWorkflow drives the coordinator/specialist tree through scripted +// FakeModels — no live LLM. The coordinator emits a transfer_to_agent call to the +// weather specialist; the specialist then calls get_weather and answers. The test +// asserts the specialist's answer surfaced and that the get_weather tool ran (as a +// real activity), proving the transfer took effect. +func TestMultiAgentWorkflow(t *testing.T) { + var s testsuite.WorkflowTestSuite + env := s.NewTestWorkflowEnvironment() + + env.RegisterWorkflow(multiagent.MultiAgentWorkflow) + env.RegisterActivityWithOptions(multiagent.GetWeather, activity.RegisterOptions{Name: multiagent.WeatherToolName}) + + // Track whether the get_weather activity ran — it only does if the coordinator + // successfully transferred to the weather specialist and the specialist called + // the tool. + var weatherRan bool + env.SetOnActivityStartedListener(func(info *activity.Info, _ context.Context, _ converter.EncodedValues) { + if info.ActivityType.Name == multiagent.WeatherToolName { + weatherRan = true + } + }) + + // Per-agent scripted models keyed by their distinct model names. + // coordinator: turn 1 delegates to the weather specialist. + // weather specialist: turn 1 calls get_weather, turn 2 answers. + // jokes specialist: never invoked in this scenario. + acts, err := googleadk.NewActivities(googleadk.Config{ + Models: map[string]googleadk.ModelFactory{ + multiagent.CoordinatorModelName: scriptedModelFactory( + googleadk.FunctionCallResponse("t1", "transfer_to_agent", map[string]any{"agent_name": "weather"}), + ), + multiagent.WeatherModelName: scriptedModelFactory( + googleadk.FunctionCallResponse("call-1", multiagent.WeatherToolName, map[string]any{"city": "San Francisco"}), + googleadk.TextResponse("It's sunny, 72°F in San Francisco."), + ), + multiagent.JokesModelName: scriptedModelFactory( + googleadk.TextResponse("Why did the cloud break up with the fog? It needed space."), + ), + }, + }) + require.NoError(t, err) + env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) + + env.ExecuteWorkflow(multiagent.MultiAgentWorkflow, "What's the weather in San Francisco?") + + require.True(t, env.IsWorkflowCompleted()) + require.NoError(t, env.GetWorkflowError()) + + var answer string + require.NoError(t, env.GetWorkflowResult(&answer)) + // The final answer came from the weather specialist after the transfer. + assert.Contains(t, answer, "sunny") + assert.True(t, weatherRan, "the transfer must reach the weather specialist so get_weather runs") +} From 750c657f594d5c7c8ee87948acde6ac036d40fbb Mon Sep 17 00:00:00 2001 From: DABH Date: Tue, 7 Jul 2026 23:00:04 -0500 Subject: [PATCH 3/6] Clarify HITL sample: real destructive ops belong in an ActivityAsTool; one confirmation per pass --- googleadk/humanintheloop/README.md | 14 ++++++++++++++ googleadk/humanintheloop/workflow.go | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/googleadk/humanintheloop/README.md b/googleadk/humanintheloop/README.md index ba562e50..607b8401 100644 --- a/googleadk/humanintheloop/README.md +++ b/googleadk/humanintheloop/README.md @@ -23,6 +23,20 @@ How it works: can sit blocked for minutes or days and survive worker restarts — no state is lost. When the approval signal finally arrives, the agent resumes exactly where it paused. +### Notes + +- **`delete_resource` runs in-workflow and only *simulates* the delete** (it returns + a status map) to keep the demo deterministic. A real destructive operation does I/O + and must not run in the workflow — expose it with `googleadk.ActivityAsTool` so it + runs worker-side under Temporal's retry/timeout policy. The confirmation gate is + identical either way: the tool still calls `ctx.RequestConfirmation(...)` before + doing the work. +- **The workflow handles one pending confirmation per resume pass.** If a single + model turn asked to approve several tool calls at once, you would collect a + decision for each and pass them together to + `googleadk.ConfirmationResponse(decisions...)`. This sample keeps to the common + single-confirmation case. + ### Prerequisites - A running [Temporal server](https://github.com/temporalio/samples-go/tree/main/#how-to-use) diff --git a/googleadk/humanintheloop/workflow.go b/googleadk/humanintheloop/workflow.go index 54161058..7a2f879f 100644 --- a/googleadk/humanintheloop/workflow.go +++ b/googleadk/humanintheloop/workflow.go @@ -51,6 +51,13 @@ type Result struct { // no confirmation yet, so it requests one (via ctx.RequestConfirmation) and // returns without doing the work — this pauses the agent. On the resumed // invocation ADK supplies a ToolConfirmation, so the delete proceeds. +// +// NOTE: this runs in-workflow and only simulates the delete (it returns a status +// map), which keeps the sample deterministic. A real destructive operation does +// I/O and must NOT run in the workflow — expose it with googleadk.ActivityAsTool +// so it runs worker-side under Temporal's retry/timeout policy. The confirmation +// gate is identical either way: request confirmation first, do the work only once +// confirmed. func deleteResource(tctx agent.Context, args DeleteArgs) (map[string]any, error) { if tctx.ToolConfirmation() == nil { if err := tctx.RequestConfirmation("Delete "+args.Resource+"?", nil); err != nil { @@ -133,6 +140,11 @@ func ApprovalWorkflow(ctx workflow.Context, request string) (Result, error) { // The agent paused. Durably wait for the human's decision to arrive as a // Temporal signal. This is the whole point: the workflow can sit here for // as long as it takes — across worker restarts — without losing state. + // + // This handles one pending confirmation per pass (the common case). If a + // single model turn requested several confirmations at once, collect a + // decision for each and pass them together: + // googleadk.ConfirmationResponse(decisions...). var decision googleadk.ConfirmationDecision workflow.GetSignalChannel(ctx, googleadk.ConfirmationSignalName).Receive(ctx, &decision) res.Approved = decision.Confirmed From d7763157cffb378e68ee9fa5b84e2be9792953a8 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 16:09:01 -0500 Subject: [PATCH 4/6] Add snippet markers for the docs page and googleadk CODEOWNERS Wrap the worker wiring, agent workflow, weather tool, starter, and the multi-agent / human-in-the-loop / continue-as-new workflow funcs with @@@SNIPSTART markers so the Google ADK Go integration docs page can source snippets via snipsync. Comment-only; no behavior change. Also add a CODEOWNERS entry for the googleadk samples. --- .github/CODEOWNERS | 1 + googleadk/chat/workflow.go | 3 +++ googleadk/humanintheloop/workflow.go | 3 +++ googleadk/multiagent/workflow.go | 3 +++ googleadk/starter/main.go | 2 ++ googleadk/worker/main.go | 2 ++ googleadk/workflow.go | 6 ++++++ 7 files changed, 20 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6ec63229..5762242d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,3 +5,4 @@ * @temporalio/sdk /workflowstreams/ @temporalio/sdk @temporalio/ai-sdk +/googleadk/ @temporalio/sdk @temporalio/ai-sdk diff --git a/googleadk/chat/workflow.go b/googleadk/chat/workflow.go index 6e6af7aa..283feb15 100644 --- a/googleadk/chat/workflow.go +++ b/googleadk/chat/workflow.go @@ -58,6 +58,7 @@ type ChatInput struct { // shared session, and recording each answer (readable via a query). When Temporal // suggests continue-as-new (or MaxTurns is reached) it exports the session and // continues-as-new carrying the snapshot forward. +// @@@SNIPSTART googleadk-chat-workflow func ChatWorkflow(ctx workflow.Context, in ChatInput) error { // A fresh in-memory session service, kept in a local so we can Export it later. svc := session.InMemoryService() @@ -164,3 +165,5 @@ func ChatWorkflow(ctx workflow.Context, in ChatInput) error { } } } + +// @@@SNIPEND diff --git a/googleadk/humanintheloop/workflow.go b/googleadk/humanintheloop/workflow.go index 7a2f879f..dcdd7f29 100644 --- a/googleadk/humanintheloop/workflow.go +++ b/googleadk/humanintheloop/workflow.go @@ -73,6 +73,7 @@ func deleteResource(tctx agent.Context, args DeleteArgs) (map[string]any, error) // googleadk.ConfirmationSignalName carrying a googleadk.ConfirmationDecision. Once // the decision arrives it resumes the agent with googleadk.ConfirmationResponse, // so the tool runs (or is blocked) according to the human's choice. +// @@@SNIPSTART googleadk-hitl-workflow func ApprovalWorkflow(ctx workflow.Context, request string) (Result, error) { delTool, err := functiontool.New[DeleteArgs, map[string]any]( functiontool.Config{ @@ -158,3 +159,5 @@ func ApprovalWorkflow(ctx workflow.Context, request string) (Result, error) { msg = googleadk.ConfirmationResponse(decision) } } + +// @@@SNIPEND diff --git a/googleadk/multiagent/workflow.go b/googleadk/multiagent/workflow.go index e1234ce2..818ff7d5 100644 --- a/googleadk/multiagent/workflow.go +++ b/googleadk/multiagent/workflow.go @@ -63,6 +63,7 @@ func GetWeather(ctx context.Context, in GetWeatherInput) (GetWeatherOutput, erro // runs the question through the tree. The coordinator decides which specialist to // delegate to (emitting a transfer_to_agent call, which ADK resolves in-workflow); // the chosen specialist then answers. The workflow returns the final text. +// @@@SNIPSTART googleadk-multiagent-workflow func MultiAgentWorkflow(ctx workflow.Context, question string) (string, error) { weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{ Name: WeatherToolName, @@ -141,3 +142,5 @@ func MultiAgentWorkflow(ctx workflow.Context, question string) (string, error) { } return answer, nil } + +// @@@SNIPEND diff --git a/googleadk/starter/main.go b/googleadk/starter/main.go index aff758e1..5e8c5360 100644 --- a/googleadk/starter/main.go +++ b/googleadk/starter/main.go @@ -23,6 +23,7 @@ func main() { TaskQueue: adk.TaskQueue, } + // @@@SNIPSTART googleadk-hello-starter question := "What's the weather in San Francisco?" we, err := c.ExecuteWorkflow(context.Background(), workflowOptions, adk.AgentWorkflow, question) if err != nil { @@ -37,4 +38,5 @@ func main() { log.Fatalln("Unable to get workflow result", err) } log.Println("Agent answer:", answer) + // @@@SNIPEND } diff --git a/googleadk/worker/main.go b/googleadk/worker/main.go index bd2f0680..d001ad9b 100644 --- a/googleadk/worker/main.go +++ b/googleadk/worker/main.go @@ -25,6 +25,7 @@ func main() { } defer c.Close() + // @@@SNIPSTART googleadk-hello-worker w := worker.New(c, adk.TaskQueue, worker.Options{}) w.RegisterWorkflow(adk.AgentWorkflow) @@ -52,4 +53,5 @@ func main() { if err := w.Run(worker.InterruptCh()); err != nil { log.Fatalln("Unable to start worker", err) } + // @@@SNIPEND } diff --git a/googleadk/workflow.go b/googleadk/workflow.go index ce6ba1d0..7793e76b 100644 --- a/googleadk/workflow.go +++ b/googleadk/workflow.go @@ -49,14 +49,18 @@ type GetWeatherOutput struct { // googleadk.ActivityAsTool, it runs durably worker-side (retried, timed-out, // visible in the UI) whenever the model calls it — the recommended pattern for a // tool that does I/O. A real implementation would call a weather API here. +// @@@SNIPSTART googleadk-hello-tool func GetWeather(ctx context.Context, in GetWeatherInput) (GetWeatherOutput, error) { return GetWeatherOutput{City: in.City, Conditions: "sunny, 72°F"}, nil } +// @@@SNIPEND + // AgentWorkflow runs a native ADK agent durably. The model call inside r.Run is // dispatched to the InvokeModel Activity by googleadk.NewModel, and the // get_weather tool call is dispatched to the GetWeather Activity by // googleadk.ActivityAsTool. +// @@@SNIPSTART googleadk-hello-workflow func AgentWorkflow(ctx workflow.Context, question string) (string, error) { weatherTool, err := googleadk.ActivityAsTool(GetWeather, googleadk.ActivityToolOptions{ Name: WeatherToolName, @@ -109,3 +113,5 @@ func AgentWorkflow(ctx workflow.Context, question string) (string, error) { } return answer, nil } + +// @@@SNIPEND From 85e66de03b1cd70cb37ad8e7745d67462c2a7eb6 Mon Sep 17 00:00:00 2001 From: DABH Date: Wed, 15 Jul 2026 16:28:27 -0500 Subject: [PATCH 5/6] Use a Workflow Update (not signal + query) for chat agent turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the chat scenario: it delivered each user message as a signal and read the answer via a polled query. Switch to a `send-message` Update that runs one agent turn on the shared ADK session and returns the answer on the same call — no polling. Turns are serialized so concurrent Updates can't interleave on the session, and continue-as-new drains in-flight turns via AllHandlersFinished before exporting the session. Starter and test drive Updates. --- googleadk/chat/README.md | 31 ++++--- googleadk/chat/starter/main.go | 24 ++--- googleadk/chat/workflow.go | 154 ++++++++++++++++---------------- googleadk/chat/workflow_test.go | 22 +++-- 4 files changed, 122 insertions(+), 109 deletions(-) diff --git a/googleadk/chat/README.md b/googleadk/chat/README.md index 5f37d2d3..fe82527f 100644 --- a/googleadk/chat/README.md +++ b/googleadk/chat/README.md @@ -1,23 +1,26 @@ ### Google ADK long-lived chat (bounded history via continue-as-new) -A **long-lived, signal-driven** Google ADK chat running durably on Temporal with +A **long-lived, update-driven** Google ADK chat running durably on Temporal with the [`googleadk`](https://pkg.go.dev/go.temporal.io/sdk/contrib/googleadk) contrib integration. A single `ChatWorkflow` serves an ongoing conversation: -- each user message arrives as a Temporal signal (`user-message`); +- each user message arrives as a Temporal **Update** (`send-message`); - the agent answers it on the **same** ADK session, so conversation history accumulates and later turns have full context; -- the latest answer is readable at any time via a query (`latest-answer`). +- the answer is **returned on the Update itself** — the caller sends a message and + gets the reply on one call, with no signal + query polling. Turns are serialized + so concurrent Updates can't interleave on the shared session. To keep a conversation from growing unbounded in one workflow run, the workflow **continues-as-new** once Temporal suggests it (`workflow.GetInfo(ctx).GetContinueAsNewSuggested()`) — or, for the demo, after a -small `MaxTurns` cap. Before continuing it calls `googleadk.ExportSession` to -capture the session (identity, session-scoped state, and event history) into a -serializable `googleadk.SessionSnapshot`, passes it in the `ChatInput` of the next -run, and the next run calls `googleadk.ImportSession` to rebuild the session before -serving the next message. The conversation therefore survives the boundary while -each run's history stays bounded. +small `MaxTurns` cap. Before continuing it drains any in-flight turn +(`workflow.AllHandlersFinished`), then calls `googleadk.ExportSession` to capture the +session (identity, session-scoped state, and event history) into a serializable +`googleadk.SessionSnapshot`, passes it in the `ChatInput` of the next run, and the +next run calls `googleadk.ImportSession` to rebuild the session before serving the +next message. The conversation therefore survives the boundary while each run's +history stays bounded. ### Prerequisites @@ -41,14 +44,14 @@ go run googleadk/chat/starter/main.go ``` The starter starts the chat (with a small `MaxTurns` so continue-as-new fires -quickly), sends a couple of message signals, and queries the latest answer after -each. The workflow keeps running (continuing-as-new to bound history); terminate it -from the Temporal UI when you're done. +quickly) and sends a couple of messages via Updates, printing each answer as it +comes back. The workflow keeps running (continuing-as-new to bound history); +terminate it from the Temporal UI when you're done. ### Test without a live LLM -`workflow_test.go` drives two messages via signals and asserts the second turn's -model request carried prior history (proving the session persisted across signals), +`workflow_test.go` drives two messages via Updates and asserts the second turn's +model request carried prior history (proving the session persisted across turns), and exercises the continue-as-new path with `MaxTurns=1`. No API key or network needed: ```bash diff --git a/googleadk/chat/starter/main.go b/googleadk/chat/starter/main.go index 1c764dfd..815cf898 100644 --- a/googleadk/chat/starter/main.go +++ b/googleadk/chat/starter/main.go @@ -3,7 +3,6 @@ package main import ( "context" "log" - "time" "go.temporal.io/sdk/client" "go.temporal.io/sdk/contrib/envconfig" @@ -37,21 +36,22 @@ func main() { "What's a fun fact about durable execution?", } for _, m := range messages { - if err := c.SignalWorkflow(context.Background(), workflowID, "", chat.UserMessageSignalName, m); err != nil { - log.Fatalln("Unable to send message signal", err) - } - log.Printf("Sent message: %q", m) - // Give the agent time to answer before querying. - time.Sleep(2 * time.Second) - - resp, err := c.QueryWorkflow(context.Background(), workflowID, "", chat.LatestAnswerQueryType) + // Send the message as an Update and get the agent's answer back on the same + // call — no signal + query polling. + handle, err := c.UpdateWorkflow(context.Background(), client.UpdateWorkflowOptions{ + WorkflowID: workflowID, + UpdateName: chat.SendMessageUpdateName, + WaitForStage: client.WorkflowUpdateStageCompleted, + Args: []interface{}{m}, + }) if err != nil { - log.Fatalln("Unable to query latest answer", err) + log.Fatalln("Unable to send message update", err) } var answer string - if err := resp.Get(&answer); err != nil { - log.Fatalln("Unable to decode query result", err) + if err := handle.Get(context.Background(), &answer); err != nil { + log.Fatalln("Unable to get update result", err) } + log.Printf("You: %q", m) log.Printf("Assistant: %q", answer) } diff --git a/googleadk/chat/workflow.go b/googleadk/chat/workflow.go index 283feb15..053f9985 100644 --- a/googleadk/chat/workflow.go +++ b/googleadk/chat/workflow.go @@ -1,8 +1,9 @@ -// Package chat demonstrates a long-lived, signal-driven Google ADK (adk-go) chat +// Package chat demonstrates a long-lived, update-driven Google ADK (adk-go) chat // running durably on Temporal with the go.temporal.io/sdk/contrib/googleadk // integration. A single Workflow serves an ongoing conversation: each user message -// arrives as a Temporal signal, the agent answers it on the SAME ADK session (so -// history accumulates), and the latest answer is readable via a query. +// arrives as a Temporal Update, the agent answers it on the SAME ADK session (so +// history accumulates), and the answer is returned on the Update itself — no signal +// + query polling. // // To keep history bounded, the Workflow continues-as-new once Temporal suggests it // (or after a demo turn cap): it exports the session with googleadk.ExportSession, @@ -12,6 +13,8 @@ package chat import ( + "fmt" + "go.temporal.io/sdk/workflow" "google.golang.org/adk/v2/agent" @@ -30,11 +33,10 @@ const ( // ModelName is the Gemini model name the agent ships in-workflow. ModelName = "gemini-2.0-flash" - // UserMessageSignalName delivers a new user message to the chat workflow. - UserMessageSignalName = "user-message" - - // LatestAnswerQueryType reads the most recent agent answer. - LatestAnswerQueryType = "latest-answer" + // SendMessageUpdateName is the Update that delivers a user message and returns + // the agent's answer. An Update (rather than a signal + query) lets the caller + // send the message and receive the answer on one call, with no polling. + SendMessageUpdateName = "send-message" // AppName / UserID / SessionID identify the single conversation session. AppName = "chat" @@ -53,10 +55,10 @@ type ChatInput struct { MaxTurns int } -// ChatWorkflow serves a long-lived conversation. It imports any prior session, -// then loops receiving user-message signals, running the agent per message on the -// shared session, and recording each answer (readable via a query). When Temporal -// suggests continue-as-new (or MaxTurns is reached) it exports the session and +// ChatWorkflow serves a long-lived conversation. It imports any prior session, then +// registers a SendMessage Update handler that runs one agent turn per message on the +// shared session and returns the answer. When Temporal suggests continue-as-new (or +// MaxTurns is reached) it drains any in-flight turn, exports the session, and // continues-as-new carrying the snapshot forward. // @@@SNIPSTART googleadk-chat-workflow func ChatWorkflow(ctx workflow.Context, in ChatInput) error { @@ -83,10 +85,8 @@ func ChatWorkflow(ctx workflow.Context, in ChatInput) error { } r, err := runner.New(runner.Config{ - AppName: AppName, - Agent: root, - // AutoCreateSession creates the session on first use when we did not import - // one; when we imported, the session already exists. + AppName: AppName, + Agent: root, SessionService: svc, AutoCreateSession: true, }) @@ -94,76 +94,76 @@ func ChatWorkflow(ctx workflow.Context, in ChatInput) error { return err } - // latestAnswer is served by the query handler. - var latestAnswer string - if err := workflow.SetQueryHandler(ctx, LatestAnswerQueryType, func() (string, error) { - return latestAnswer, nil - }); err != nil { - return err - } - - msgCh := workflow.GetSignalChannel(ctx, UserMessageSignalName) - turns := 0 - for { - // Wait for the next user message. - var text string - msgCh.Receive(ctx, &text) - turns++ - - msg := genai.NewContentFromText(text, genai.RoleUser) - for ev, err := range r.Run(adkCtx, UserID, SessionID, msg, agent.RunConfig{}) { - if err != nil { - return err - } - if ev == nil || ev.Content == nil { - continue + // One agent turn runs at a time: serialize concurrent Updates so they can't + // interleave on the shared ADK session. + busy := false + + err = workflow.SetUpdateHandlerWithOptions( + ctx, + SendMessageUpdateName, + func(ctx workflow.Context, text string) (string, error) { + if err := workflow.Await(ctx, func() bool { return !busy }); err != nil { + return "", err } - for _, p := range ev.Content.Parts { - if p != nil && p.Text != "" { - latestAnswer = p.Text + busy = true + defer func() { busy = false }() + + // Build the ADK context from this Update handler's own workflow.Context so + // the model Activity is scheduled on the handler's coroutine. + turnCtx := googleadk.NewContext(ctx) + var answer string + msg := genai.NewContentFromText(text, genai.RoleUser) + for ev, err := range r.Run(turnCtx, UserID, SessionID, msg, agent.RunConfig{}) { + if err != nil { + return "", err } - } - } - - // Continue-as-new to keep history bounded: either when Temporal suggests it - // (history/event count getting large) or after the demo turn cap. - suggested := workflow.GetInfo(ctx).GetContinueAsNewSuggested() - capReached := in.MaxTurns > 0 && turns >= in.MaxTurns - if suggested || capReached { - // Drain any messages that arrived while we were serving this turn, so - // they aren't lost across the continue-as-new boundary. - for { - var pending string - if !msgCh.ReceiveAsync(&pending) { - break + if ev == nil || ev.Content == nil { + continue } - pmsg := genai.NewContentFromText(pending, genai.RoleUser) - for ev, err := range r.Run(adkCtx, UserID, SessionID, pmsg, agent.RunConfig{}) { - if err != nil { - return err - } - if ev == nil || ev.Content == nil { - continue - } - for _, p := range ev.Content.Parts { - if p != nil && p.Text != "" { - latestAnswer = p.Text - } + for _, p := range ev.Content.Parts { + if p != nil && p.Text != "" { + answer = p.Text } } } + turns++ + return answer, nil + }, + workflow.UpdateHandlerOptions{ + Validator: func(ctx workflow.Context, text string) error { + if text == "" { + return fmt.Errorf("message must not be empty") + } + return nil + }, + }, + ) + if err != nil { + return err + } - snap, err := googleadk.ExportSession(adkCtx, svc, AppName, UserID, SessionID) - if err != nil { - return err - } - return workflow.NewContinueAsNewError(ctx, ChatWorkflow, ChatInput{ - Snapshot: snap, - MaxTurns: in.MaxTurns, - }) - } + // Serve messages until Temporal suggests continue-as-new (history getting large) + // or the demo turn cap is reached. + if err := workflow.Await(ctx, func() bool { + return workflow.GetInfo(ctx).GetContinueAsNewSuggested() || (in.MaxTurns > 0 && turns >= in.MaxTurns) + }); err != nil { + return err + } + + // Let any in-flight Update finish so its turn is captured in the snapshot. + if err := workflow.Await(ctx, func() bool { return workflow.AllHandlersFinished(ctx) }); err != nil { + return err } + + snap, err := googleadk.ExportSession(adkCtx, svc, AppName, UserID, SessionID) + if err != nil { + return err + } + return workflow.NewContinueAsNewError(ctx, ChatWorkflow, ChatInput{ + Snapshot: snap, + MaxTurns: in.MaxTurns, + }) } // @@@SNIPEND diff --git a/googleadk/chat/workflow_test.go b/googleadk/chat/workflow_test.go index f6473012..4aa4c5ae 100644 --- a/googleadk/chat/workflow_test.go +++ b/googleadk/chat/workflow_test.go @@ -23,7 +23,7 @@ import ( // recordingModel wraps a FakeModel and records the number of Contents in each // request it serves, so a test can prove that a later turn's request carried the -// prior conversation history (proving the session persisted across signals). +// prior conversation history (proving the session persisted across turns). type recordingModel struct { inner *googleadk.FakeModel @@ -51,8 +51,18 @@ func (m *recordingModel) contentsAt(turn int) int { return m.requestContents[turn] } +// failOnReject returns update callbacks that fail the test if the update is +// rejected or completes with an error. +func failOnReject(t *testing.T) *testsuite.TestUpdateCallback { + return &testsuite.TestUpdateCallback{ + OnAccept: func() {}, + OnReject: func(err error) { t.Errorf("update rejected: %v", err) }, + OnComplete: func(_ interface{}, err error) {}, + } +} + // TestChatCarriesHistory drives two user messages through the chat workflow via -// signals and asserts the second turn's model request carried more Contents than +// Updates and asserts the second turn's model request carried more Contents than // the first — proving the two messages ran on the SAME ADK session, so history // accumulated. MaxTurns is high enough that no continue-as-new fires here. func TestChatCarriesHistory(t *testing.T) { @@ -75,12 +85,12 @@ func TestChatCarriesHistory(t *testing.T) { // Send two messages, spaced so each is served before the next arrives. Use a // high MaxTurns so this test does not hit the continue-as-new path. env.RegisterDelayedCallback(func() { - env.SignalWorkflow(chat.UserMessageSignalName, "Hi! My name is David.") + env.UpdateWorkflow(chat.SendMessageUpdateName, "msg-1", failOnReject(t), "Hi! My name is David.") }, time.Second) env.RegisterDelayedCallback(func() { - env.SignalWorkflow(chat.UserMessageSignalName, "What is durable execution?") + env.UpdateWorkflow(chat.SendMessageUpdateName, "msg-2", failOnReject(t), "What is durable execution?") }, 5*time.Second) - // End the (otherwise infinite) workflow by cancelling after both are served. + // End the (otherwise long-lived) workflow by cancelling after both are served. env.RegisterDelayedCallback(func() { env.CancelWorkflow() }, 10*time.Second) @@ -117,7 +127,7 @@ func TestChatContinueAsNew(t *testing.T) { env.RegisterActivityWithOptions(acts.InvokeModel, activity.RegisterOptions{Name: googleadk.InvokeModelActivityName}) env.RegisterDelayedCallback(func() { - env.SignalWorkflow(chat.UserMessageSignalName, "Hi there!") + env.UpdateWorkflow(chat.SendMessageUpdateName, "msg-1", failOnReject(t), "Hi there!") }, time.Second) env.ExecuteWorkflow(chat.ChatWorkflow, chat.ChatInput{MaxTurns: 1}) From d2a0682013de348ef85777b7738d00a8e58d6308 Mon Sep 17 00:00:00 2001 From: David Hyde Date: Wed, 15 Jul 2026 19:23:03 -0500 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- googleadk/chat/workflow_test.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/googleadk/chat/workflow_test.go b/googleadk/chat/workflow_test.go index 4aa4c5ae..593354f3 100644 --- a/googleadk/chat/workflow_test.go +++ b/googleadk/chat/workflow_test.go @@ -55,9 +55,13 @@ func (m *recordingModel) contentsAt(turn int) int { // rejected or completes with an error. func failOnReject(t *testing.T) *testsuite.TestUpdateCallback { return &testsuite.TestUpdateCallback{ - OnAccept: func() {}, - OnReject: func(err error) { t.Errorf("update rejected: %v", err) }, - OnComplete: func(_ interface{}, err error) {}, + OnAccept: func() {}, + OnReject: func(err error) { t.Errorf("update rejected: %v", err) }, + OnComplete: func(_ interface{}, err error) { + if err != nil { + t.Errorf("update completed with error: %v", err) + } + }, } }