diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index d820bde..e6befb4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,7 +16,8 @@ ## Checklist -- [ ] I have run `make lint` and `make test` locally +- [ ] I have run `make check` +- [ ] I have run `task examples:all` - [ ] I have run `make tidy` if I added or removed dependencies - [ ] Commit messages follow [conventional commits](https://www.conventionalcommits.org) (e.g. `feat:`, `fix:`, `docs:`) - [ ] I have added/updated tests for my changes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97792d7..f0a3f10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,5 @@ # CI: lint, test, coverage, build -# - Push to branches (not main): full checks + coverage (no Codecov upload) -# - PR to main: same + Codecov upload +# - PR to main: full checks + Codecov upload # - workflow_dispatch: same + Codecov upload (on-demand coverage report) # - Push / merge to main: no workflow (already validated on the PR) # (Secret scanning: run `make secrets-scan` locally.) @@ -9,9 +8,6 @@ name: CI on: pull_request: branches: [main] - push: - branches-ignore: - - main workflow_dispatch: jobs: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 17e0ed3..b7cb3b8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,27 +56,43 @@ git checkout -b Keep your branch short and descriptive. Sync with `main` before opening a PR: `git pull upstream main` (or rebase if you prefer). Push your branch to your fork and open a PR against `main`. -### 3. Run tests +### 3. Run checks before a PR ```bash -make test +make check ``` -**CI runs automatically** on pull requests and on pushes to branches other than `main`. Pushes or merges to `main` do not trigger CI automatically; use **workflow_dispatch** in GitHub Actions when you need an on-demand run. Lint and test must pass before merge — fix any CI failures in your PR. +Runs `fmt-check`, spell check, `make lint`, `make test`, `make build`, and `make secrets-scan` — same core gates as CI (coverage is CI-only; use `make test-coverage` locally if you want a report). -Or run tests for a specific package: +Also run the full example suite on any code change to catch regressions unit tests may miss: + +```bash +task examples:all +``` + +Requires Task, Docker, and LLM credentials — see [examples/README.md](examples/README.md). + +**CI runs automatically** on pull requests to `main` (open a PR or push updates to an existing PR to re-run checks). Pushes or merges to `main` do not trigger CI; use **workflow_dispatch** in GitHub Actions for an on-demand run. Run `make check` locally before opening a PR; CI must pass on the PR before merge. + +To run only tests (e.g. while iterating): + +```bash +make test +``` + +Or a specific package: ```bash go test ./pkg/agent/... -count=1 -v ``` -### 4. Run linters +### 4. Run linters (included in `make check`) ```bash make lint ``` -This runs `go vet` and `golangci-lint`. All contributions must pass lint with zero errors. +This runs `gofmt -s` check, `misspell`, `go vet`, and `golangci-lint`. Use when debugging a lint failure without re-running the full `make check`. **golangci-lint vs Go version:** If you see `the Go language version used to build golangci-lint is lower than the targeted Go version`, your `golangci-lint` binary is too old for this module (Go 1.26+ requires **golangci-lint v2**). Reinstall: `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`, ensure `$(go env GOPATH)/bin` is on `PATH` ahead of any older install, then run `golangci-lint version` — it should report **v2.x** and a Go build **≥ 1.26**. @@ -87,7 +103,7 @@ make test-coverage # Open coverage.html in a browser ``` -### 6. Run examples (optional) +### 6. Run examples Examples load **`examples/.env.defaults`** automatically. Set LLM credentials via environment or an optional override file: @@ -98,7 +114,15 @@ export LLM_MODEL=your-model # LLM_PROVIDER: openai, anthropic, or gemini. Or append the same keys to examples/.env ``` -Then run any example: +Run the full example suite before a PR (local + temporal, with reports): + +```bash +task examples:all +``` + +When you add a new example, register it in **`taskfiles/examples.yml`** if it can run non-interactively via Task (one-shot `go run`, no REPL, no split worker process). See existing lists (`EXAMPLES`, `EXAMPLES_WITH_PROMPTS`, `EXAMPLES_TEMPORAL`) and commented TODOs for patterns. + +Or run a single example: ```bash go run ./examples/simple_agent "Hello" @@ -140,7 +164,9 @@ Using the SDK and ran into issues, unclear docs, or confusing behavior? **Raise ## What Contributors Must Follow 1. **Code quality** - - Run `make lint` and `make test` before submitting a PR. PRs must pass both. + - Run `make check` before submitting a PR (format, spell, lint, test, build, secrets scan). PRs must pass. + - Run `task examples:all` before submitting a PR to verify nothing in the example suite breaks (any code change — not only example edits). Requires Task, Docker, and LLM credentials — see [examples/README.md](examples/README.md). + - New examples that support batch runs must be added to **`taskfiles/examples.yml`** (see §6). - Run `make tidy` before committing if you add or remove dependencies. 2. **Tests** diff --git a/README.md b/README.md index 911771b..4b4c8e4 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ a, _ := agent.NewAgent( ) defer a.Close() -result, err := a.Run(ctx, "Hello", "") +result, err := a.Run(ctx, "Hello", nil) // result.Content, result.AgentName, result.Model ``` @@ -205,7 +205,7 @@ a, _ := agent.NewAgent( ) defer a.Close() -result, err := a.Run(ctx, "Hello", "") +result, err := a.Run(ctx, "Hello", nil) ``` [examples/simple_agent](examples/simple_agent) @@ -297,7 +297,7 @@ a, _ := agent.NewAgent( ) defer a.Close() -eventCh, err := a.Stream(ctx, "What's 17 * 23?", "") +eventCh, err := a.Stream(ctx, "What's 17 * 23?", nil) for ev := range eventCh { if ev == nil { continue @@ -363,7 +363,7 @@ a, _ := agent.NewAgent( ) defer a.Close() -result, _ := a.Run(ctx, "What's the weather in Tokyo?", "") +result, _ := a.Run(ctx, "What's the weather in Tokyo?", nil) ``` [examples/agent_with_tools](examples/agent_with_tools) @@ -694,7 +694,7 @@ mainAgent, _ := agent.NewAgent( ) defer mainAgent.Close() -result, _ := mainAgent.Run(ctx, "What is 144 divided by 12?", "") +result, _ := mainAgent.Run(ctx, "What is 144 divided by 12?", nil) ``` [examples/agent_with_subagents](examples/agent_with_subagents) @@ -755,7 +755,7 @@ a, _ := agent.NewAgent( }), // ... ) -a.Run(ctx, prompt, "") +a.Run(ctx, prompt, nil) ``` **Stream** — approval and delegation requests are `**CUSTOM`** events (`[AgentEventTypeCustom](pkg/agent/event.go)`). Parse with `[ParseCustomEventApproval](pkg/agent/event.go)` / `[ParseCustomEventDelegation](pkg/agent/event.go)`, then call `[OnApproval](pkg/agent/approval.go)` with the token from the value field (see [examples/durable_agent/agent/main.go](examples/durable_agent/agent/main.go)): @@ -780,7 +780,7 @@ for ev := range eventCh { **RunAsync** — channel-based completion without streaming. Do not set `WithApprovalHandler` for this path (it is replaced for the duration of the run). Receive each pending approval on `approvalCh` and call `req.Respond` (same idea as `WithApprovalHandler`): ```go -resultCh, approvalCh, err := a.RunAsync(ctx, prompt, "") +resultCh, approvalCh, err := a.RunAsync(ctx, prompt, nil) if err != nil { /* validation error before goroutine started */ } go func() { @@ -825,7 +825,7 @@ a, _ := agent.NewAgent( agent.WithTimeout(5 * time.Minute), // ... ) -result, err := a.Run(context.Background(), "Hello", "") +result, err := a.Run(context.Background(), "Hello", nil) ``` **Notes:** @@ -933,7 +933,7 @@ a, _ := agent.NewAgent( agent.WithLLMClient(...), agent.DisableLocalWorker(), ) -result, _ := a.Run(ctx, "Hello", "") +result, _ := a.Run(ctx, "Hello", nil) ``` [examples/agent_with_worker](examples/agent_with_worker) · [examples/durable_agent](examples/durable_agent) @@ -947,7 +947,7 @@ result, _ := a.Run(ctx, "Hello", "") Pass `agent.WithConversation(conv)` to persist message history for multi-turn context. Use `agent.WithConversationSize(n)` to limit how many messages are fetched for LLM context (default 20). -**Conversation ID:** When the agent is configured with a conversation, pass the same `conversationID` to both `Run(ctx, prompt, conversationID)` and `Stream(ctx, prompt, conversationID)` for the same session—so history is shared across turns. +**Conversation ID:** When the agent is configured with a conversation, pass an `*agent.AgentRunOptions` with `ConversationOptions.ID` set to the same session ID on every call to `Run`, `RunAsync`, and `Stream`—so history is shared across turns. Choose implementation by deployment: @@ -973,7 +973,8 @@ a, _ := agent.NewAgent( agent.WithConversation(conv), agent.WithConversationSize(20), // optional; default 20 ) -result, _ := a.Run(ctx, "Hello", "session-1") +opts := &agent.AgentRunOptions{ConversationOptions: &agent.ConversationOptions{ID: "session-1"}} +result, _ := a.Run(ctx, "Hello", opts) // Worker process convW, _ := redis.NewRedisConversation(redis.WithAddr("localhost:6379")) @@ -994,7 +995,8 @@ a, _ := agent.NewAgent( agent.DisableLocalWorker(), agent.WithConversation(convA), ) -result, _ := a.Run(ctx, "Hello", "session-1") +opts := &agent.AgentRunOptions{ConversationOptions: &agent.ConversationOptions{ID: "session-1"}} +result, _ := a.Run(ctx, "Hello", opts) ``` **Lifecycle:** You own the conversation. Call `Clear` when ending a session or when you no longer need the history. The agent never calls `Clear`. @@ -1016,9 +1018,9 @@ a, _ := agent.NewAgent( ) defer a.Close() -convID := "session-1" -a.Run(ctx, "I'm Alice. Remember that.", convID) -a.Run(ctx, "What's my name?", convID) // agent uses history: "Alice" +opts := &agent.AgentRunOptions{ConversationOptions: &agent.ConversationOptions{ID: "session-1"}} +a.Run(ctx, "I'm Alice. Remember that.", opts) +a.Run(ctx, "What's my name?", opts) // agent uses history: "Alice" ``` [examples/agent_with_conversation](examples/agent_with_conversation) @@ -1032,7 +1034,7 @@ Events like `RUN_STARTED`, `TEXT_MESSAGE_CONTENT`, `TOOL_CALL_START`, and `REASO For a complete server + UI reference, see [examples/agent_with_agui](examples/agent_with_agui) (Go SSE server in `server/main.go`, Next.js + CopilotKit bridge in `ui/app/api/copilotkit/route.ts`). ```go -ch, err := a.Stream(ctx, prompt, conversationID) +ch, err := a.Stream(ctx, prompt, nil) // pass &agent.AgentRunOptions{ConversationOptions: &agent.ConversationOptions{ID: id}} when using conversation if err != nil { return err } @@ -1173,7 +1175,7 @@ A Temporal connection (`WithTemporalConfig` or `WithTemporalClient`) is **option - **WithTemporalClient**: Pre-configured Temporal client. Use for TLS, API key auth, Temporal Cloud. Requires `WithTaskQueue`. Agent does not close the client. - **WithTaskQueue**: Task queue name. Required when using `WithTemporalClient`. Ignored when using `WithTemporalConfig`. - **WithResponseFormat**: LLM response format. Omit for text-only. Use `&interfaces.ResponseFormat{Type, Name, Schema}` for JSON with schema. See [Response format](#response-format). -- **WithConversation**: Message history store. Use `inmem` for single process; `redis` for remote workers. Pass same `conversationID` to `Run` and `Stream` for a session. See [Conversation](#conversation-message-history). +- **WithConversation**: Message history store. Use `inmem` for single process; `redis` for remote workers. Pass the conversation ID via `AgentRunOptions` to `Run`, `RunAsync`, and `Stream` to share history across turns. See [Conversation](#conversation-message-history). - **WithConversationSize**: Max messages to fetch for LLM context (default 20). Only applies when `WithConversation` is set. - **EnableRemoteWorkers**: Pass `EnableRemoteWorkers()` when using `DisableLocalWorker` with approval or streaming (starts the event worker/workflow path). - **WithSubAgents**: Attach specialist agents the main agent can delegate to. Each needs its own task queue and worker. See [Sub-agents](#sub-agents). diff --git a/benchmarks/runner.go b/benchmarks/runner.go index ad24d86..2cdbe76 100644 --- a/benchmarks/runner.go +++ b/benchmarks/runner.go @@ -91,7 +91,7 @@ func runBenchmark(ctx context.Context, cfg *setup.Config, llm *setup.MockLLMClie func executeRun(ctx context.Context, a *agent.Agent, rng *rand.Rand) runOutcome { start := time.Now() - _, err := a.Run(ctx, setup.RandomUserPrompt(rng), "") + _, err := a.Run(ctx, setup.RandomUserPrompt(rng), nil) return runOutcome{ latencyMs: float64(time.Since(start).Milliseconds()), success: err == nil, diff --git a/cmd/main.go b/cmd/main.go index 4fc40c3..b231bce 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -125,7 +125,12 @@ func main() { break } - eventCh, err := a.Stream(context.Background(), line, convID) + opts := &agent.AgentRunOptions{ + ConversationOptions: &agent.ConversationOptions{ + ID: convID, + }, + } + eventCh, err := a.Stream(context.Background(), line, opts) if err != nil { log.Printf("agent error: %v", err) continue diff --git a/examples/README.md b/examples/README.md index 3ca4ffc..a0ed25c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -69,7 +69,7 @@ EOF Override **`LLM_PROVIDER`** / **`LLM_MODEL`**, **`MCP_TRANSPORT=streamable_http`** + **`MCP_STREAMABLE_HTTP_URL`**, a remote **`A2A_URL`**, or retriever vars when not using the default local stack. Process environment (export / root **`Taskfile.yml`** `dotenv`) wins over both files. See [env vars](#env-vars) and **`examples/.env.defaults`**. -**Task** — not installed by default; install via **[Task installation](https://taskfile.dev/installation/)** (platform-specific). Not needed for **`go run ./`** when the overview table has no infra. Compose infra also needs **Docker**. From **`examples/`**: **`task infra:status`**, **`infra:deps:up`** / **`down`**, **`infra:*:up`** / **`down`**. From **repo root**: **`task examples:local`**, **`task examples:temporal`**. **`task --dry`** only prints commands (no report file). To preview the report layout without running examples or infra, use **`task examples:local:plan`**, **`task examples:temporal:plan`**, or **`task examples:all:plan`**. +**Task** — not installed by default; install via **[Task installation](https://taskfile.dev/installation/)** (platform-specific). Not needed for **`go run ./`** when the overview table has no infra. Compose infra also needs **Docker**. From **`examples/`**: **`task infra:status`**, **`infra:deps:up`** / **`down`**, **`infra:*:up`** / **`down`**. From **repo root**: **`task examples:local`**, **`task examples:temporal`**, **`task examples:all`**. Contributors: run **`task examples:all`** before any PR to catch regressions across local and Temporal runtimes. New examples that can run non-interactively (one-shot, no REPL, no separate worker) should be listed in **`taskfiles/examples.yml`** (`EXAMPLES`, `EXAMPLES_WITH_PROMPTS`, or `EXAMPLES_TEMPORAL` as appropriate). **`task --dry`** only prints commands (no report file). To preview the report layout without running examples or infra, use **`task examples:local:plan`**, **`task examples:temporal:plan`**, or **`task examples:all:plan`**. ## Run examples diff --git a/examples/agent_with_a2a_client/main.go b/examples/agent_with_a2a_client/main.go index f8782d8..3a57ab8 100644 --- a/examples/agent_with_a2a_client/main.go +++ b/examples/agent_with_a2a_client/main.go @@ -73,7 +73,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_a2a_config/main.go b/examples/agent_with_a2a_config/main.go index e4a3865..409093f 100644 --- a/examples/agent_with_a2a_config/main.go +++ b/examples/agent_with_a2a_config/main.go @@ -48,7 +48,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_agui/server/main.go b/examples/agent_with_agui/server/main.go index ee2255c..6beb785 100644 --- a/examples/agent_with_agui/server/main.go +++ b/examples/agent_with_agui/server/main.go @@ -125,7 +125,7 @@ func streamHandler(a *agent.Agent) http.HandlerFunc { w.Header().Set("Connection", "keep-alive") ctx := r.Context() - ch, err := a.Stream(ctx, prompt, "") + ch, err := a.Stream(ctx, prompt, nil) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/examples/agent_with_conversation/main.go b/examples/agent_with_conversation/main.go index 384a6cb..13c87b7 100644 --- a/examples/agent_with_conversation/main.go +++ b/examples/agent_with_conversation/main.go @@ -68,7 +68,12 @@ func main() { func runSingleTurn(ctx context.Context, a *agent.Agent, prompt, convID string) { fmt.Println("user:", prompt) - result, err := a.Run(ctx, prompt, convID) + opts := &agent.AgentRunOptions{ + ConversationOptions: &agent.ConversationOptions{ + ID: convID, + }, + } + result, err := a.Run(ctx, prompt, opts) if err != nil { log.Printf("run failed: %v", err) return @@ -91,7 +96,12 @@ func runInteractive(ctx context.Context, a *agent.Agent, convID string) { if prompt == "exit" || prompt == "quit" || prompt == "bye" { break } - result, err := a.Run(ctx, prompt, convID) + opts := &agent.AgentRunOptions{ + ConversationOptions: &agent.ConversationOptions{ + ID: convID, + }, + } + result, err := a.Run(ctx, prompt, opts) if err != nil { log.Printf("run failed: %v", err) continue diff --git a/examples/agent_with_json_response/main.go b/examples/agent_with_json_response/main.go index 58e41ea..6226026 100644 --- a/examples/agent_with_json_response/main.go +++ b/examples/agent_with_json_response/main.go @@ -65,7 +65,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Fatalf("run failed: %v", err) } diff --git a/examples/agent_with_mcp_client/main.go b/examples/agent_with_mcp_client/main.go index 6f4f48d..8087c02 100644 --- a/examples/agent_with_mcp_client/main.go +++ b/examples/agent_with_mcp_client/main.go @@ -68,7 +68,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_mcp_config/main.go b/examples/agent_with_mcp_config/main.go index 2d540e9..fa53816 100644 --- a/examples/agent_with_mcp_config/main.go +++ b/examples/agent_with_mcp_config/main.go @@ -64,7 +64,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_observability/config/main.go b/examples/agent_with_observability/config/main.go index 9f6f6f7..e88a6f2 100644 --- a/examples/agent_with_observability/config/main.go +++ b/examples/agent_with_observability/config/main.go @@ -44,7 +44,7 @@ func main() { prompt := setup.UserPrompt() fmt.Printf("entry=config (WithObservabilityConfig: OTLP traces, metrics, logs)\nuser: %s\n", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_observability/objects/main.go b/examples/agent_with_observability/objects/main.go index 017a20e..70f0d64 100644 --- a/examples/agent_with_observability/objects/main.go +++ b/examples/agent_with_observability/objects/main.go @@ -86,7 +86,7 @@ func main() { prompt := setup.UserPrompt() fmt.Printf("entry=objects (WithTracer / WithMetrics / WithLogs; default logger bridged to OTLP)\nuser: %s\n", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_reasoning/main.go b/examples/agent_with_reasoning/main.go index dc4cfff..1a8da25 100644 --- a/examples/agent_with_reasoning/main.go +++ b/examples/agent_with_reasoning/main.go @@ -66,7 +66,7 @@ func main() { fmt.Println("user:", prompt) fmt.Println("--- stream (REASONING_MESSAGE_CONTENT may appear before assistant text) ---") - eventCh, err := a.Stream(context.Background(), prompt, "") + eventCh, err := a.Stream(context.Background(), prompt, nil) if err != nil { log.Fatalf("Stream: %v", err) } diff --git a/examples/agent_with_retriever/pgvector/main.go b/examples/agent_with_retriever/pgvector/main.go index 86d39d6..107dc9c 100644 --- a/examples/agent_with_retriever/pgvector/main.go +++ b/examples/agent_with_retriever/pgvector/main.go @@ -81,7 +81,7 @@ func main() { fmt.Printf("hint: %s\n", common.ModeHint(retrieverCfg.RetrieverMode)) fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_retriever/weaviate/main.go b/examples/agent_with_retriever/weaviate/main.go index 614766c..fb89da3 100644 --- a/examples/agent_with_retriever/weaviate/main.go +++ b/examples/agent_with_retriever/weaviate/main.go @@ -71,7 +71,7 @@ func main() { fmt.Printf("hint: %s\n", common.ModeHint(retrieverCfg.RetrieverMode)) fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_run_async/main.go b/examples/agent_with_run_async/main.go index e527fa1..49170b5 100644 --- a/examples/agent_with_run_async/main.go +++ b/examples/agent_with_run_async/main.go @@ -63,7 +63,7 @@ func main() { } ctx := context.Background() - resultCh, approvalCh, err := a.RunAsync(ctx, prompt, "") + resultCh, approvalCh, err := a.RunAsync(ctx, prompt, nil) if err != nil { log.Fatalf("RunAsync: %v", err) } diff --git a/examples/agent_with_stream/main.go b/examples/agent_with_stream/main.go index 01b0fc2..11d2baf 100644 --- a/examples/agent_with_stream/main.go +++ b/examples/agent_with_stream/main.go @@ -64,7 +64,7 @@ func main() { fmt.Println("user:", prompt) - eventCh, err := a.Stream(context.Background(), prompt, "") + eventCh, err := a.Stream(context.Background(), prompt, nil) if err != nil { log.Printf("Stream failed: %v", err) return diff --git a/examples/agent_with_stream_conversation/main.go b/examples/agent_with_stream_conversation/main.go index 6fc6d48..ecbe875 100644 --- a/examples/agent_with_stream_conversation/main.go +++ b/examples/agent_with_stream_conversation/main.go @@ -70,7 +70,12 @@ func main() { func runSingleTurn(ctx context.Context, a *agent.Agent, prompt, convID string) { fmt.Println("user:", prompt) fmt.Println("assistant:") - eventCh, err := a.Stream(ctx, prompt, convID) + opts := &agent.AgentRunOptions{ + ConversationOptions: &agent.ConversationOptions{ + ID: convID, + }, + } + eventCh, err := a.Stream(ctx, prompt, opts) if err != nil { log.Printf("Stream failed: %v", err) return @@ -94,7 +99,12 @@ func runInteractive(ctx context.Context, a *agent.Agent, convID string) { if prompt == "exit" || prompt == "quit" || prompt == "bye" { break } - eventCh, err := a.Stream(ctx, prompt, convID) + opts := &agent.AgentRunOptions{ + ConversationOptions: &agent.ConversationOptions{ + ID: convID, + }, + } + eventCh, err := a.Stream(ctx, prompt, opts) if err != nil { log.Printf("Stream failed: %v", err) continue diff --git a/examples/agent_with_subagents/main.go b/examples/agent_with_subagents/main.go index 595b189..d490b78 100644 --- a/examples/agent_with_subagents/main.go +++ b/examples/agent_with_subagents/main.go @@ -111,7 +111,7 @@ func main() { fmt.Println("All approvals (main agent delegation + sub-agent calculator) are handled here.") fmt.Println() - eventCh, err := mainAgent.Stream(context.Background(), prompt, "") + eventCh, err := mainAgent.Stream(context.Background(), prompt, nil) if err != nil { log.Fatalf("run stream failed: %v", err) } diff --git a/examples/agent_with_temporal_client/main.go b/examples/agent_with_temporal_client/main.go index db7df6f..b5c9fb5 100644 --- a/examples/agent_with_temporal_client/main.go +++ b/examples/agent_with_temporal_client/main.go @@ -59,7 +59,7 @@ func main() { prompt = "Hello, what can you do?" } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("agent run failed: %v", err) return diff --git a/examples/agent_with_tools/approval/main.go b/examples/agent_with_tools/approval/main.go index 5929d66..352d7c3 100644 --- a/examples/agent_with_tools/approval/main.go +++ b/examples/agent_with_tools/approval/main.go @@ -61,7 +61,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_tools/authorizer/main.go b/examples/agent_with_tools/authorizer/main.go index ebe6723..4cc27c8 100644 --- a/examples/agent_with_tools/authorizer/main.go +++ b/examples/agent_with_tools/authorizer/main.go @@ -46,7 +46,7 @@ func main() { fmt.Println("user:", prompt) fmt.Println("tip: set ALLOW_PROTECTED_NOTE=1 to authorize the tool") - eventCh, err := a.Stream(context.Background(), prompt, "") + eventCh, err := a.Stream(context.Background(), prompt, nil) if err != nil { log.Printf("stream failed: %v", err) return diff --git a/examples/agent_with_tools/basic/main.go b/examples/agent_with_tools/basic/main.go index 090795b..c898717 100644 --- a/examples/agent_with_tools/basic/main.go +++ b/examples/agent_with_tools/basic/main.go @@ -59,7 +59,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_tools/custom/main.go b/examples/agent_with_tools/custom/main.go index 6d5c722..9a14dd1 100644 --- a/examples/agent_with_tools/custom/main.go +++ b/examples/agent_with_tools/custom/main.go @@ -44,7 +44,7 @@ func main() { } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("run failed: %v", err) return diff --git a/examples/agent_with_worker/agent/main.go b/examples/agent_with_worker/agent/main.go index 7e0afb0..884ba35 100644 --- a/examples/agent_with_worker/agent/main.go +++ b/examples/agent_with_worker/agent/main.go @@ -89,7 +89,7 @@ func main() { } func runStream(ctx context.Context, a *agent.Agent, scanner *bufio.Scanner, prompt string) { - eventCh, err := a.Stream(ctx, prompt, "") + eventCh, err := a.Stream(ctx, prompt, nil) if err != nil { fmt.Printf("[error] failed to start stream: %v\n\n", err) return diff --git a/examples/durable_agent/agent/main.go b/examples/durable_agent/agent/main.go index a7ae23d..a4cf4ee 100644 --- a/examples/durable_agent/agent/main.go +++ b/examples/durable_agent/agent/main.go @@ -99,7 +99,7 @@ func main() { } func runStream(ctx context.Context, a *agent.Agent, scanner *bufio.Scanner, prompt string) { - eventCh, err := a.Stream(ctx, prompt, "") + eventCh, err := a.Stream(ctx, prompt, nil) if err != nil { fmt.Printf("[error] failed to start stream: %v\n\n", err) return diff --git a/examples/multiple_agents/main.go b/examples/multiple_agents/main.go index b7588ae..452369f 100644 --- a/examples/multiple_agents/main.go +++ b/examples/multiple_agents/main.go @@ -59,7 +59,7 @@ func main() { runAgent := func(name string, a *agent.Agent, p string) { fmt.Printf("\n--- %s ---\n", name) - result, err := a.Run(context.Background(), p, "") + result, err := a.Run(context.Background(), p, nil) if err != nil { fmt.Printf("%s error: %v\n", name, err) return diff --git a/examples/simple_agent/main.go b/examples/simple_agent/main.go index a7aa74e..2268276 100644 --- a/examples/simple_agent/main.go +++ b/examples/simple_agent/main.go @@ -39,7 +39,7 @@ func main() { prompt = "Hi" } fmt.Println("user:", prompt) - result, err := a.Run(context.Background(), prompt, "") + result, err := a.Run(context.Background(), prompt, nil) if err != nil { log.Printf("agent foreground run failed: %v", err) return diff --git a/internal/runtime/base/utils.go b/internal/runtime/base/utils.go index 86a2abe..c331833 100644 --- a/internal/runtime/base/utils.go +++ b/internal/runtime/base/utils.go @@ -91,3 +91,10 @@ func ApplyLLMSampling(s *types.LLMSampling, req *interfaces.LLMRequest) { req.Reasoning = &r } } + +func GetConversationID(req *runtime.ExecuteRequest) string { + if req != nil && req.RunOptions != nil && req.RunOptions.ConversationOptions != nil { + return req.RunOptions.ConversationOptions.ID + } + return "" +} diff --git a/internal/runtime/base/utils_test.go b/internal/runtime/base/utils_test.go index f3eced1..6c173cf 100644 --- a/internal/runtime/base/utils_test.go +++ b/internal/runtime/base/utils_test.go @@ -3,6 +3,7 @@ package base import ( "testing" + "github.com/agenticenv/agent-sdk-go/internal/runtime" "github.com/agenticenv/agent-sdk-go/internal/types" "github.com/agenticenv/agent-sdk-go/pkg/interfaces" "github.com/golang/mock/gomock" @@ -145,3 +146,26 @@ func TestApplyLLMSampling_MaxTokensZeroNotApplied(t *testing.T) { ApplyLLMSampling(&types.LLMSampling{MaxTokens: 0}, req) require.Equal(t, 100, req.MaxTokens) // unchanged when zero } + +// --- GetConversationID --- + +func TestGetConversationID(t *testing.T) { + t.Run("nil request", func(t *testing.T) { + require.Equal(t, "", GetConversationID(nil)) + }) + t.Run("nil RunOptions", func(t *testing.T) { + require.Equal(t, "", GetConversationID(&runtime.ExecuteRequest{})) + }) + t.Run("nil ConversationOptions", func(t *testing.T) { + req := &runtime.ExecuteRequest{RunOptions: &types.AgentRunOptions{}} + require.Equal(t, "", GetConversationID(req)) + }) + t.Run("returns ID", func(t *testing.T) { + req := &runtime.ExecuteRequest{ + RunOptions: &types.AgentRunOptions{ + ConversationOptions: &types.ConversationOptions{ID: "session-1"}, + }, + } + require.Equal(t, "session-1", GetConversationID(req)) + }) +} diff --git a/internal/runtime/local/runtime.go b/internal/runtime/local/runtime.go index 444d5c8..4fd7788 100644 --- a/internal/runtime/local/runtime.go +++ b/internal/runtime/local/runtime.go @@ -116,10 +116,12 @@ func (rt *LocalRuntime) Execute(ctx context.Context, req *sdkruntime.ExecuteRequ } } + conversationID := base.GetConversationID(req) runID := uuid.New().String() + loopResult, err := rt.RunAgentLoop(runCtx, AgentLoopInput{ UserPrompt: req.UserPrompt, - ConversationID: req.ConversationID, + ConversationID: conversationID, StreamingEnabled: false, ChannelName: "", ApprovalHandler: req.ApprovalHandler, @@ -150,8 +152,10 @@ func (rt *LocalRuntime) ExecuteStream(ctx context.Context, req *sdkruntime.Execu slog.String("agent", agentName), slog.Int("inputLen", len(req.UserPrompt))) + conversationID := base.GetConversationID(req) runID := uuid.New().String() - threadID := req.ConversationID + + threadID := conversationID if threadID == "" { threadID = runID } @@ -201,7 +205,7 @@ func (rt *LocalRuntime) ExecuteStream(ctx context.Context, req *sdkruntime.Execu result, loopErr := rt.RunAgentLoop(runCtx, AgentLoopInput{ UserPrompt: req.UserPrompt, - ConversationID: req.ConversationID, + ConversationID: conversationID, StreamingEnabled: req.StreamingEnabled, ChannelName: channel, ApprovalHandler: req.ApprovalHandler, diff --git a/internal/runtime/local/runtime_test.go b/internal/runtime/local/runtime_test.go index f8cadd4..d181a71 100644 --- a/internal/runtime/local/runtime_test.go +++ b/internal/runtime/local/runtime_test.go @@ -646,8 +646,12 @@ func TestExecute_PersistsConversationMessages(t *testing.T) { require.NoError(t, err) _, err = rt.Execute(context.Background(), &sdkruntime.ExecuteRequest{ - UserPrompt: "remember this", - ConversationID: "conv-1", + UserPrompt: "remember this", + RunOptions: &types.AgentRunOptions{ + ConversationOptions: &types.ConversationOptions{ + ID: "conv-1", + }, + }, }) require.NoError(t, err) } diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 000487a..21e16d4 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -147,18 +147,21 @@ type AgentLimits struct { // can read identity (including agent name on AgentSpec.Name), prompts, LLM, tools, and policies // for this run. Implementations may ignore fields they do not use. type ExecuteRequest struct { - UserPrompt string - ConversationID string - StreamingEnabled bool + UserPrompt string `json:"user_prompt"` + // RunOptions is the per-call options forwarded from pkg/agent (e.g. conversation session). May be nil. + // Runtimes must use [base.GetConversationID] to safely extract the conversation ID rather than + // accessing the nested fields directly, so the nil-check is centralised. + RunOptions *types.AgentRunOptions `json:"run_options,omitempty"` + StreamingEnabled bool `json:"streaming_enabled"` // EventTypes filters streamed events; empty means default (implementation-defined, often all types). - EventTypes []events.AgentEventType - SubAgents []*SubAgentSpec - MaxSubAgentDepth int + EventTypes []events.AgentEventType `json:"event_types,omitempty"` + SubAgents []*SubAgentSpec `json:"sub_agents,omitempty"` + MaxSubAgentDepth int `json:"max_sub_agent_depth"` - ApprovalHandler types.ApprovalHandler + ApprovalHandler types.ApprovalHandler `json:"approval_handler"` // AgentSpec is identity and output-format metadata for this run (name, description, system prompt, response format). - AgentSpec *AgentSpec + AgentSpec *AgentSpec `json:"agent_spec"` // AgentExecution is LLM, tools, conversation, sampling, and policy for this run. - AgentExecution *AgentExecution + AgentExecution *AgentExecution `json:"agent_execution"` } diff --git a/internal/runtime/temporal/runtime.go b/internal/runtime/temporal/runtime.go index a250cd3..7f08454 100644 --- a/internal/runtime/temporal/runtime.go +++ b/internal/runtime/temporal/runtime.go @@ -279,9 +279,11 @@ func (rt *TemporalRuntime) Execute(ctx context.Context, req *runtime.ExecuteRequ defer cancel() } + conversationID := base.GetConversationID(req) runID := uuid.New().String() - threadID := req.ConversationID - if threadID != "" { + + threadID := conversationID + if threadID == "" { threadID = rt.instanceId if threadID == "" { threadID = runID @@ -302,7 +304,7 @@ func (rt *TemporalRuntime) Execute(ctx context.Context, req *runtime.ExecuteRequ StreamingEnabled: false, EventWorkflowID: "", LocalChannelName: eventChannelName(workflowID), - ConversationID: req.ConversationID, + ConversationID: conversationID, AgentFingerprint: rt.agentFingerprint, EventTypes: []events.AgentEventType{}, SubAgentDepth: 0, @@ -438,8 +440,10 @@ func (rt *TemporalRuntime) Execute(ctx context.Context, req *runtime.ExecuteRequ func (rt *TemporalRuntime) ExecuteStream(ctx context.Context, req *runtime.ExecuteRequest) (<-chan events.AgentEvent, error) { rt.logger.Debug(ctx, "runtime stream run dispatch", slog.String("scope", "runtime"), slog.String("agent", agentNameFromExecuteRequest(req)), slog.Int("inputLen", len(req.UserPrompt))) + conversationID := base.GetConversationID(req) runID := uuid.New().String() - threadID := req.ConversationID + + threadID := conversationID if threadID == "" { threadID = rt.instanceId if threadID == "" { @@ -484,7 +488,7 @@ func (rt *TemporalRuntime) ExecuteStream(ctx context.Context, req *runtime.Execu EventTaskQueue: eventTaskQueue, LocalChannelName: eventChannelName(workflowID), StreamingEnabled: req.StreamingEnabled, - ConversationID: req.ConversationID, + ConversationID: conversationID, AgentFingerprint: rt.agentFingerprint, EventTypes: streamEventTypes, SubAgentDepth: 0, diff --git a/internal/types/agent.go b/internal/types/agent.go index 1b695ae..384df0a 100644 --- a/internal/types/agent.go +++ b/internal/types/agent.go @@ -1,5 +1,22 @@ package types +// AgentRunOptions holds per-call options passed to [Agent.Run], [Agent.RunAsync], and [Agent.Stream]. +// A nil pointer is valid and means no options (no conversation, default behaviour). +// Add new per-call knobs here as nested option structs; keep agent-level settings on [agentConfig]. +type AgentRunOptions struct { + // ConversationOptions selects a conversation session for this call. + // Required when the agent was configured with WithConversation; must be nil otherwise. + ConversationOptions *ConversationOptions `json:"conversation_options,omitempty"` +} + +// ConversationOptions identifies a conversation session for one call. +// ID must be a non-empty, stable string that is the same across all turns of a session +// (e.g. a user or chat ID). The agent loads history for this ID before the LLM call +// and persists the new messages after it completes. +type ConversationOptions struct { + ID string +} + // AgentRunResult is the structured result of a completed run (content, model, metadata). type AgentRunResult struct { Content string `json:"content"` diff --git a/pkg/agent/a2a_server.go b/pkg/agent/a2a_server.go index f756c12..ea20eba 100644 --- a/pkg/agent/a2a_server.go +++ b/pkg/agent/a2a_server.go @@ -451,7 +451,7 @@ func (e *agentA2AExecutor) Execute(ctx context.Context, execCtx *a2asrv.Executor if !streaming { // Non-streaming: one blocking Run, one Message reply. - result, err := e.agent.Run(ctx, inputText, "") + result, err := e.agent.Run(ctx, inputText, nil) if err != nil { sp.RecordError(err) errMsg := a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart(err.Error())) @@ -470,7 +470,7 @@ func (e *agentA2AExecutor) Execute(ctx context.Context, execCtx *a2asrv.Executor return } - streamCh, err := e.agent.Stream(ctx, inputText, "") + streamCh, err := e.agent.Stream(ctx, inputText, nil) if err != nil { sp.RecordError(err) errMsg := a2a.NewMessage(a2a.MessageRoleAgent, a2a.NewTextPart(err.Error())) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 76d5c02..b69dacb 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -28,6 +28,12 @@ type Agent struct { // ErrAgentAlreadyRunning is returned when Run, RunAsync, or Stream is called while a run is already in progress. var ErrAgentAlreadyRunning = errors.New("agent already has an active run") +// AgentRunOptions is the options to use runtime execution +type AgentRunOptions = types.AgentRunOptions + +// ConversationOptions is the options to use for the conversation +type ConversationOptions = types.ConversationOptions + // AgentRunResult is the structured result of [Agent.Run] and [Agent.RunAsync] ([RunAsyncResult.Result]). type AgentRunResult = types.AgentRunResult @@ -142,9 +148,11 @@ func (a *Agent) Close() { // Run starts one execution and returns the result. Use [WithApprovalHandler] when tools require approval for Run (handler uses req.Respond); [Stream] uses approval events and [Agent.OnApproval]. // Use [WithTimeout] or a context with deadline to avoid blocking. // When using [WithConversation], pass the conversation ID; agent and worker must use the same ID. -func (a *Agent) Run(ctx context.Context, input string, conversationID string) (*AgentRunResult, error) { +func (a *Agent) Run(ctx context.Context, input string, opts *AgentRunOptions) (*AgentRunResult, error) { a.logger.Debug(ctx, "agent run started", slog.String("scope", "agent"), slog.String("name", a.Name), slog.Int("inputLen", len(input))) + conversationID := conversationIDFromOpts(opts) + start := time.Now() ctx, sp := a.tracer.StartSpan(ctx, "agent.run", interfaces.Attribute{Key: "agent.name", Value: a.Name}, @@ -169,7 +177,7 @@ func (a *Agent) Run(ctx context.Context, input string, conversationID string) (* return nil, err } - req := a.executeRequest(input, conversationID, false) + req := a.executeRequest(input, opts, false) result, err := a.runtime.Execute(ctx, req) if err != nil { @@ -191,9 +199,11 @@ func (a *Agent) Run(ctx context.Context, input string, conversationID string) (* // // WithApprovalHandler is temporarily replaced for the duration of the run; restore happens when the run finishes. // If tools do not require approval, approvalCh is still closed immediately with no values. -func (a *Agent) RunAsync(ctx context.Context, input string, conversationID string) (resultCh <-chan AgentRunAsyncResult, approvalCh <-chan *ApprovalRequest, err error) { +func (a *Agent) RunAsync(ctx context.Context, input string, opts *AgentRunOptions) (resultCh <-chan AgentRunAsyncResult, approvalCh <-chan *ApprovalRequest, err error) { a.logger.Debug(ctx, "agent run async started", slog.String("scope", "agent"), slog.String("name", a.Name), slog.Int("inputLen", len(input))) + conversationID := conversationIDFromOpts(opts) + if err := a.validateConversationID(conversationID); err != nil { return nil, nil, err } @@ -224,7 +234,7 @@ func (a *Agent) RunAsync(ctx context.Context, input string, conversationID strin defer func() { a.approvalHandler = saved }() } - resp, runErr := a.Run(ctx, input, conversationID) + resp, runErr := a.Run(ctx, input, opts) if runErr != nil { resCh <- AgentRunAsyncResult{Error: runErr} return @@ -253,9 +263,11 @@ func copyApprovalArgs(src map[string]any) map[string]any { // For approvals (tool or delegation), receive [AgentEventTypeCustom] ([AgentCustomEvent]), parse with // [ParseCustomEventApproval] / [ParseCustomEventDelegation], then call [Agent.OnApproval] with the token from Value. // When using [WithConversation], pass the conversation ID. -func (a *Agent) Stream(ctx context.Context, input string, conversationID string) (<-chan events.AgentEvent, error) { +func (a *Agent) Stream(ctx context.Context, input string, opts *AgentRunOptions) (<-chan events.AgentEvent, error) { a.logger.Debug(ctx, "agent run stream started", slog.String("scope", "agent"), slog.String("name", a.Name), slog.Int("inputLen", len(input))) + conversationID := conversationIDFromOpts(opts) + start := time.Now() ctx, sp := a.tracer.StartSpan(ctx, "agent.stream", interfaces.Attribute{Key: "agent.name", Value: a.Name}, @@ -272,7 +284,7 @@ func (a *Agent) Stream(ctx context.Context, input string, conversationID string) return nil, err } - req := a.executeRequest(input, conversationID, true) + req := a.executeRequest(input, opts, true) streamCh, err := a.runtime.ExecuteStream(ctx, req) if err != nil { @@ -286,6 +298,13 @@ func (a *Agent) Stream(ctx context.Context, input string, conversationID string) return streamCh, nil } +func conversationIDFromOpts(opts *AgentRunOptions) string { + if opts != nil && opts.ConversationOptions != nil { + return opts.ConversationOptions.ID + } + return "" +} + func (a *Agent) validateConversationID(conversationID string) error { if conversationID != "" && a.conversation == nil { return fmt.Errorf("conversationID %s requires conversation configuration", conversationID) @@ -297,10 +316,10 @@ func (a *Agent) validateConversationID(conversationID string) error { } // executeRequest builds [runtime.ExecuteRequest] with per-run fields plus AgentSpec and AgentExecution for custom Runtime implementations. -func (a *Agent) executeRequest(userPrompt, conversationID string, streaming bool) *runtime.ExecuteRequest { +func (a *Agent) executeRequest(userPrompt string, opts *AgentRunOptions, streaming bool) *runtime.ExecuteRequest { return &runtime.ExecuteRequest{ UserPrompt: userPrompt, - ConversationID: conversationID, + RunOptions: opts, StreamingEnabled: streaming, SubAgents: a.buildSubAgentSpecs(), MaxSubAgentDepth: a.maxSubAgentDepth, diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 0a7cc59..0154cd5 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -51,7 +51,7 @@ func TestAgent_Run_ForwardsRequestAndReturnsResponse(t *testing.T) { }) a := testAgentWithRuntime(mockRT) - resp, err := a.Run(context.Background(), "hello", "") + resp, err := a.Run(context.Background(), "hello", nil) if err != nil { t.Fatal(err) } @@ -79,7 +79,7 @@ func TestAgent_Stream_SetsStreamingEnabled(t *testing.T) { }) a := testAgentWithRuntime(mockRT) - ch, err := a.Stream(context.Background(), "prompt", "") + ch, err := a.Stream(context.Background(), "prompt", nil) if err != nil { t.Fatal(err) } @@ -123,7 +123,7 @@ func TestAgent_RunAsync_DeliversResult(t *testing.T) { mockRT.EXPECT().Execute(gomock.Any(), gomock.Any()).Return(&types.AgentRunResult{Content: "mock", AgentName: "TestAgent", Model: "stub"}, nil) a := testAgentWithRuntime(mockRT) - resCh, apprCh, err := a.RunAsync(context.Background(), "async", "") + resCh, apprCh, err := a.RunAsync(context.Background(), "async", nil) if err != nil { t.Fatal(err) } @@ -155,7 +155,7 @@ func TestAgent_Stream_CustomStreamFn(t *testing.T) { }) a := testAgentWithRuntime(mockRT) - ch, err := a.Stream(context.Background(), "x", "") + ch, err := a.Stream(context.Background(), "x", nil) if err != nil { t.Fatal(err) } @@ -204,6 +204,52 @@ func TestCopyApprovalArgs(t *testing.T) { } } +func TestConversationIDFromOpts(t *testing.T) { + if got := conversationIDFromOpts(nil); got != "" { + t.Errorf("nil opts: got %q", got) + } + if got := conversationIDFromOpts(&AgentRunOptions{}); got != "" { + t.Errorf("nil ConversationOptions: got %q", got) + } + opts := &AgentRunOptions{ConversationOptions: &ConversationOptions{ID: "session-1"}} + if got := conversationIDFromOpts(opts); got != "session-1" { + t.Errorf("got %q, want session-1", got) + } +} + +func TestAgent_Run_ForwardsRunOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockRT := rtmocks.NewMockRuntime(ctrl) + + opts := &AgentRunOptions{ConversationOptions: &ConversationOptions{ID: "conv-1"}} + mockRT.EXPECT().Execute(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, req *runtime.ExecuteRequest) (*types.AgentRunResult, error) { + if req.RunOptions == nil || req.RunOptions.ConversationOptions == nil { + t.Fatal("Run must forward RunOptions with ConversationOptions") + } + if req.RunOptions.ConversationOptions.ID != "conv-1" { + t.Errorf("ConversationOptions.ID = %q", req.RunOptions.ConversationOptions.ID) + } + return &types.AgentRunResult{Content: "ok"}, nil + }) + + a := testAgentWithRuntime(mockRT) + a.conversation = &mockConversation{} + _, err := a.Run(context.Background(), "hello", opts) + if err != nil { + t.Fatal(err) + } +} + +func TestAgent_Stream_RejectsMissingConversationID(t *testing.T) { + a := testAgentWithRuntime(&stubRuntime{}) + a.conversation = &mockConversation{} + _, err := a.Stream(context.Background(), "prompt", nil) + if err == nil { + t.Fatal("expected error when conversation configured but opts nil") + } +} + func TestAgent_ValidateConversationID(t *testing.T) { l := logger.DefaultLogger("error") a := &Agent{agentConfig: agentConfig{logger: l}} @@ -349,7 +395,7 @@ func TestAgent_Run_RequiresApprovalHandlerWhenToolsNeedApproval(t *testing.T) { }, runtime: mockRT, } - _, err := a.Run(context.Background(), "hi", "") + _, err := a.Run(context.Background(), "hi", nil) if err == nil || !strings.Contains(err.Error(), "WithApprovalHandler") { t.Fatalf("got %v", err) }