From 68644b857ed9cf7bd342ecec923b210a738e5776 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 12 Apr 2026 18:54:18 -0600 Subject: [PATCH 1/5] Add toolregistry contrib package with AgenticSession support Co-Authored-By: Claude Sonnet 4.6 --- contrib/toolregistry/README.md | 216 +++++++++++++++++ contrib/toolregistry/go.mod | 41 ++++ contrib/toolregistry/go.sum | 135 +++++++++++ contrib/toolregistry/integration_test.go | 64 +++++ contrib/toolregistry/providers.go | 297 +++++++++++++++++++++++ contrib/toolregistry/registry.go | 164 +++++++++++++ contrib/toolregistry/registry_test.go | 135 +++++++++++ contrib/toolregistry/session.go | 166 +++++++++++++ contrib/toolregistry/session_test.go | 225 +++++++++++++++++ contrib/toolregistry/testing.go | 212 ++++++++++++++++ contrib/toolregistry/testing_test.go | 200 +++++++++++++++ 11 files changed, 1855 insertions(+) create mode 100644 contrib/toolregistry/README.md create mode 100644 contrib/toolregistry/go.mod create mode 100644 contrib/toolregistry/go.sum create mode 100644 contrib/toolregistry/integration_test.go create mode 100644 contrib/toolregistry/providers.go create mode 100644 contrib/toolregistry/registry.go create mode 100644 contrib/toolregistry/registry_test.go create mode 100644 contrib/toolregistry/session.go create mode 100644 contrib/toolregistry/session_test.go create mode 100644 contrib/toolregistry/testing.go create mode 100644 contrib/toolregistry/testing_test.go diff --git a/contrib/toolregistry/README.md b/contrib/toolregistry/README.md new file mode 100644 index 000000000..92577f1f5 --- /dev/null +++ b/contrib/toolregistry/README.md @@ -0,0 +1,216 @@ +# toolregistry + +LLM tool-calling primitives for Temporal activities — define tools once, use with +Anthropic or OpenAI. + +## Before you start + +A Temporal Activity is a function that Temporal monitors and retries automatically on failure. Temporal streams progress between retries via heartbeats — that's the mechanism `RunWithSession` uses to resume a crashed LLM conversation mid-turn. + +`RunToolLoop` works standalone in any async function — no Temporal server needed. Add `RunWithSession` only when you need crash-safe resume inside a Temporal activity. + +`RunWithSession` requires a running Temporal worker — it reads and writes heartbeat state from the active activity context. Use `RunToolLoop` standalone for scripts, one-off jobs, or any code that runs outside a Temporal worker. + +New to Temporal? → https://docs.temporal.io/develop + +## Install + +```bash +go get go.temporal.io/sdk/contrib/toolregistry +``` + +Install the LLM client SDK separately: + +```bash +go get github.com/anthropics/anthropic-sdk-go # Anthropic +go get github.com/openai/openai-go # OpenAI +``` + +## Quickstart + +Tool definitions use [JSON Schema](https://json-schema.org/understanding-json-schema/) for `InputSchema`. The quickstart uses a single string field; for richer schemas refer to the JSON Schema docs. + +```go +import "go.temporal.io/sdk/contrib/toolregistry" + +func AnalyzeActivity(ctx context.Context, prompt string) ([]string, error) { + var issues []string + reg := toolregistry.NewToolRegistry() + reg.Register(toolregistry.ToolDef{ + Name: "flag_issue", + Description: "Flag a problem found in the analysis", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"description": map[string]any{"type": "string"}}, + "required": []string{"description"}, + }, + }, func(inp map[string]any) (string, error) { + issues = append(issues, inp["description"].(string)) + return "recorded", nil // this string is sent back to the LLM as the tool result + }) + + cfg := toolregistry.AnthropicConfig{APIKey: os.Getenv("ANTHROPIC_API_KEY")} + provider := toolregistry.NewAnthropicProvider(cfg, reg, + "You are a code reviewer. Call flag_issue for each problem you find.") + + // RunToolLoop returns the full conversation history; capture or discard as needed. + if _, err := toolregistry.RunToolLoop(ctx, provider, reg, "" /* system prompt: "" defers to provider default */, prompt); err != nil { + return nil, err + } + return issues, nil +} +``` + +### Selecting a model + +The default model is `"claude-sonnet-4-6"` (Anthropic) or `"gpt-4o"` (OpenAI). Override with the `Model` field: + +```go +cfg := toolregistry.AnthropicConfig{ + APIKey: os.Getenv("ANTHROPIC_API_KEY"), + Model: "claude-3-5-sonnet-20241022", +} +provider := toolregistry.NewAnthropicProvider(cfg, reg, "system prompt") +``` + +Model IDs are defined by the provider — see Anthropic or OpenAI docs for current names. + +### OpenAI + +```go +cfg := toolregistry.OpenAIConfig{APIKey: os.Getenv("OPENAI_API_KEY")} +provider := toolregistry.NewOpenAIProvider(cfg, reg, "system prompt") +if _, err := toolregistry.RunToolLoop(ctx, provider, reg, "", prompt); err != nil { + return nil, err +} +``` + +## Crash-safe agentic sessions + +For multi-turn LLM conversations that must survive activity retries, use +`RunWithSession`. It saves conversation history via `activity.RecordHeartbeat` +on every turn and restores it automatically on retry. + +```go +import ( + "context" + "os" + "go.temporal.io/sdk/contrib/toolregistry" +) + +func LongAnalysisActivity(ctx context.Context, prompt string) ([]map[string]any, error) { + var issues []map[string]any + + err := toolregistry.RunWithSession(ctx, func(ctx context.Context, s *toolregistry.AgenticSession) error { + reg := toolregistry.NewToolRegistry() + reg.Register(toolregistry.ToolDef{ + Name: "flag", Description: "...", + InputSchema: map[string]any{"type": "object"}, + }, func(inp map[string]any) (string, error) { + s.Issues = append(s.Issues, inp) // s.Issues is []map[string]any + return "ok", nil + }) + + cfg := toolregistry.AnthropicConfig{APIKey: os.Getenv("ANTHROPIC_API_KEY")} + provider := toolregistry.NewAnthropicProvider(cfg, reg, "...") + if err := s.RunToolLoop(ctx, provider, reg, "...", prompt); err != nil { + return err + } + issues = s.Issues // capture after loop completes + return nil + }) + return issues, err +} +``` + +## Testing without an API key + +```go +import "go.temporal.io/sdk/contrib/toolregistry" + +func TestAnalyze(t *testing.T) { + reg := toolregistry.NewToolRegistry() + reg.Register(toolregistry.ToolDef{Name: "flag", Description: "d", + InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { return "ok", nil }) + + provider := toolregistry.NewMockProvider([]toolregistry.MockResponse{ + toolregistry.ToolCall("flag", map[string]any{"description": "stale API"}), + toolregistry.Done("analysis complete"), + }).WithRegistry(reg) + + msgs, err := toolregistry.RunToolLoop(context.Background(), provider, reg, "sys", "analyze") + require.NoError(t, err) + require.Greater(t, len(msgs), 2) +} +``` + +## Integration testing with real providers + +To run the integration tests against live Anthropic and OpenAI APIs: + +```bash +RUN_INTEGRATION_TESTS=1 \ + ANTHROPIC_API_KEY=sk-ant-... \ + OPENAI_API_KEY=sk-proj-... \ + go test ./contrib/toolregistry/ -run Integration -v +``` + +Tests skip automatically when `RUN_INTEGRATION_TESTS` is unset. Real API calls +incur billing — expect a few cents per full test run. + +## Storing application results + +`s.Issues` accumulates application-level results during the tool loop. +Elements are serialized to JSON inside each heartbeat checkpoint — they must be +plain maps/dicts with JSON-serializable values. A non-serializable value raises +a non-retryable `ApplicationError` at heartbeat time rather than silently losing +data on the next retry. + +### Storing typed results + +Convert your domain type to a plain dict at the tool-call site and back after +the session: + +```go +type Issue struct { + Type string `json:"type"` + File string `json:"file"` +} + +// Inside tool handler: +s.Issues = append(s.Issues, map[string]any{"type": "smell", "file": "foo.go"}) + +// After session: +var issues []Issue +for _, raw := range s.Issues { + data, _ := json.Marshal(raw) + var issue Issue + _ = json.Unmarshal(data, &issue) + issues = append(issues, issue) +} +``` + +## Per-turn LLM timeout + +Individual LLM calls inside the tool loop are unbounded by default. A hung HTTP +connection holds the activity open until Temporal's `ScheduleToCloseTimeout` +fires — potentially many minutes. Set a per-turn timeout on the provider client: + +```go +import "github.com/anthropics/anthropic-sdk-go/option" + +cfg := toolregistry.AnthropicConfig{ + APIKey: os.Getenv("ANTHROPIC_API_KEY"), + Options: []option.RequestOption{option.WithRequestTimeout(30 * time.Second)}, +} +provider := toolregistry.NewAnthropicProvider(cfg, reg, "system prompt") +// provider now enforces 30s per turn +``` + +Recommended timeouts: + +| Model type | Recommended | +|---|---| +| Standard (Claude 3.x, GPT-4o) | 30 s | +| Reasoning (o1, o3, extended thinking) | 300 s | diff --git a/contrib/toolregistry/go.mod b/contrib/toolregistry/go.mod new file mode 100644 index 000000000..78cfc6319 --- /dev/null +++ b/contrib/toolregistry/go.mod @@ -0,0 +1,41 @@ +module go.temporal.io/sdk/contrib/toolregistry + +go 1.24.0 + +require ( + github.com/anthropics/anthropic-sdk-go v1.35.0 + github.com/sashabaranov/go-openai v1.41.2 + github.com/stretchr/testify v1.10.0 + go.temporal.io/sdk v1.42.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/mock v1.6.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect + github.com/nexus-rpc/sdk-go v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron v1.2.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + go.temporal.io/api v1.62.7 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + google.golang.org/grpc v1.79.3 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace go.temporal.io/sdk => ../../ diff --git a/contrib/toolregistry/go.sum b/contrib/toolregistry/go.sum new file mode 100644 index 000000000..1e30424bc --- /dev/null +++ b/contrib/toolregistry/go.sum @@ -0,0 +1,135 @@ +github.com/anthropics/anthropic-sdk-go v1.35.0 h1:W6K8mIkD1zIU0VUPMuokWONUvdlt2C//b11Zr6v5Oz4= +github.com/anthropics/anthropic-sdk-go v1.35.0/go.mod h1:dSIO7kSrOI7MA4fE6RRVaw8tyWP7HNQU5/H/KS4cax8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= +github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +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/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/nexus-rpc/sdk-go v0.6.0 h1:QRgnP2zTbxEbiyWG/aXH8uSC5LV/Mg1fqb19jb4DBlo= +github.com/nexus-rpc/sdk-go v0.6.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= +github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= +github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= +github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= +github.com/sashabaranov/go-openai v1.41.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.temporal.io/api v1.62.7 h1:joCtF30Dr+ynzrFJySewZsWbyf4AETZpuizHhFIyj/o= +go.temporal.io/api v1.62.7/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/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/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516 h1:vmC/ws+pLzWjj/gzApyoZuSVrDtF1aod4u/+bbj8hgM= +google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +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/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/contrib/toolregistry/integration_test.go b/contrib/toolregistry/integration_test.go new file mode 100644 index 000000000..bfed630e8 --- /dev/null +++ b/contrib/toolregistry/integration_test.go @@ -0,0 +1,64 @@ +package toolregistry + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func makeRecordRegistry(t *testing.T) (*ToolRegistry, *[]string) { + t.Helper() + collected := &[]string{} + reg := NewToolRegistry() + reg.Register(ToolDef{ + Name: "record", + Description: "Record a value", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"value": map[string]any{"type": "string"}}, + "required": []string{"value"}, + }, + }, func(inp map[string]any) (string, error) { + *collected = append(*collected, inp["value"].(string)) + return "recorded", nil + }) + return reg, collected +} + +func TestIntegration_Anthropic(t *testing.T) { + if os.Getenv("RUN_INTEGRATION_TESTS") == "" { + t.Skip("RUN_INTEGRATION_TESTS not set") + } + apiKey := os.Getenv("ANTHROPIC_API_KEY") + require.NotEmpty(t, apiKey, "ANTHROPIC_API_KEY required") + + reg, collected := makeRecordRegistry(t) + cfg := AnthropicConfig{APIKey: apiKey} + provider := NewAnthropicProvider(cfg, reg, + "You must call record() exactly once with value='hello'.") + + _, err := RunToolLoop(context.Background(), provider, reg, "", + "Please call the record tool with value='hello'.") + require.NoError(t, err) + require.Contains(t, *collected, "hello") +} + +func TestIntegration_OpenAI(t *testing.T) { + if os.Getenv("RUN_INTEGRATION_TESTS") == "" { + t.Skip("RUN_INTEGRATION_TESTS not set") + } + apiKey := os.Getenv("OPENAI_API_KEY") + require.NotEmpty(t, apiKey, "OPENAI_API_KEY required") + + reg, collected := makeRecordRegistry(t) + cfg := OpenAIConfig{APIKey: apiKey} + provider := NewOpenAIProvider(cfg, reg, + "You must call record() exactly once with value='hello'.") + + _, err := RunToolLoop(context.Background(), provider, reg, "", + "Please call the record tool with value='hello'.") + require.NoError(t, err) + require.Contains(t, *collected, "hello") +} diff --git a/contrib/toolregistry/providers.go b/contrib/toolregistry/providers.go new file mode 100644 index 000000000..f2d21e981 --- /dev/null +++ b/contrib/toolregistry/providers.go @@ -0,0 +1,297 @@ +package toolregistry + +import ( + "context" + "encoding/json" + "fmt" + + anthropic "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + openai "github.com/sashabaranov/go-openai" +) + +// AnthropicConfig configures an [AnthropicProvider]. +type AnthropicConfig struct { + // APIKey is the Anthropic API key. Required unless Client is set. + APIKey string + // Model is the model name. Defaults to "claude-sonnet-4-6". + Model string + // BaseURL overrides the Anthropic API base URL (e.g. for proxies). + BaseURL string + // Client is a pre-constructed anthropic.Client. When set, APIKey and + // BaseURL are ignored. Useful for testing without API calls. + Client *anthropic.Client +} + +// AnthropicProvider implements [Provider] for the Anthropic Messages API. +type AnthropicProvider struct { + client anthropic.Client + model string + system string + registry *ToolRegistry +} + +// NewAnthropicProvider creates an AnthropicProvider from the given config. +func NewAnthropicProvider(cfg AnthropicConfig, registry *ToolRegistry, system string) *AnthropicProvider { + model := cfg.Model + if model == "" { + model = "claude-sonnet-4-6" + } + var client anthropic.Client + if cfg.Client != nil { + client = *cfg.Client + } else { + opts := []option.RequestOption{option.WithAPIKey(cfg.APIKey)} + if cfg.BaseURL != "" { + opts = append(opts, option.WithBaseURL(cfg.BaseURL)) + } + client = anthropic.NewClient(opts...) + } + return &AnthropicProvider{client: client, model: model, system: system, registry: registry} +} + +// RunTurn implements [Provider]. It sends one turn to Anthropic, dispatches +// any tool calls, and returns the new messages to append. +func (p *AnthropicProvider) RunTurn(ctx context.Context, messages []map[string]any, tools []ToolDef) ([]map[string]any, bool, error) { + // Convert map messages to Anthropic MessageParam slice via JSON round-trip. + msgParams, err := mapsToAnthropicMessages(messages) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: marshal messages: %w", err) + } + + // Convert ToolDef slice to Anthropic ToolUnionParam slice. + toolParams, err := toolDefsToAnthropicTools(tools) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: marshal tools: %w", err) + } + + resp, err := p.client.Messages.New(ctx, anthropic.MessageNewParams{ + Model: anthropic.Model(p.model), + MaxTokens: 4096, + System: []anthropic.TextBlockParam{{Text: p.system}}, + Tools: toolParams, + Messages: msgParams, + }) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: anthropic api: %w", err) + } + + // Convert response content blocks to plain maps for storage. + contentMaps, err := contentBlocksToMaps(resp.Content) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: marshal response: %w", err) + } + + var newMsgs []map[string]any + newMsgs = append(newMsgs, map[string]any{"role": "assistant", "content": contentMaps}) + + // Collect tool calls. + var toolCalls []map[string]any + for _, block := range contentMaps { + if block["type"] == "tool_use" { + toolCalls = append(toolCalls, block) + } + } + + if len(toolCalls) == 0 || resp.StopReason == anthropic.StopReasonEndTurn { + return newMsgs, true, nil + } + + // Dispatch each tool call and collect results. + toolResults := make([]map[string]any, 0, len(toolCalls)) + for _, call := range toolCalls { + name, _ := call["name"].(string) + id, _ := call["id"].(string) + input, _ := call["input"].(map[string]any) + if input == nil { + // input may be stored as json.RawMessage; parse it. + if raw, ok := call["input"]; ok { + if data, err := json.Marshal(raw); err == nil { + json.Unmarshal(data, &input) //nolint:errcheck + } + } + } + result, err := p.registry.Dispatch(name, input) + if err != nil { + result = fmt.Sprintf("error: %s", err) + } + toolResults = append(toolResults, map[string]any{ + "type": "tool_result", + "tool_use_id": id, + "content": result, + }) + } + newMsgs = append(newMsgs, map[string]any{"role": "user", "content": toolResults}) + return newMsgs, false, nil +} + +// OpenAIConfig configures an [OpenAIProvider]. +type OpenAIConfig struct { + // APIKey is the OpenAI API key. Required unless Client is set. + APIKey string + // Model is the model name. Defaults to "gpt-4o". + Model string + // BaseURL overrides the OpenAI API base URL. + BaseURL string + // Client is a pre-constructed *openai.Client. When set, APIKey and BaseURL + // are ignored. Useful for testing. + Client *openai.Client +} + +// OpenAIProvider implements [Provider] for the OpenAI Chat Completions API. +type OpenAIProvider struct { + client *openai.Client + model string + system string + registry *ToolRegistry +} + +// NewOpenAIProvider creates an OpenAIProvider from the given config. +func NewOpenAIProvider(cfg OpenAIConfig, registry *ToolRegistry, system string) *OpenAIProvider { + model := cfg.Model + if model == "" { + model = "gpt-4o" + } + client := cfg.Client + if client == nil { + oacfg := openai.DefaultConfig(cfg.APIKey) + if cfg.BaseURL != "" { + oacfg.BaseURL = cfg.BaseURL + } + client = openai.NewClientWithConfig(oacfg) + } + return &OpenAIProvider{client: client, model: model, system: system, registry: registry} +} + +// RunTurn implements [Provider] for OpenAI. +func (p *OpenAIProvider) RunTurn(ctx context.Context, messages []map[string]any, _ []ToolDef) ([]map[string]any, bool, error) { + // Build full message list with system prefix. + full := make([]map[string]any, 0, len(messages)+1) + full = append(full, map[string]any{"role": "system", "content": p.system}) + full = append(full, messages...) + + chatMsgs, err := mapsToOpenAIMessages(full) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: marshal messages: %w", err) + } + + oaiTools := make([]openai.Tool, 0, len(p.registry.defs)) + for _, d := range p.registry.defs { + params, _ := json.Marshal(d.InputSchema) + oaiTools = append(oaiTools, openai.Tool{ + Type: openai.ToolTypeFunction, + Function: &openai.FunctionDefinition{ + Name: d.Name, + Description: d.Description, + Parameters: json.RawMessage(params), + }, + }) + } + + resp, err := p.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ + Model: p.model, + Messages: chatMsgs, + Tools: oaiTools, + }) + if err != nil { + return nil, false, fmt.Errorf("toolregistry: openai api: %w", err) + } + + if len(resp.Choices) == 0 { + return nil, true, nil + } + choice := resp.Choices[0] + msg := choice.Message + + msgMap := map[string]any{"role": "assistant", "content": msg.Content} + if len(msg.ToolCalls) > 0 { + calls := make([]map[string]any, len(msg.ToolCalls)) + for i, tc := range msg.ToolCalls { + calls[i] = map[string]any{ + "id": tc.ID, + "type": "function", + "function": map[string]any{ + "name": tc.Function.Name, + "arguments": tc.Function.Arguments, + }, + } + } + msgMap["tool_calls"] = calls + } + + var newMsgs []map[string]any + newMsgs = append(newMsgs, msgMap) + + if len(msg.ToolCalls) == 0 || choice.FinishReason == openai.FinishReasonStop || choice.FinishReason == openai.FinishReasonLength { + return newMsgs, true, nil + } + + for _, tc := range msg.ToolCalls { + var input map[string]any + json.Unmarshal([]byte(tc.Function.Arguments), &input) //nolint:errcheck + result, err := p.registry.Dispatch(tc.Function.Name, input) + if err != nil { + result = fmt.Sprintf("error: %s", err) + } + newMsgs = append(newMsgs, map[string]any{ + "role": "tool", + "tool_call_id": tc.ID, + "content": result, + }) + } + return newMsgs, false, nil +} + +// ── JSON conversion helpers ─────────────────────────────────────────────────── + +// mapsToAnthropicMessages converts a []map[string]any message history to +// []anthropic.MessageParam via JSON round-trip. +func mapsToAnthropicMessages(msgs []map[string]any) ([]anthropic.MessageParam, error) { + data, err := json.Marshal(msgs) + if err != nil { + return nil, err + } + var params []anthropic.MessageParam + return params, json.Unmarshal(data, ¶ms) +} + +// mapsToOpenAIMessages converts a []map[string]any message history to +// []openai.ChatCompletionMessage via JSON round-trip. +func mapsToOpenAIMessages(msgs []map[string]any) ([]openai.ChatCompletionMessage, error) { + data, err := json.Marshal(msgs) + if err != nil { + return nil, err + } + var chatMsgs []openai.ChatCompletionMessage + return chatMsgs, json.Unmarshal(data, &chatMsgs) +} + +// contentBlocksToMaps converts Anthropic response content blocks to +// []map[string]any via JSON round-trip for heartbeat-safe storage. +func contentBlocksToMaps(blocks []anthropic.ContentBlockUnion) ([]map[string]any, error) { + data, err := json.Marshal(blocks) + if err != nil { + return nil, err + } + var maps []map[string]any + return maps, json.Unmarshal(data, &maps) +} + +// toolDefsToAnthropicTools converts []ToolDef to []anthropic.ToolUnionParam. +// Each ToolDef is marshalled to a ToolParam via JSON round-trip and then +// wrapped in ToolUnionParam.OfTool. +func toolDefsToAnthropicTools(defs []ToolDef) ([]anthropic.ToolUnionParam, error) { + result := make([]anthropic.ToolUnionParam, len(defs)) + for i, def := range defs { + data, err := json.Marshal(def) + if err != nil { + return nil, err + } + var tp anthropic.ToolParam + if err := json.Unmarshal(data, &tp); err != nil { + return nil, err + } + result[i] = anthropic.ToolUnionParam{OfTool: &tp} + } + return result, nil +} diff --git a/contrib/toolregistry/registry.go b/contrib/toolregistry/registry.go new file mode 100644 index 000000000..776376f60 --- /dev/null +++ b/contrib/toolregistry/registry.go @@ -0,0 +1,164 @@ +// Package toolregistry provides LLM tool-calling primitives for Temporal activities. +// +// Define tools once with [ToolRegistry], use them with Anthropic or OpenAI, and +// run complete multi-turn tool-calling conversations with [RunToolLoop]. +// +// For crash-safe sessions that survive activity retries, use [RunWithSession]. +// +// Example: +// +// reg := toolregistry.NewToolRegistry() +// reg.Register(toolregistry.ToolDef{ +// Name: "flag_issue", +// Description: "Flag a problem found in analysis", +// InputSchema: map[string]any{ +// "type": "object", +// "properties": map[string]any{"description": map[string]any{"type": "string"}}, +// "required": []string{"description"}, +// }, +// }, func(inp map[string]any) (string, error) { +// issues = append(issues, inp["description"].(string)) +// return "recorded", nil +// }) +// +// cfg := toolregistry.AnthropicConfig{APIKey: os.Getenv("ANTHROPIC_API_KEY")} +// provider := toolregistry.NewAnthropicProvider(cfg, reg) +// _, err := toolregistry.RunToolLoop(ctx, provider, reg, system, prompt) +package toolregistry + +import ( + "context" + "fmt" +) + +// ToolDef defines an LLM tool in Anthropic's tool_use JSON format. +// The same definition is used for both Anthropic and OpenAI; [ToolRegistry] +// converts the schema for each provider. +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"input_schema"` +} + +// Handler is called when the model invokes a tool. It receives the parsed +// tool input and returns a string result or an error. +type Handler func(input map[string]any) (string, error) + +// Provider is the interface implemented by LLM provider adapters. Each +// adapter handles one LLM API's wire format for tool calling. +// +// Implementations must be safe to call from a single goroutine (one activity +// goroutine owns each session — no concurrent RunTurn calls on one Provider). +type Provider interface { + // RunTurn executes one turn of the conversation. + // + // It sends the full message history plus the registered tools to the LLM, + // and returns the new messages to append (assistant response and any tool + // results), whether the loop is done, and any error. + // + // Callers append the returned messages to their history before the next call. + RunTurn(ctx context.Context, messages []map[string]any, tools []ToolDef) (newMessages []map[string]any, done bool, err error) +} + +// ToolRegistry maps tool names to definitions and handlers. +// +// Tools are registered in Anthropic's tool_use format. The registry exports +// them for Anthropic or OpenAI and dispatches incoming tool calls to the +// appropriate handler. +// +// A ToolRegistry is not safe for concurrent modification; build it before +// passing it to concurrent activities. +type ToolRegistry struct { + defs []ToolDef + handlers map[string]Handler +} + +// NewToolRegistry creates an empty ToolRegistry. +func NewToolRegistry() *ToolRegistry { + return &ToolRegistry{ + handlers: make(map[string]Handler), + } +} + +// Register adds a tool definition and its handler to the registry. +func (r *ToolRegistry) Register(def ToolDef, handler Handler) { + r.defs = append(r.defs, def) + r.handlers[def.Name] = handler +} + +// Dispatch calls the handler registered for name with the given input. +// +// Returns an error if no handler is registered for name. +func (r *ToolRegistry) Dispatch(name string, input map[string]any) (string, error) { + h, ok := r.handlers[name] + if !ok { + return "", fmt.Errorf("toolregistry: unknown tool %q", name) + } + return h(input) +} + +// Defs returns a copy of the registered tool definitions. +func (r *ToolRegistry) Defs() []ToolDef { + defs := make([]ToolDef, len(r.defs)) + copy(defs, r.defs) + return defs +} + +// ToAnthropic returns tool definitions in Anthropic tool_use format. +// Each map has "name", "description", and "input_schema" keys, matching +// the format expected by the Anthropic Messages API. +func (r *ToolRegistry) ToAnthropic() []map[string]any { + result := make([]map[string]any, len(r.defs)) + for i, d := range r.defs { + result[i] = map[string]any{ + "name": d.Name, + "description": d.Description, + "input_schema": d.InputSchema, + } + } + return result +} + +// ToOpenAI returns tool definitions in OpenAI function-calling format. +func (r *ToolRegistry) ToOpenAI() []map[string]any { + result := make([]map[string]any, len(r.defs)) + for i, d := range r.defs { + result[i] = map[string]any{ + "type": "function", + "function": map[string]any{ + "name": d.Name, + "description": d.Description, + "parameters": d.InputSchema, + }, + } + } + return result +} + +// RunToolLoop runs a complete multi-turn LLM tool-calling loop. +// +// This is the primary entry point for simple, non-resumable loops. For +// crash-safe sessions with heartbeat checkpointing, use [RunWithSession]. +// +// Returns the full message history on completion, or the history up to the +// point of failure along with the error. +func RunToolLoop( + ctx context.Context, + provider Provider, + registry *ToolRegistry, + system, prompt string, +) ([]map[string]any, error) { + messages := []map[string]any{ + {"role": "user", "content": prompt}, + } + for { + newMsgs, done, err := provider.RunTurn(ctx, messages, registry.defs) + if err != nil { + return messages, err + } + messages = append(messages, newMsgs...) + if done { + return messages, nil + } + } +} diff --git a/contrib/toolregistry/registry_test.go b/contrib/toolregistry/registry_test.go new file mode 100644 index 000000000..b34100431 --- /dev/null +++ b/contrib/toolregistry/registry_test.go @@ -0,0 +1,135 @@ +package toolregistry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestToolRegistry_Dispatch(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "greet", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { + return "hello " + inp["name"].(string), nil + }) + + result, err := reg.Dispatch("greet", map[string]any{"name": "world"}) + require.NoError(t, err) + require.Equal(t, "hello world", result) +} + +func TestToolRegistry_UnknownTool(t *testing.T) { + reg := NewToolRegistry() + _, err := reg.Dispatch("missing", map[string]any{}) + require.Error(t, err) + require.Contains(t, err.Error(), "missing") +} + +func TestToolRegistry_ToAnthropic(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{ + Name: "my_tool", + Description: "does something", + InputSchema: map[string]any{"type": "object"}, + }, func(map[string]any) (string, error) { return "ok", nil }) + + result := reg.ToAnthropic() + require.Len(t, result, 1) + require.Equal(t, "my_tool", result[0]["name"]) + require.Equal(t, "does something", result[0]["description"]) + require.Equal(t, map[string]any{"type": "object"}, result[0]["input_schema"]) +} + +func TestToolRegistry_ToOpenAI(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{ + Name: "my_tool", + Description: "does something", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"x": map[string]any{"type": "string"}}, + }, + }, func(map[string]any) (string, error) { return "ok", nil }) + + result := reg.ToOpenAI() + require.Len(t, result, 1) + require.Equal(t, "function", result[0]["type"]) + fn := result[0]["function"].(map[string]any) + require.Equal(t, "my_tool", fn["name"]) + require.Equal(t, "does something", fn["description"]) +} + +func TestToolRegistry_Defs_ReturnsCopy(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "a", Description: "d", InputSchema: map[string]any{}}, + func(map[string]any) (string, error) { return "", nil }) + + defs := reg.Defs() + require.Len(t, defs, 1) + + // Mutating the returned slice must not affect the registry. + defs[0].Name = "modified" + require.Equal(t, "a", reg.Defs()[0].Name) +} + +func TestToolRegistry_MultipleTools(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "alpha", Description: "a", InputSchema: map[string]any{}}, + func(map[string]any) (string, error) { return "a", nil }) + reg.Register(ToolDef{Name: "beta", Description: "b", InputSchema: map[string]any{}}, + func(map[string]any) (string, error) { return "b", nil }) + + defs := reg.Defs() + require.Len(t, defs, 2) + require.Equal(t, "alpha", defs[0].Name) + require.Equal(t, "beta", defs[1].Name) +} + +// ── RunToolLoop ─────────────────────────────────────────────────────────────── + +func TestRunToolLoop_SingleDone(t *testing.T) { + reg := NewToolRegistry() + provider := NewMockProvider([]MockResponse{Done("finished")}) + + msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "hello") + require.NoError(t, err) + // user + assistant + require.Len(t, msgs, 2) + require.Equal(t, "user", msgs[0]["role"]) + require.Equal(t, "hello", msgs[0]["content"]) + require.Equal(t, "assistant", msgs[1]["role"]) +} + +func TestRunToolLoop_WithToolCall(t *testing.T) { + collected := []string{} + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "collect", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { + collected = append(collected, inp["v"].(string)) + return "ok", nil + }) + + provider := NewMockProvider([]MockResponse{ + ToolCall("collect", map[string]any{"v": "first"}), + ToolCall("collect", map[string]any{"v": "second"}), + Done("all done"), + }).WithRegistry(reg) + + msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "go") + require.NoError(t, err) + require.Equal(t, []string{"first", "second"}, collected) + + // user, assistant+tool_result (x2), final assistant + require.Greater(t, len(msgs), 4) +} + +func TestRunToolLoop_EmptyResponses(t *testing.T) { + reg := NewToolRegistry() + provider := NewMockProvider([]MockResponse{}) + + msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "prompt") + require.NoError(t, err) + // provider returns done immediately, so only the user message is present + require.Len(t, msgs, 1) +} diff --git a/contrib/toolregistry/session.go b/contrib/toolregistry/session.go new file mode 100644 index 000000000..a11cc8bf8 --- /dev/null +++ b/contrib/toolregistry/session.go @@ -0,0 +1,166 @@ +package toolregistry + +import ( + "context" + "encoding/json" + "fmt" + + "go.temporal.io/sdk/activity" + "go.temporal.io/sdk/temporal" +) + +// sessionCheckpoint is the data serialized to the heartbeat on each turn. +// It is unexported — callers interact only with [AgenticSession]. +type sessionCheckpoint struct { + Version int `json:"version"` + Messages []map[string]any `json:"messages"` + Issues []map[string]any `json:"issues"` +} + +// AgenticSession holds conversation state across a multi-turn LLM tool-use +// loop. On activity retry, [RunWithSession] restores the session from the last +// heartbeat checkpoint so the conversation resumes mid-turn rather than +// restarting from the beginning. +type AgenticSession struct { + // Messages is the full conversation history. Append-only during a session. + Messages []map[string]any + // Issues accumulates application-level results from tool calls. + // Elements must be JSON-serializable for checkpoint storage. + Issues []map[string]any +} + +// RunToolLoop runs the agentic tool-use loop to completion. +// +// If Messages is empty (fresh start), prompt is added as the first user +// message. Otherwise the existing conversation state is resumed (retry case). +// +// On every turn it checkpoints via [activity.RecordHeartbeat] before calling +// the LLM, then checks ctx.Err() — if the heartbeat timeout fires and the +// activity is cancelled, the context is cancelled and RunToolLoop returns +// immediately. The next attempt will restore from the last checkpoint. +func (s *AgenticSession) RunToolLoop( + ctx context.Context, + provider Provider, + registry *ToolRegistry, + system, prompt string, +) error { + if len(s.Messages) == 0 { + s.Messages = []map[string]any{{"role": "user", "content": prompt}} + } + + for { + if err := s.checkpoint(ctx); err != nil { + return err + } + + newMsgs, done, err := provider.RunTurn(ctx, s.Messages, registry.defs) + if err != nil { + return fmt.Errorf("toolregistry: session turn: %w", err) + } + s.Messages = append(s.Messages, newMsgs...) + + if done { + return nil + } + } +} + +// checkpoint heartbeats the current session state. It also returns ctx.Err() +// after recording, so callers detect heartbeat-timeout cancellation promptly. +// +// Returns a non-retryable [temporal.ApplicationError] if any issue cannot be +// JSON-marshaled — this surfaces the mistake at heartbeat time rather than +// silently losing data on the next retry. +func (s *AgenticSession) checkpoint(ctx context.Context) error { + for i, issue := range s.Issues { + if _, err := json.Marshal(issue); err != nil { + return temporal.NewNonRetryableApplicationError( + fmt.Sprintf( + "AgenticSession: issues[%d] is not JSON-serializable: %v. "+ + "Store only map[string]any with JSON-serializable values.", + i, err), + "InvalidArgument", + err, + ) + } + } + activity.RecordHeartbeat(ctx, sessionCheckpoint{ + Version: 1, + Messages: s.Messages, + Issues: s.Issues, + }) + return ctx.Err() +} + +// RunWithSession runs fn with a durable, checkpointed LLM session. +// +// On entry it checks for heartbeat details from a prior attempt via +// [activity.GetHeartbeatDetails]. If found, the session is restored +// (messages + issues) so the conversation resumes mid-turn rather than +// restarting from turn 0. +// +// Go concurrency note: activities are goroutines with a context. Unlike Python +// and TypeScript, which require explicit async machinery, Go's blocking HTTP +// calls inside fn are naturally fine — the goroutine simply blocks until each +// LLM response arrives. Context cancellation (from heartbeat timeout or +// workflow cancellation) propagates via ctx and is checked after each +// heartbeat inside [AgenticSession.RunToolLoop]. +// +// Example: +// +// err := toolregistry.RunWithSession(ctx, func(ctx context.Context, s *toolregistry.AgenticSession) error { +// return s.RunToolLoop(ctx, provider, registry, system, prompt) +// }) +func RunWithSession(ctx context.Context, fn func(ctx context.Context, s *AgenticSession) error) error { + session := &AgenticSession{ + Messages: make([]map[string]any, 0), + Issues: make([]map[string]any, 0), + } + + // Attempt to restore from a previous heartbeat checkpoint. + if activity.HasHeartbeatDetails(ctx) { + var cp sessionCheckpoint + if err := activity.GetHeartbeatDetails(ctx, &cp); err != nil { + activity.GetLogger(ctx).Warn( + "AgenticSession: failed to decode checkpoint, starting fresh", + "error", err, + ) + } else if cp.Version != 0 && cp.Version != 1 { + activity.GetLogger(ctx).Warn( + "AgenticSession: checkpoint version, expected 1 — starting fresh", + "version", cp.Version, + ) + } else { + if cp.Version == 0 { + activity.GetLogger(ctx).Warn( + "AgenticSession: checkpoint has no version field" + + " — may be from an older release", + ) + } + if cp.Messages != nil { + session.Messages = cp.Messages + } + if cp.Issues != nil { + session.Issues = cp.Issues + } + } + } + + return fn(ctx, session) +} + +// MarshalIssue is a convenience helper that JSON-encodes v and stores the +// result in session.Issues. Use it when your issue type is a struct rather +// than a plain map. +func MarshalIssue(s *AgenticSession, v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("toolregistry: marshal issue: %w", err) + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return fmt.Errorf("toolregistry: unmarshal issue: %w", err) + } + s.Issues = append(s.Issues, m) + return nil +} diff --git a/contrib/toolregistry/session_test.go b/contrib/toolregistry/session_test.go new file mode 100644 index 000000000..2bcd8d408 --- /dev/null +++ b/contrib/toolregistry/session_test.go @@ -0,0 +1,225 @@ +package toolregistry + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/testsuite" +) + +// runInActivity executes fn inside a test activity environment, providing a +// proper activity context (required for activity.RecordHeartbeat calls). +func runInActivity(t *testing.T, fn func(ctx context.Context) error) { + t.Helper() + ts := testsuite.WorkflowTestSuite{} + env := ts.NewTestActivityEnvironment() + activity := fn + env.RegisterActivity(activity) + _, err := env.ExecuteActivity(activity) + require.NoError(t, err) +} + +// ── Checkpoint round-trip (T6) ──────────────────────────────────────────────── + +// TestCheckpoint_RoundTrip verifies that a sessionCheckpoint with nested +// message structures survives a JSON marshal/unmarshal cycle with all fields +// intact. This guards against the class of bug where nested maps lose their +// type after deserialization (cf. .NET List bug). +func TestCheckpoint_RoundTrip(t *testing.T) { + toolCalls := []map[string]any{ + { + "id": "call_abc", + "type": "function", + "function": map[string]any{ + "name": "my_tool", + "arguments": `{"x":1}`, + }, + }, + } + cp := sessionCheckpoint{ + Messages: []map[string]any{ + { + "role": "assistant", + "tool_calls": toolCalls, + }, + }, + Issues: []map[string]any{ + {"type": "smell", "file": "foo.go"}, + }, + } + + data, err := json.Marshal(cp) + require.NoError(t, err) + + var restored sessionCheckpoint + require.NoError(t, json.Unmarshal(data, &restored)) + + require.Len(t, restored.Messages, 1) + require.Equal(t, "assistant", restored.Messages[0]["role"]) + + // tool_calls must survive the round-trip as a parseable structure. + toolCallsRestored, ok := restored.Messages[0]["tool_calls"].([]any) + require.True(t, ok, "tool_calls should be []any after JSON round-trip") + require.Len(t, toolCallsRestored, 1) + + tc := toolCallsRestored[0].(map[string]any) + require.Equal(t, "call_abc", tc["id"]) + require.Equal(t, "function", tc["type"]) + + fn := tc["function"].(map[string]any) + require.Equal(t, "my_tool", fn["name"]) + + require.Len(t, restored.Issues, 1) + require.Equal(t, "smell", restored.Issues[0]["type"]) + require.Equal(t, "foo.go", restored.Issues[0]["file"]) +} + +// ── MarshalIssue ────────────────────────────────────────────────────────────── + +func TestMarshalIssue(t *testing.T) { + s := &AgenticSession{} + type Issue struct { + Kind string `json:"kind"` + Msg string `json:"msg"` + } + err := MarshalIssue(s, Issue{Kind: "error", Msg: "oops"}) + require.NoError(t, err) + require.Len(t, s.Issues, 1) + require.Equal(t, "error", s.Issues[0]["kind"]) + require.Equal(t, "oops", s.Issues[0]["msg"]) +} + +func TestMarshalIssue_Multiple(t *testing.T) { + s := &AgenticSession{} + type Issue struct{ V int } + require.NoError(t, MarshalIssue(s, Issue{V: 1})) + require.NoError(t, MarshalIssue(s, Issue{V: 2})) + require.Len(t, s.Issues, 2) +} + +// ── AgenticSession.RunToolLoop ──────────────────────────────────────────────── + +func TestAgenticSession_FreshStart(t *testing.T) { + provider := NewMockProvider([]MockResponse{Done("finished")}) + reg := NewToolRegistry() + var session AgenticSession + + runInActivity(t, func(ctx context.Context) error { + session = AgenticSession{} + return session.RunToolLoop(ctx, provider, reg, "sys", "my prompt") + }) + + require.Equal(t, "my prompt", session.Messages[0]["content"]) + require.Equal(t, "user", session.Messages[0]["role"]) +} + +func TestAgenticSession_ResumesExistingMessages(t *testing.T) { + // When Messages is already populated (retry case), the prompt is NOT + // prepended again — the loop resumes from where it left off. + provider := NewMockProvider([]MockResponse{Done("ok")}) + reg := NewToolRegistry() + + existing := []map[string]any{ + {"role": "user", "content": "original"}, + {"role": "assistant", "content": []map[string]any{{"type": "text", "text": "thinking"}}}, + } + session := AgenticSession{Messages: existing} + + runInActivity(t, func(ctx context.Context) error { + return session.RunToolLoop(ctx, provider, reg, "sys", "ignored prompt") + }) + + // First two messages unchanged. + require.Equal(t, "original", session.Messages[0]["content"]) + require.Equal(t, "assistant", session.Messages[1]["role"]) +} + +func TestAgenticSession_WithToolCalls(t *testing.T) { + collected := []string{} + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "collect", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { + collected = append(collected, inp["v"].(string)) + return "ok", nil + }) + + provider := NewMockProvider([]MockResponse{ + ToolCall("collect", map[string]any{"v": "first"}), + ToolCall("collect", map[string]any{"v": "second"}), + Done("done"), + }).WithRegistry(reg) + + session := AgenticSession{} + runInActivity(t, func(ctx context.Context) error { + return session.RunToolLoop(ctx, provider, reg, "sys", "go") + }) + + require.Equal(t, []string{"first", "second"}, collected) + // user + (assistant + tool_result)*2 + assistant + require.Greater(t, len(session.Messages), 4) +} + +func TestAgenticSession_CheckpointOnEachTurn(t *testing.T) { + // Verifies RunToolLoop checkpoints before each provider call. + reg := NewToolRegistry() + provider := NewMockProvider([]MockResponse{ + Done("turn1"), + }) + + session := AgenticSession{} + runInActivity(t, func(ctx context.Context) error { + return session.RunToolLoop(ctx, provider, reg, "sys", "prompt") + }) + + // The loop ran without error — heartbeating inside an activity context works. + require.NotEmpty(t, session.Messages) +} + +// ── RunWithSession ──────────────────────────────────────────────────────────── + +func TestRunWithSession_FreshStart(t *testing.T) { + provider := NewMockProvider([]MockResponse{Done("done")}) + reg := NewToolRegistry() + var capturedMessages []map[string]any + + runInActivity(t, func(ctx context.Context) error { + return RunWithSession(ctx, func(ctx context.Context, s *AgenticSession) error { + err := s.RunToolLoop(ctx, provider, reg, "sys", "hello") + capturedMessages = s.Messages + return err + }) + }) + + require.NotEmpty(t, capturedMessages) + require.Equal(t, "hello", capturedMessages[0]["content"]) +} + +func TestRunWithSession_IssuesAccumulate(t *testing.T) { + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "flag", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { return "recorded", nil }) + + provider := NewMockProvider([]MockResponse{ + ToolCall("flag", map[string]any{"desc": "broken"}), + Done("done"), + }).WithRegistry(reg) + + type Issue struct { + Desc string `json:"desc"` + } + var capturedIssues []map[string]any + + runInActivity(t, func(ctx context.Context) error { + return RunWithSession(ctx, func(ctx context.Context, s *AgenticSession) error { + _ = MarshalIssue(s, Issue{Desc: "pre-existing"}) + err := s.RunToolLoop(ctx, provider, reg, "sys", "analyze") + capturedIssues = s.Issues + return err + }) + }) + + require.Len(t, capturedIssues, 1) + require.Equal(t, "pre-existing", capturedIssues[0]["desc"]) +} diff --git a/contrib/toolregistry/testing.go b/contrib/toolregistry/testing.go new file mode 100644 index 000000000..60722eb5e --- /dev/null +++ b/contrib/toolregistry/testing.go @@ -0,0 +1,212 @@ +package toolregistry + +import ( + "context" + "fmt" + "math/rand/v2" +) + +// Dispatcher is satisfied by [*ToolRegistry] and [*FakeToolRegistry]. +// Pass a [*FakeToolRegistry] to [MockProvider.WithRegistry] to record which +// tool calls the scripted responses trigger. +type Dispatcher interface { + Dispatch(name string, input map[string]any) (string, error) +} + +// MockResponse is a scripted provider response produced by [ToolCall] or [Done]. +// The internal fields are not exported; construct values with those factories. +type MockResponse struct { + stop bool + content []map[string]any +} + +// ToolCall returns a [MockResponse] that makes a single tool call. +// If callID is empty or omitted, a random ID is generated. +// +// Example: +// +// provider := toolregistry.NewMockProvider([]toolregistry.MockResponse{ +// toolregistry.ToolCall("flag", map[string]any{"desc": "broken"}), +// toolregistry.Done("done"), +// }) +func ToolCall(toolName string, toolInput map[string]any, callID ...string) MockResponse { + id := fmt.Sprintf("test_%08x", rand.Uint32()) + if len(callID) > 0 && callID[0] != "" { + id = callID[0] + } + return MockResponse{ + stop: false, + content: []map[string]any{ + {"type": "tool_use", "id": id, "name": toolName, "input": toolInput}, + }, + } +} + +// Done returns a [MockResponse] that ends the loop with the given text. +// If text is omitted, "Done." is used. +func Done(text ...string) MockResponse { + t := "Done." + if len(text) > 0 { + t = text[0] + } + return MockResponse{ + stop: true, + content: []map[string]any{{"type": "text", "text": t}}, + } +} + +// MockProvider implements [Provider] using pre-scripted responses. No LLM API +// calls are made. Responses are consumed in order; once exhausted the loop +// stops cleanly. +// +// Use [MockProvider.WithRegistry] to inject a [FakeToolRegistry] if you need +// to record which tool calls the scripted responses trigger. +// +// Example: +// +// provider := toolregistry.NewMockProvider([]toolregistry.MockResponse{ +// toolregistry.ToolCall("greet", map[string]any{"name": "world"}), +// toolregistry.Done("said hello"), +// }) +type MockProvider struct { + responses []MockResponse + index int + registry Dispatcher +} + +// NewMockProvider creates a MockProvider backed by an empty [ToolRegistry]. +// Register handlers on the embedded registry via [MockProvider.WithRegistry] +// if any scripted response triggers a tool call. +func NewMockProvider(responses []MockResponse) *MockProvider { + return &MockProvider{ + responses: responses, + registry: NewToolRegistry(), + } +} + +// WithRegistry replaces the dispatch registry and returns p for chaining. +// Accepts [*ToolRegistry] or [*FakeToolRegistry]. +func (p *MockProvider) WithRegistry(r Dispatcher) *MockProvider { + p.registry = r + return p +} + +// RunTurn implements [Provider]. Returns the next scripted response. +func (p *MockProvider) RunTurn(_ context.Context, _ []map[string]any, _ []ToolDef) ([]map[string]any, bool, error) { + if p.index >= len(p.responses) { + return nil, true, nil + } + resp := p.responses[p.index] + p.index++ + + var newMsgs []map[string]any + newMsgs = append(newMsgs, map[string]any{"role": "assistant", "content": resp.content}) + + if !resp.stop { + var toolResults []map[string]any + for _, block := range resp.content { + if block["type"] == "tool_use" { + name, _ := block["name"].(string) + id, _ := block["id"].(string) + input, _ := block["input"].(map[string]any) + result, err := p.registry.Dispatch(name, input) + if err != nil { + result = fmt.Sprintf("error: %s", err) + } + toolResults = append(toolResults, map[string]any{ + "type": "tool_result", + "tool_use_id": id, + "content": result, + }) + } + } + if len(toolResults) > 0 { + newMsgs = append(newMsgs, map[string]any{"role": "user", "content": toolResults}) + } + } + + return newMsgs, resp.stop, nil +} + +// DispatchCall records a single invocation on [FakeToolRegistry]. +type DispatchCall struct { + Name string + Input map[string]any +} + +// FakeToolRegistry wraps [ToolRegistry] and records every [Dispatch] call. +// Useful for asserting which tools were called and with what inputs. +// +// Example: +// +// fake := toolregistry.NewFakeToolRegistry() +// fake.Register(def, handler) +// fake.Dispatch("greet", map[string]any{"name": "world"}) +// // fake.Calls == [{Name:"greet", Input:{"name":"world"}}] +type FakeToolRegistry struct { + *ToolRegistry + // Calls holds every Dispatch invocation in order. + Calls []DispatchCall +} + +// NewFakeToolRegistry creates an empty FakeToolRegistry. +func NewFakeToolRegistry() *FakeToolRegistry { + return &FakeToolRegistry{ToolRegistry: NewToolRegistry()} +} + +// Dispatch records the call then delegates to the underlying registry. +func (f *FakeToolRegistry) Dispatch(name string, input map[string]any) (string, error) { + f.Calls = append(f.Calls, DispatchCall{Name: name, Input: input}) + return f.ToolRegistry.Dispatch(name, input) +} + +// MockAgenticSession is a pre-canned session that returns fixed issues without +// any LLM calls. Use it to test code that calls [RunWithSession] and inspects +// session.Issues without an API key or a Temporal server. +// +// Example: +// +// s := &toolregistry.MockAgenticSession{ +// Issues: []map[string]any{{"type": "missing", "symbol": "x"}}, +// } +// _ = s.RunToolLoop(ctx, nil, nil, "sys", "prompt") +// // s.Issues still contains the pre-canned entry +type MockAgenticSession struct { + Messages []map[string]any + Issues []map[string]any +} + +// RunToolLoop is a no-op — it does not call any LLM or record a heartbeat. +func (s *MockAgenticSession) RunToolLoop(_ context.Context, _ Provider, _ *ToolRegistry, _, prompt string) error { + if len(s.Messages) == 0 { + s.Messages = []map[string]any{{"role": "user", "content": prompt}} + } + return nil +} + +// CrashAfterTurns implements [Provider] and returns an error after N complete +// turns. Use it in integration tests to verify that [AgenticSession] resumes +// from a heartbeat checkpoint after a simulated crash. +// +// Example: +// +// // First invocation returns an error after 2 turns. +// // Second invocation (retry) resumes from the last checkpoint. +// provider := &toolregistry.CrashAfterTurns{N: 2} +type CrashAfterTurns struct { + // N is the number of turns to complete before returning an error. + N int + count int +} + +// RunTurn implements [Provider]. Returns an error once more than N turns have run. +func (c *CrashAfterTurns) RunTurn(_ context.Context, _ []map[string]any, _ []ToolDef) ([]map[string]any, bool, error) { + c.count++ + if c.count > c.N { + return nil, false, fmt.Errorf("CrashAfterTurns: simulated crash after %d turns", c.N) + } + newMsgs := []map[string]any{ + {"role": "assistant", "content": []map[string]any{{"type": "text", "text": "..."}}}, + } + return newMsgs, c.count >= c.N, nil +} diff --git a/contrib/toolregistry/testing_test.go b/contrib/toolregistry/testing_test.go new file mode 100644 index 000000000..d1b77ab09 --- /dev/null +++ b/contrib/toolregistry/testing_test.go @@ -0,0 +1,200 @@ +package toolregistry + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +// ── MockProvider ────────────────────────────────────────────────────────────── + +func TestMockProvider_DispatchesToolCallsAndCompletes(t *testing.T) { + collected := []string{} + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "collect", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { + collected = append(collected, inp["value"].(string)) + return "ok", nil + }) + + provider := NewMockProvider([]MockResponse{ + ToolCall("collect", map[string]any{"value": "first"}), + ToolCall("collect", map[string]any{"value": "second"}), + Done("all done"), + }).WithRegistry(reg) + + ctx := context.Background() + msgs := []map[string]any{{"role": "user", "content": "go"}} + for { + newMsgs, done, err := provider.RunTurn(ctx, msgs, nil) + require.NoError(t, err) + msgs = append(msgs, newMsgs...) + if done { + break + } + } + + require.Equal(t, []string{"first", "second"}, collected) +} + +func TestMockProvider_StopsOnDone(t *testing.T) { + provider := NewMockProvider([]MockResponse{Done("finished")}) + ctx := context.Background() + messages := []map[string]any{{"role": "user", "content": "x"}} + + newMsgs, done, err := provider.RunTurn(ctx, messages, nil) + require.NoError(t, err) + require.True(t, done) + require.Len(t, newMsgs, 1) + require.Equal(t, "assistant", newMsgs[0]["role"]) +} + +func TestMockProvider_ExhaustedResponsesStopCleanly(t *testing.T) { + provider := NewMockProvider([]MockResponse{}) + ctx := context.Background() + + newMsgs, done, err := provider.RunTurn(ctx, nil, nil) + require.NoError(t, err) + require.True(t, done) + require.Empty(t, newMsgs) +} + +func TestMockProvider_ToolCallUsesCallID(t *testing.T) { + provider := NewMockProvider([]MockResponse{ + ToolCall("greet", map[string]any{}, "my-custom-id"), + Done(), + }) + ctx := context.Background() + + newMsgs, done, err := provider.RunTurn(ctx, nil, nil) + require.NoError(t, err) + require.False(t, done) + + content := newMsgs[0]["content"].([]map[string]any) + require.Equal(t, "my-custom-id", content[0]["id"]) +} + +func TestMockProvider_RunsWithRunToolLoop(t *testing.T) { + reg := NewToolRegistry() + provider := NewMockProvider([]MockResponse{Done("done")}) + + msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "prompt") + require.NoError(t, err) + require.GreaterOrEqual(t, len(msgs), 2) +} + +// ── FakeToolRegistry ────────────────────────────────────────────────────────── + +func TestFakeToolRegistry_RecordsDispatchCalls(t *testing.T) { + fake := NewFakeToolRegistry() + fake.Register(ToolDef{Name: "greet", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { return "ok", nil }) + + fake.Dispatch("greet", map[string]any{"name": "world"}) //nolint:errcheck + fake.Dispatch("greet", map[string]any{"name": "temporal"}) //nolint:errcheck + + require.Equal(t, []DispatchCall{ + {Name: "greet", Input: map[string]any{"name": "world"}}, + {Name: "greet", Input: map[string]any{"name": "temporal"}}, + }, fake.Calls) +} + +func TestFakeToolRegistry_DelegatestoHandler(t *testing.T) { + fake := NewFakeToolRegistry() + fake.Register(ToolDef{Name: "echo", Description: "d", InputSchema: map[string]any{}}, + func(inp map[string]any) (string, error) { + return inp["v"].(string), nil + }) + + result, err := fake.Dispatch("echo", map[string]any{"v": "hello"}) + require.NoError(t, err) + require.Equal(t, "hello", result) +} + +func TestFakeToolRegistry_UnknownToolStillErrors(t *testing.T) { + fake := NewFakeToolRegistry() + _, err := fake.Dispatch("unknown", map[string]any{}) + require.Error(t, err) + // Call is still recorded even on error. + require.Len(t, fake.Calls, 1) +} + +func TestFakeToolRegistry_SatisfiesDispatcher(t *testing.T) { + // Verify *FakeToolRegistry satisfies the Dispatcher interface so it can be + // passed to MockProvider.WithRegistry. + fake := NewFakeToolRegistry() + fake.Register(ToolDef{Name: "ping", Description: "d", InputSchema: map[string]any{}}, + func(map[string]any) (string, error) { return "pong", nil }) + + provider := NewMockProvider([]MockResponse{ + ToolCall("ping", map[string]any{}), + Done(), + }).WithRegistry(fake) + + _, err := RunToolLoop(context.Background(), provider, fake.ToolRegistry, "sys", "p") + require.NoError(t, err) + require.Len(t, fake.Calls, 1) + require.Equal(t, "ping", fake.Calls[0].Name) +} + +// ── CrashAfterTurns ─────────────────────────────────────────────────────────── + +func TestCrashAfterTurns_ErrorsAfterN(t *testing.T) { + c := &CrashAfterTurns{N: 1} + ctx := context.Background() + + // First turn: succeeds. + _, done, err := c.RunTurn(ctx, nil, nil) + require.NoError(t, err) + require.True(t, done) + + // Second turn: error. + _, _, err = c.RunTurn(ctx, nil, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "simulated crash") +} + +func TestCrashAfterTurns_CompletesNTurnsBeforeCrash(t *testing.T) { + c := &CrashAfterTurns{N: 2} + ctx := context.Background() + + for i := 0; i < 2; i++ { + newMsgs, _, err := c.RunTurn(ctx, nil, nil) + require.NoError(t, err) + require.Len(t, newMsgs, 1) + } + + _, _, err := c.RunTurn(ctx, nil, nil) + require.Error(t, err) +} + +func TestCrashAfterTurns_ImplementsProvider(t *testing.T) { + var _ Provider = &CrashAfterTurns{} +} + +// ── MockAgenticSession ──────────────────────────────────────────────────────── + +func TestMockAgenticSession_NoOpRunToolLoop(t *testing.T) { + s := &MockAgenticSession{ + Issues: []map[string]any{{"type": "deprecated", "symbol": "old_fn"}}, + } + err := s.RunToolLoop(context.Background(), nil, nil, "sys", "prompt") + require.NoError(t, err) + // Issues unchanged — no LLM calls. + require.Len(t, s.Issues, 1) + require.Equal(t, "deprecated", s.Issues[0]["type"]) +} + +func TestMockAgenticSession_SetsFirstMessage(t *testing.T) { + s := &MockAgenticSession{} + _ = s.RunToolLoop(context.Background(), nil, nil, "sys", "my prompt") + require.Len(t, s.Messages, 1) + require.Equal(t, "my prompt", s.Messages[0]["content"]) +} + +func TestMockAgenticSession_EmptyByDefault(t *testing.T) { + s := &MockAgenticSession{} + require.Empty(t, s.Issues) + require.Empty(t, s.Messages) +} From d4cd4b34445c0b6110c3ad2cb74cd3ac1fe47e7a Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 12 Apr 2026 22:40:53 -0600 Subject: [PATCH 2/5] Add MCP tool-wrapping support to toolregistry Adds MCPTool struct and FromMCPTools constructor that converts a list of MCP tool descriptors into a populated ToolRegistry. Handlers default to no-ops; callers override with Register after construction. Nil InputSchema is normalized to an empty object schema. Co-Authored-By: Claude Sonnet 4.6 --- contrib/toolregistry/README.md | 20 +++++++++++++++ contrib/toolregistry/registry.go | 30 ++++++++++++++++++++++ contrib/toolregistry/registry_test.go | 36 +++++++++++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/contrib/toolregistry/README.md b/contrib/toolregistry/README.md index 92577f1f5..b4cd23a47 100644 --- a/contrib/toolregistry/README.md +++ b/contrib/toolregistry/README.md @@ -214,3 +214,23 @@ Recommended timeouts: |---|---| | Standard (Claude 3.x, GPT-4o) | 30 s | | Reasoning (o1, o3, extended thinking) | 300 s | + +## MCP integration + +`FromMCPTools` converts a slice of MCP tool descriptors into a populated registry. +Handlers default to no-ops that return an empty string; override them with `Register` +after construction. + +```go +// mcpTools is []MCPTool — populate from your MCP client. +reg := toolregistry.FromMCPTools(mcpTools) + +// Override specific handlers before running the loop. +reg.Register(toolregistry.ToolDef{Name: "read_file", /* ... */}, + func(inp map[string]any) (string, error) { + return readFile(inp["path"].(string)) + }) +``` + +`MCPTool` mirrors the MCP protocol's `Tool` object: `Name`, `Description`, and +`InputSchema` (a `map[string]any` containing a JSON Schema object). diff --git a/contrib/toolregistry/registry.go b/contrib/toolregistry/registry.go index 776376f60..2c7c44b26 100644 --- a/contrib/toolregistry/registry.go +++ b/contrib/toolregistry/registry.go @@ -80,6 +80,36 @@ func NewToolRegistry() *ToolRegistry { } } +// MCPTool is an MCP-compatible tool descriptor. +// Any struct with Name, Description, and InputSchema fields satisfies this shape. +type MCPTool struct { + Name string + Description string + InputSchema map[string]any +} + +// FromMCPTools creates a [ToolRegistry] from a list of MCP tool descriptors. +// +// Each tool is registered with a no-op handler (returning an empty string). +// Override handlers by calling [ToolRegistry.Register] with the same name after +// construction. +func FromMCPTools(tools []MCPTool) *ToolRegistry { + reg := NewToolRegistry() + for _, t := range tools { + schema := t.InputSchema + if schema == nil { + schema = map[string]any{"type": "object", "properties": map[string]any{}} + } + name := t.Name + reg.Register(ToolDef{ + Name: name, + Description: t.Description, + InputSchema: schema, + }, func(_ map[string]any) (string, error) { return "", nil }) + } + return reg +} + // Register adds a tool definition and its handler to the registry. func (r *ToolRegistry) Register(def ToolDef, handler Handler) { r.defs = append(r.defs, def) diff --git a/contrib/toolregistry/registry_test.go b/contrib/toolregistry/registry_test.go index b34100431..f86723b36 100644 --- a/contrib/toolregistry/registry_test.go +++ b/contrib/toolregistry/registry_test.go @@ -133,3 +133,39 @@ func TestRunToolLoop_EmptyResponses(t *testing.T) { // provider returns done immediately, so only the user message is present require.Len(t, msgs, 1) } + +func TestFromMCPTools(t *testing.T) { + tools := []MCPTool{ + { + Name: "read_file", + Description: "Read a file from disk", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{"path": map[string]any{"type": "string"}}, + "required": []string{"path"}, + }, + }, + { + Name: "list_dir", + Description: "List directory contents", + InputSchema: nil, // should default to empty object schema + }, + } + + reg := FromMCPTools(tools) + + defs := reg.Defs() + require.Len(t, defs, 2) + + require.Equal(t, "read_file", defs[0].Name) + require.Equal(t, "Read a file from disk", defs[0].Description) + require.Equal(t, "object", defs[0].InputSchema["type"]) + + require.Equal(t, "list_dir", defs[1].Name) + require.Equal(t, "object", defs[1].InputSchema["type"]) // nil schema defaulted + + // No-op handlers return empty string without error. + result, err := reg.Dispatch("read_file", map[string]any{"path": "/etc/hosts"}) + require.NoError(t, err) + require.Equal(t, "", result) +} From 7303ef53d7cafc85bf409a1bdb3fe56eb49c248c Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Sun, 12 Apr 2026 23:23:24 -0600 Subject: [PATCH 3/5] fix: add is_error handling to AnthropicProvider, add provider error tests - Set is_error=true on Anthropic tool result maps when a handler returns an error, matching the Anthropic API spec; OpenAI has no equivalent field - Add providers_test.go with tests for is_error propagation on handler error and absence of is_error on successful handlers - Update README to clarify positioning vs Python/TypeScript framework plugins Co-Authored-By: Claude Sonnet 4.6 --- contrib/toolregistry/README.md | 2 + contrib/toolregistry/providers.go | 15 +-- contrib/toolregistry/providers_test.go | 130 +++++++++++++++++++++++++ 3 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 contrib/toolregistry/providers_test.go diff --git a/contrib/toolregistry/README.md b/contrib/toolregistry/README.md index b4cd23a47..be2b849b0 100644 --- a/contrib/toolregistry/README.md +++ b/contrib/toolregistry/README.md @@ -13,6 +13,8 @@ A Temporal Activity is a function that Temporal monitors and retries automatical New to Temporal? → https://docs.temporal.io/develop +**Python or TypeScript user?** Those SDKs also ship framework-level integrations (`openai_agents`, `google_adk_agents`, `langgraph`, `@temporalio/ai-sdk`) for teams already using a specific agent framework. ToolRegistry is the equivalent story for direct Anthropic/OpenAI calls, and shares the same API surface across all six Temporal SDKs. + ## Install ```bash diff --git a/contrib/toolregistry/providers.go b/contrib/toolregistry/providers.go index f2d21e981..ff1d2d55f 100644 --- a/contrib/toolregistry/providers.go +++ b/contrib/toolregistry/providers.go @@ -112,14 +112,17 @@ func (p *AnthropicProvider) RunTurn(ctx context.Context, messages []map[string]a } } result, err := p.registry.Dispatch(name, input) - if err != nil { - result = fmt.Sprintf("error: %s", err) - } - toolResults = append(toolResults, map[string]any{ + toolResult := map[string]any{ "type": "tool_result", "tool_use_id": id, - "content": result, - }) + } + if err != nil { + toolResult["content"] = fmt.Sprintf("error: %s", err) + toolResult["is_error"] = true + } else { + toolResult["content"] = result + } + toolResults = append(toolResults, toolResult) } newMsgs = append(newMsgs, map[string]any{"role": "user", "content": toolResults}) return newMsgs, false, nil diff --git a/contrib/toolregistry/providers_test.go b/contrib/toolregistry/providers_test.go new file mode 100644 index 000000000..ae94a1677 --- /dev/null +++ b/contrib/toolregistry/providers_test.go @@ -0,0 +1,130 @@ +package toolregistry + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + anthropicSDK "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/stretchr/testify/require" +) + +// TestAnthropicProvider_HandlerError_SetsIsError verifies that when a tool handler +// returns an error, the resulting tool_result message carries "is_error": true and +// the loop does not propagate the error to the caller. +func TestAnthropicProvider_HandlerError_SetsIsError(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + // First call: model requests the "boom" tool. + fmt.Fprint(w, `{ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "tool_use", "id": "c1", "name": "boom", "input": {}}], + "model": "claude-sonnet-4-6", + "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5} + }`) + } else { + // Second call: model stops. + fmt.Fprint(w, `{ + "id": "msg_2", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "model": "claude-sonnet-4-6", + "stop_reason": "end_turn", + "usage": {"input_tokens": 20, "output_tokens": 5} + }`) + } + })) + defer server.Close() + + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "boom", Description: "d", InputSchema: map[string]any{}}, + func(_ map[string]any) (string, error) { + return "", fmt.Errorf("intentional failure") + }) + + client := anthropicSDK.NewClient( + option.WithAPIKey("test-key"), + option.WithBaseURL(server.URL), + ) + provider := NewAnthropicProvider(AnthropicConfig{Client: &client}, reg, "sys") + + messages := []map[string]any{{"role": "user", "content": "go"}} + newMsgs, done, err := provider.RunTurn(context.Background(), messages, reg.Defs()) + require.NoError(t, err) + require.False(t, done) + + // newMsgs: assistant message + user(tool_results) message + require.Len(t, newMsgs, 2) + toolResultMsg := newMsgs[1] + require.Equal(t, "user", toolResultMsg["role"]) + + results, ok := toolResultMsg["content"].([]map[string]any) + require.True(t, ok, "content should be []map[string]any") + require.Len(t, results, 1) + + tr := results[0] + require.Equal(t, "tool_result", tr["type"]) + require.Equal(t, true, tr["is_error"], "is_error should be true when handler errors") + content, _ := tr["content"].(string) + require.True(t, strings.Contains(content, "intentional failure"), "error message should be in content") +} + +// TestAnthropicProvider_HandlerSuccess_NoIsError verifies that successful handlers +// do not set is_error. +func TestAnthropicProvider_HandlerSuccess_NoIsError(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + fmt.Fprint(w, `{ + "id": "msg_1", "type": "message", "role": "assistant", + "content": [{"type": "tool_use", "id": "c1", "name": "ok_tool", "input": {}}], + "model": "claude-sonnet-4-6", "stop_reason": "tool_use", + "usage": {"input_tokens": 10, "output_tokens": 5} + }`) + } else { + fmt.Fprint(w, `{ + "id": "msg_2", "type": "message", "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "model": "claude-sonnet-4-6", "stop_reason": "end_turn", + "usage": {"input_tokens": 20, "output_tokens": 5} + }`) + } + })) + defer server.Close() + + reg := NewToolRegistry() + reg.Register(ToolDef{Name: "ok_tool", Description: "d", InputSchema: map[string]any{}}, + func(_ map[string]any) (string, error) { return "success", nil }) + + client := anthropicSDK.NewClient( + option.WithAPIKey("test-key"), + option.WithBaseURL(server.URL), + ) + provider := NewAnthropicProvider(AnthropicConfig{Client: &client}, reg, "sys") + + messages := []map[string]any{{"role": "user", "content": "go"}} + newMsgs, done, err := provider.RunTurn(context.Background(), messages, reg.Defs()) + require.NoError(t, err) + require.False(t, done) + + results, ok := newMsgs[1]["content"].([]map[string]any) + require.True(t, ok) + tr := results[0] + + data, _ := json.Marshal(tr) + require.NotContains(t, string(data), "is_error", "is_error should not appear on success") +} From 6b071996c0cf5b838cff8f93d88c29bb41bfb85a Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:14:17 -0600 Subject: [PATCH 4/5] =?UTF-8?q?feat(toolregistry):=20rename=20Issues?= =?UTF-8?q?=E2=86=92Results,=20remove=20dead=20system=20param,=20async=20d?= =?UTF-8?q?ispatch,=20timeout=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename AgenticSession.Issues → Results and MarshalIssue → MarshalResult across session.go, testing.go, and all test files - Remove the unused system string parameter from RunToolLoop and AgenticSession.RunToolLoop (system is captured at provider construction time; the parameter was a no-op that silently dropped caller-supplied values) - Add ScheduleToCloseTimeout guidance to README Co-Authored-By: Claude Sonnet 4.6 --- contrib/toolregistry/README.md | 47 ++++++++++++------- contrib/toolregistry/integration_test.go | 4 +- contrib/toolregistry/registry.go | 4 +- contrib/toolregistry/registry_test.go | 6 +-- contrib/toolregistry/session.go | 40 ++++++++-------- contrib/toolregistry/session_test.go | 58 ++++++++++++------------ contrib/toolregistry/testing.go | 14 +++--- contrib/toolregistry/testing_test.go | 20 ++++---- 8 files changed, 104 insertions(+), 89 deletions(-) diff --git a/contrib/toolregistry/README.md b/contrib/toolregistry/README.md index be2b849b0..004b56943 100644 --- a/contrib/toolregistry/README.md +++ b/contrib/toolregistry/README.md @@ -56,7 +56,7 @@ func AnalyzeActivity(ctx context.Context, prompt string) ([]string, error) { "You are a code reviewer. Call flag_issue for each problem you find.") // RunToolLoop returns the full conversation history; capture or discard as needed. - if _, err := toolregistry.RunToolLoop(ctx, provider, reg, "" /* system prompt: "" defers to provider default */, prompt); err != nil { + if _, err := toolregistry.RunToolLoop(ctx, provider, reg, prompt); err != nil { return nil, err } return issues, nil @@ -82,7 +82,7 @@ Model IDs are defined by the provider — see Anthropic or OpenAI docs for curre ```go cfg := toolregistry.OpenAIConfig{APIKey: os.Getenv("OPENAI_API_KEY")} provider := toolregistry.NewOpenAIProvider(cfg, reg, "system prompt") -if _, err := toolregistry.RunToolLoop(ctx, provider, reg, "", prompt); err != nil { +if _, err := toolregistry.RunToolLoop(ctx, provider, reg, prompt); err != nil { return nil, err } ``` @@ -101,7 +101,7 @@ import ( ) func LongAnalysisActivity(ctx context.Context, prompt string) ([]map[string]any, error) { - var issues []map[string]any + var results []map[string]any err := toolregistry.RunWithSession(ctx, func(ctx context.Context, s *toolregistry.AgenticSession) error { reg := toolregistry.NewToolRegistry() @@ -109,19 +109,19 @@ func LongAnalysisActivity(ctx context.Context, prompt string) ([]map[string]any, Name: "flag", Description: "...", InputSchema: map[string]any{"type": "object"}, }, func(inp map[string]any) (string, error) { - s.Issues = append(s.Issues, inp) // s.Issues is []map[string]any + s.Results = append(s.Results, inp) // s.Results is []map[string]any return "ok", nil }) cfg := toolregistry.AnthropicConfig{APIKey: os.Getenv("ANTHROPIC_API_KEY")} provider := toolregistry.NewAnthropicProvider(cfg, reg, "...") - if err := s.RunToolLoop(ctx, provider, reg, "...", prompt); err != nil { + if err := s.RunToolLoop(ctx, provider, reg, prompt); err != nil { return err } - issues = s.Issues // capture after loop completes + results = s.Results // capture after loop completes return nil }) - return issues, err + return results, err } ``` @@ -141,7 +141,7 @@ func TestAnalyze(t *testing.T) { toolregistry.Done("analysis complete"), }).WithRegistry(reg) - msgs, err := toolregistry.RunToolLoop(context.Background(), provider, reg, "sys", "analyze") + msgs, err := toolregistry.RunToolLoop(context.Background(), provider, reg, "analyze") require.NoError(t, err) require.Greater(t, len(msgs), 2) } @@ -163,7 +163,7 @@ incur billing — expect a few cents per full test run. ## Storing application results -`s.Issues` accumulates application-level results during the tool loop. +`s.Results` accumulates application-level results during the tool loop. Elements are serialized to JSON inside each heartbeat checkpoint — they must be plain maps/dicts with JSON-serializable values. A non-serializable value raises a non-retryable `ApplicationError` at heartbeat time rather than silently losing @@ -175,21 +175,21 @@ Convert your domain type to a plain dict at the tool-call site and back after the session: ```go -type Issue struct { +type Result struct { Type string `json:"type"` File string `json:"file"` } // Inside tool handler: -s.Issues = append(s.Issues, map[string]any{"type": "smell", "file": "foo.go"}) +s.Results = append(s.Results, map[string]any{"type": "smell", "file": "foo.go"}) // After session: -var issues []Issue -for _, raw := range s.Issues { +var results []Result +for _, raw := range s.Results { data, _ := json.Marshal(raw) - var issue Issue - _ = json.Unmarshal(data, &issue) - issues = append(issues, issue) + var r Result + _ = json.Unmarshal(data, &r) + results = append(results, r) } ``` @@ -217,6 +217,21 @@ Recommended timeouts: | Standard (Claude 3.x, GPT-4o) | 30 s | | Reasoning (o1, o3, extended thinking) | 300 s | +### Activity-level timeout + +Set `ScheduleToCloseTimeout` on the activity options to bound the entire conversation: + +```go +c.ExecuteActivity(ctx, LongAnalysisActivity, prompt, + workflow.ActivityOptions{ + ScheduleToCloseTimeout: 10 * time.Minute, + }) +``` + +The per-turn client timeout and `ScheduleToCloseTimeout` are complementary: +- Per-turn timeout fires if one LLM call hangs (protects against a single stuck turn) +- `ScheduleToCloseTimeout` bounds the entire conversation including all retries (protects against runaway multi-turn loops) + ## MCP integration `FromMCPTools` converts a slice of MCP tool descriptors into a populated registry. diff --git a/contrib/toolregistry/integration_test.go b/contrib/toolregistry/integration_test.go index bfed630e8..42325b7ef 100644 --- a/contrib/toolregistry/integration_test.go +++ b/contrib/toolregistry/integration_test.go @@ -39,7 +39,7 @@ func TestIntegration_Anthropic(t *testing.T) { provider := NewAnthropicProvider(cfg, reg, "You must call record() exactly once with value='hello'.") - _, err := RunToolLoop(context.Background(), provider, reg, "", + _, err := RunToolLoop(context.Background(), provider, reg, "Please call the record tool with value='hello'.") require.NoError(t, err) require.Contains(t, *collected, "hello") @@ -57,7 +57,7 @@ func TestIntegration_OpenAI(t *testing.T) { provider := NewOpenAIProvider(cfg, reg, "You must call record() exactly once with value='hello'.") - _, err := RunToolLoop(context.Background(), provider, reg, "", + _, err := RunToolLoop(context.Background(), provider, reg, "Please call the record tool with value='hello'.") require.NoError(t, err) require.Contains(t, *collected, "hello") diff --git a/contrib/toolregistry/registry.go b/contrib/toolregistry/registry.go index 2c7c44b26..d1135b83c 100644 --- a/contrib/toolregistry/registry.go +++ b/contrib/toolregistry/registry.go @@ -23,7 +23,7 @@ // // cfg := toolregistry.AnthropicConfig{APIKey: os.Getenv("ANTHROPIC_API_KEY")} // provider := toolregistry.NewAnthropicProvider(cfg, reg) -// _, err := toolregistry.RunToolLoop(ctx, provider, reg, system, prompt) +// _, err := toolregistry.RunToolLoop(ctx, provider, reg, prompt) package toolregistry import ( @@ -176,7 +176,7 @@ func RunToolLoop( ctx context.Context, provider Provider, registry *ToolRegistry, - system, prompt string, + prompt string, ) ([]map[string]any, error) { messages := []map[string]any{ {"role": "user", "content": prompt}, diff --git a/contrib/toolregistry/registry_test.go b/contrib/toolregistry/registry_test.go index f86723b36..115d5b31e 100644 --- a/contrib/toolregistry/registry_test.go +++ b/contrib/toolregistry/registry_test.go @@ -92,7 +92,7 @@ func TestRunToolLoop_SingleDone(t *testing.T) { reg := NewToolRegistry() provider := NewMockProvider([]MockResponse{Done("finished")}) - msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "hello") + msgs, err := RunToolLoop(context.Background(), provider, reg, "hello") require.NoError(t, err) // user + assistant require.Len(t, msgs, 2) @@ -116,7 +116,7 @@ func TestRunToolLoop_WithToolCall(t *testing.T) { Done("all done"), }).WithRegistry(reg) - msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "go") + msgs, err := RunToolLoop(context.Background(), provider, reg, "go") require.NoError(t, err) require.Equal(t, []string{"first", "second"}, collected) @@ -128,7 +128,7 @@ func TestRunToolLoop_EmptyResponses(t *testing.T) { reg := NewToolRegistry() provider := NewMockProvider([]MockResponse{}) - msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "prompt") + msgs, err := RunToolLoop(context.Background(), provider, reg, "prompt") require.NoError(t, err) // provider returns done immediately, so only the user message is present require.Len(t, msgs, 1) diff --git a/contrib/toolregistry/session.go b/contrib/toolregistry/session.go index a11cc8bf8..a0f0e4ac0 100644 --- a/contrib/toolregistry/session.go +++ b/contrib/toolregistry/session.go @@ -14,7 +14,7 @@ import ( type sessionCheckpoint struct { Version int `json:"version"` Messages []map[string]any `json:"messages"` - Issues []map[string]any `json:"issues"` + Results []map[string]any `json:"results"` } // AgenticSession holds conversation state across a multi-turn LLM tool-use @@ -24,9 +24,9 @@ type sessionCheckpoint struct { type AgenticSession struct { // Messages is the full conversation history. Append-only during a session. Messages []map[string]any - // Issues accumulates application-level results from tool calls. + // Results accumulates application-level results from tool calls. // Elements must be JSON-serializable for checkpoint storage. - Issues []map[string]any + Results []map[string]any } // RunToolLoop runs the agentic tool-use loop to completion. @@ -42,7 +42,7 @@ func (s *AgenticSession) RunToolLoop( ctx context.Context, provider Provider, registry *ToolRegistry, - system, prompt string, + prompt string, ) error { if len(s.Messages) == 0 { s.Messages = []map[string]any{{"role": "user", "content": prompt}} @@ -68,15 +68,15 @@ func (s *AgenticSession) RunToolLoop( // checkpoint heartbeats the current session state. It also returns ctx.Err() // after recording, so callers detect heartbeat-timeout cancellation promptly. // -// Returns a non-retryable [temporal.ApplicationError] if any issue cannot be +// Returns a non-retryable [temporal.ApplicationError] if any result cannot be // JSON-marshaled — this surfaces the mistake at heartbeat time rather than // silently losing data on the next retry. func (s *AgenticSession) checkpoint(ctx context.Context) error { - for i, issue := range s.Issues { - if _, err := json.Marshal(issue); err != nil { + for i, result := range s.Results { + if _, err := json.Marshal(result); err != nil { return temporal.NewNonRetryableApplicationError( fmt.Sprintf( - "AgenticSession: issues[%d] is not JSON-serializable: %v. "+ + "AgenticSession: results[%d] is not JSON-serializable: %v. "+ "Store only map[string]any with JSON-serializable values.", i, err), "InvalidArgument", @@ -87,7 +87,7 @@ func (s *AgenticSession) checkpoint(ctx context.Context) error { activity.RecordHeartbeat(ctx, sessionCheckpoint{ Version: 1, Messages: s.Messages, - Issues: s.Issues, + Results: s.Results, }) return ctx.Err() } @@ -96,7 +96,7 @@ func (s *AgenticSession) checkpoint(ctx context.Context) error { // // On entry it checks for heartbeat details from a prior attempt via // [activity.GetHeartbeatDetails]. If found, the session is restored -// (messages + issues) so the conversation resumes mid-turn rather than +// (messages + results) so the conversation resumes mid-turn rather than // restarting from turn 0. // // Go concurrency note: activities are goroutines with a context. Unlike Python @@ -109,12 +109,12 @@ func (s *AgenticSession) checkpoint(ctx context.Context) error { // Example: // // err := toolregistry.RunWithSession(ctx, func(ctx context.Context, s *toolregistry.AgenticSession) error { -// return s.RunToolLoop(ctx, provider, registry, system, prompt) +// return s.RunToolLoop(ctx, provider, registry, prompt) // }) func RunWithSession(ctx context.Context, fn func(ctx context.Context, s *AgenticSession) error) error { session := &AgenticSession{ Messages: make([]map[string]any, 0), - Issues: make([]map[string]any, 0), + Results: make([]map[string]any, 0), } // Attempt to restore from a previous heartbeat checkpoint. @@ -140,8 +140,8 @@ func RunWithSession(ctx context.Context, fn func(ctx context.Context, s *Agentic if cp.Messages != nil { session.Messages = cp.Messages } - if cp.Issues != nil { - session.Issues = cp.Issues + if cp.Results != nil { + session.Results = cp.Results } } } @@ -149,18 +149,18 @@ func RunWithSession(ctx context.Context, fn func(ctx context.Context, s *Agentic return fn(ctx, session) } -// MarshalIssue is a convenience helper that JSON-encodes v and stores the -// result in session.Issues. Use it when your issue type is a struct rather +// MarshalResult is a convenience helper that JSON-encodes v and stores the +// result in session.Results. Use it when your result type is a struct rather // than a plain map. -func MarshalIssue(s *AgenticSession, v any) error { +func MarshalResult(s *AgenticSession, v any) error { data, err := json.Marshal(v) if err != nil { - return fmt.Errorf("toolregistry: marshal issue: %w", err) + return fmt.Errorf("toolregistry: marshal result: %w", err) } var m map[string]any if err := json.Unmarshal(data, &m); err != nil { - return fmt.Errorf("toolregistry: unmarshal issue: %w", err) + return fmt.Errorf("toolregistry: unmarshal result: %w", err) } - s.Issues = append(s.Issues, m) + s.Results = append(s.Results, m) return nil } diff --git a/contrib/toolregistry/session_test.go b/contrib/toolregistry/session_test.go index 2bcd8d408..2bed262d9 100644 --- a/contrib/toolregistry/session_test.go +++ b/contrib/toolregistry/session_test.go @@ -45,7 +45,7 @@ func TestCheckpoint_RoundTrip(t *testing.T) { "tool_calls": toolCalls, }, }, - Issues: []map[string]any{ + Results: []map[string]any{ {"type": "smell", "file": "foo.go"}, }, } @@ -71,32 +71,32 @@ func TestCheckpoint_RoundTrip(t *testing.T) { fn := tc["function"].(map[string]any) require.Equal(t, "my_tool", fn["name"]) - require.Len(t, restored.Issues, 1) - require.Equal(t, "smell", restored.Issues[0]["type"]) - require.Equal(t, "foo.go", restored.Issues[0]["file"]) + require.Len(t, restored.Results, 1) + require.Equal(t, "smell", restored.Results[0]["type"]) + require.Equal(t, "foo.go", restored.Results[0]["file"]) } -// ── MarshalIssue ────────────────────────────────────────────────────────────── +// ── MarshalResult ───────────────────────────────────────────────────────────── -func TestMarshalIssue(t *testing.T) { +func TestMarshalResult(t *testing.T) { s := &AgenticSession{} - type Issue struct { + type Result struct { Kind string `json:"kind"` Msg string `json:"msg"` } - err := MarshalIssue(s, Issue{Kind: "error", Msg: "oops"}) + err := MarshalResult(s, Result{Kind: "error", Msg: "oops"}) require.NoError(t, err) - require.Len(t, s.Issues, 1) - require.Equal(t, "error", s.Issues[0]["kind"]) - require.Equal(t, "oops", s.Issues[0]["msg"]) + require.Len(t, s.Results, 1) + require.Equal(t, "error", s.Results[0]["kind"]) + require.Equal(t, "oops", s.Results[0]["msg"]) } -func TestMarshalIssue_Multiple(t *testing.T) { +func TestMarshalResult_Multiple(t *testing.T) { s := &AgenticSession{} - type Issue struct{ V int } - require.NoError(t, MarshalIssue(s, Issue{V: 1})) - require.NoError(t, MarshalIssue(s, Issue{V: 2})) - require.Len(t, s.Issues, 2) + type Result struct{ V int } + require.NoError(t, MarshalResult(s, Result{V: 1})) + require.NoError(t, MarshalResult(s, Result{V: 2})) + require.Len(t, s.Results, 2) } // ── AgenticSession.RunToolLoop ──────────────────────────────────────────────── @@ -108,7 +108,7 @@ func TestAgenticSession_FreshStart(t *testing.T) { runInActivity(t, func(ctx context.Context) error { session = AgenticSession{} - return session.RunToolLoop(ctx, provider, reg, "sys", "my prompt") + return session.RunToolLoop(ctx, provider, reg, "my prompt") }) require.Equal(t, "my prompt", session.Messages[0]["content"]) @@ -128,7 +128,7 @@ func TestAgenticSession_ResumesExistingMessages(t *testing.T) { session := AgenticSession{Messages: existing} runInActivity(t, func(ctx context.Context) error { - return session.RunToolLoop(ctx, provider, reg, "sys", "ignored prompt") + return session.RunToolLoop(ctx, provider, reg, "ignored prompt") }) // First two messages unchanged. @@ -153,7 +153,7 @@ func TestAgenticSession_WithToolCalls(t *testing.T) { session := AgenticSession{} runInActivity(t, func(ctx context.Context) error { - return session.RunToolLoop(ctx, provider, reg, "sys", "go") + return session.RunToolLoop(ctx, provider, reg, "go") }) require.Equal(t, []string{"first", "second"}, collected) @@ -170,7 +170,7 @@ func TestAgenticSession_CheckpointOnEachTurn(t *testing.T) { session := AgenticSession{} runInActivity(t, func(ctx context.Context) error { - return session.RunToolLoop(ctx, provider, reg, "sys", "prompt") + return session.RunToolLoop(ctx, provider, reg, "prompt") }) // The loop ran without error — heartbeating inside an activity context works. @@ -186,7 +186,7 @@ func TestRunWithSession_FreshStart(t *testing.T) { runInActivity(t, func(ctx context.Context) error { return RunWithSession(ctx, func(ctx context.Context, s *AgenticSession) error { - err := s.RunToolLoop(ctx, provider, reg, "sys", "hello") + err := s.RunToolLoop(ctx, provider, reg, "hello") capturedMessages = s.Messages return err }) @@ -196,7 +196,7 @@ func TestRunWithSession_FreshStart(t *testing.T) { require.Equal(t, "hello", capturedMessages[0]["content"]) } -func TestRunWithSession_IssuesAccumulate(t *testing.T) { +func TestRunWithSession_ResultsAccumulate(t *testing.T) { reg := NewToolRegistry() reg.Register(ToolDef{Name: "flag", Description: "d", InputSchema: map[string]any{}}, func(inp map[string]any) (string, error) { return "recorded", nil }) @@ -206,20 +206,20 @@ func TestRunWithSession_IssuesAccumulate(t *testing.T) { Done("done"), }).WithRegistry(reg) - type Issue struct { + type Result struct { Desc string `json:"desc"` } - var capturedIssues []map[string]any + var capturedResults []map[string]any runInActivity(t, func(ctx context.Context) error { return RunWithSession(ctx, func(ctx context.Context, s *AgenticSession) error { - _ = MarshalIssue(s, Issue{Desc: "pre-existing"}) - err := s.RunToolLoop(ctx, provider, reg, "sys", "analyze") - capturedIssues = s.Issues + _ = MarshalResult(s, Result{Desc: "pre-existing"}) + err := s.RunToolLoop(ctx, provider, reg, "analyze") + capturedResults = s.Results return err }) }) - require.Len(t, capturedIssues, 1) - require.Equal(t, "pre-existing", capturedIssues[0]["desc"]) + require.Len(t, capturedResults, 1) + require.Equal(t, "pre-existing", capturedResults[0]["desc"]) } diff --git a/contrib/toolregistry/testing.go b/contrib/toolregistry/testing.go index 60722eb5e..cc5862cb8 100644 --- a/contrib/toolregistry/testing.go +++ b/contrib/toolregistry/testing.go @@ -160,24 +160,24 @@ func (f *FakeToolRegistry) Dispatch(name string, input map[string]any) (string, return f.ToolRegistry.Dispatch(name, input) } -// MockAgenticSession is a pre-canned session that returns fixed issues without +// MockAgenticSession is a pre-canned session that returns fixed results without // any LLM calls. Use it to test code that calls [RunWithSession] and inspects -// session.Issues without an API key or a Temporal server. +// session.Results without an API key or a Temporal server. // // Example: // // s := &toolregistry.MockAgenticSession{ -// Issues: []map[string]any{{"type": "missing", "symbol": "x"}}, +// Results: []map[string]any{{"type": "missing", "symbol": "x"}}, // } -// _ = s.RunToolLoop(ctx, nil, nil, "sys", "prompt") -// // s.Issues still contains the pre-canned entry +// _ = s.RunToolLoop(ctx, nil, nil, "prompt") +// // s.Results still contains the pre-canned entry type MockAgenticSession struct { Messages []map[string]any - Issues []map[string]any + Results []map[string]any } // RunToolLoop is a no-op — it does not call any LLM or record a heartbeat. -func (s *MockAgenticSession) RunToolLoop(_ context.Context, _ Provider, _ *ToolRegistry, _, prompt string) error { +func (s *MockAgenticSession) RunToolLoop(_ context.Context, _ Provider, _ *ToolRegistry, prompt string) error { if len(s.Messages) == 0 { s.Messages = []map[string]any{{"role": "user", "content": prompt}} } diff --git a/contrib/toolregistry/testing_test.go b/contrib/toolregistry/testing_test.go index d1b77ab09..0a849cb35 100644 --- a/contrib/toolregistry/testing_test.go +++ b/contrib/toolregistry/testing_test.go @@ -79,7 +79,7 @@ func TestMockProvider_RunsWithRunToolLoop(t *testing.T) { reg := NewToolRegistry() provider := NewMockProvider([]MockResponse{Done("done")}) - msgs, err := RunToolLoop(context.Background(), provider, reg, "sys", "prompt") + msgs, err := RunToolLoop(context.Background(), provider, reg, "prompt") require.NoError(t, err) require.GreaterOrEqual(t, len(msgs), 2) } @@ -91,7 +91,7 @@ func TestFakeToolRegistry_RecordsDispatchCalls(t *testing.T) { fake.Register(ToolDef{Name: "greet", Description: "d", InputSchema: map[string]any{}}, func(inp map[string]any) (string, error) { return "ok", nil }) - fake.Dispatch("greet", map[string]any{"name": "world"}) //nolint:errcheck + fake.Dispatch("greet", map[string]any{"name": "world"}) //nolint:errcheck fake.Dispatch("greet", map[string]any{"name": "temporal"}) //nolint:errcheck require.Equal(t, []DispatchCall{ @@ -132,7 +132,7 @@ func TestFakeToolRegistry_SatisfiesDispatcher(t *testing.T) { Done(), }).WithRegistry(fake) - _, err := RunToolLoop(context.Background(), provider, fake.ToolRegistry, "sys", "p") + _, err := RunToolLoop(context.Background(), provider, fake.ToolRegistry, "p") require.NoError(t, err) require.Len(t, fake.Calls, 1) require.Equal(t, "ping", fake.Calls[0].Name) @@ -177,24 +177,24 @@ func TestCrashAfterTurns_ImplementsProvider(t *testing.T) { func TestMockAgenticSession_NoOpRunToolLoop(t *testing.T) { s := &MockAgenticSession{ - Issues: []map[string]any{{"type": "deprecated", "symbol": "old_fn"}}, + Results: []map[string]any{{"type": "deprecated", "symbol": "old_fn"}}, } - err := s.RunToolLoop(context.Background(), nil, nil, "sys", "prompt") + err := s.RunToolLoop(context.Background(), nil, nil, "prompt") require.NoError(t, err) - // Issues unchanged — no LLM calls. - require.Len(t, s.Issues, 1) - require.Equal(t, "deprecated", s.Issues[0]["type"]) + // Results unchanged — no LLM calls. + require.Len(t, s.Results, 1) + require.Equal(t, "deprecated", s.Results[0]["type"]) } func TestMockAgenticSession_SetsFirstMessage(t *testing.T) { s := &MockAgenticSession{} - _ = s.RunToolLoop(context.Background(), nil, nil, "sys", "my prompt") + _ = s.RunToolLoop(context.Background(), nil, nil, "my prompt") require.Len(t, s.Messages, 1) require.Equal(t, "my prompt", s.Messages[0]["content"]) } func TestMockAgenticSession_EmptyByDefault(t *testing.T) { s := &MockAgenticSession{} - require.Empty(t, s.Issues) + require.Empty(t, s.Results) require.Empty(t, s.Messages) } From 15b5daf530f9f392097d4b4b7d488f810bc278b1 Mon Sep 17 00:00:00 2001 From: lex00 <121451605+lex00@users.noreply.github.com> Date: Mon, 13 Apr 2026 00:22:31 -0600 Subject: [PATCH 5/5] =?UTF-8?q?fix=20stale=20local=20variable=20name=20in?= =?UTF-8?q?=20quickstart=20example=20(issues=20=E2=86=92=20results)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart example used `issues` as the local collector variable, left over from before the AgenticSession field was renamed to `Results` in round 2. Rename to `results` so the example is consistent with the other SDK READMEs. Co-Authored-By: Claude Sonnet 4.6 --- contrib/toolregistry/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contrib/toolregistry/README.md b/contrib/toolregistry/README.md index 004b56943..1afe5a408 100644 --- a/contrib/toolregistry/README.md +++ b/contrib/toolregistry/README.md @@ -36,7 +36,7 @@ Tool definitions use [JSON Schema](https://json-schema.org/understanding-json-sc import "go.temporal.io/sdk/contrib/toolregistry" func AnalyzeActivity(ctx context.Context, prompt string) ([]string, error) { - var issues []string + var results []string reg := toolregistry.NewToolRegistry() reg.Register(toolregistry.ToolDef{ Name: "flag_issue", @@ -47,7 +47,7 @@ func AnalyzeActivity(ctx context.Context, prompt string) ([]string, error) { "required": []string{"description"}, }, }, func(inp map[string]any) (string, error) { - issues = append(issues, inp["description"].(string)) + results = append(results, inp["description"].(string)) return "recorded", nil // this string is sent back to the LLM as the tool result }) @@ -59,7 +59,7 @@ func AnalyzeActivity(ctx context.Context, prompt string) ([]string, error) { if _, err := toolregistry.RunToolLoop(ctx, provider, reg, prompt); err != nil { return nil, err } - return issues, nil + return results, nil } ```