From 30affd79511d9af1c7b7f7409728a885d0287a71 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 22:51:53 -0700 Subject: [PATCH 1/8] Revert "Merge pull request #31 from superdurable/codex/step-options-release-simplification" This reverts commit 49c8c0c914fd9e457da8e0cc31199ed7b406b342, reversing changes made to 6bd7203390587b6ed35e178d155bfd38dd2afcba. --- .github/workflows/ci.yml | 2 +- .github/workflows/dex-release-upgrade.yml | 59 +++++ .github/workflows/github-release-ui.yml | 68 +++++ ARCHITECTURE.md | 8 +- CONTRIBUTING.md | 16 +- Makefile | 13 +- README.md | 12 +- agent/agent.go | 4 - dex-release.lock.json | 17 ++ docs/adr/0011-dex-owned-tool-retries.md | 5 - ...rk-and-input-consumption-reconciliation.md | 8 +- docs/adr/0014-registered-rpc-options.md | 41 --- docs/flow-model.md | 43 +--- go.mod | 3 +- go.sum | 4 +- internal/agent/client.go | 131 +++++----- internal/agent/client_test.go | 40 +-- internal/agent/flow.go | 236 ++++++++---------- internal/agent/flow_integration_test.go | 24 +- internal/agent/history_test.go | 17 ++ internal/agent/tool_recovery_test.go | 69 ----- internal/agent/types.go | 35 --- internal/mcp/config.go | 59 ----- internal/mcp/config_test.go | 55 +--- internal/mcp/registry.go | 36 +-- internal/mcp/registry_test.go | 27 +- script/check_dex_release.py | 79 ++++++ script/check_dex_versions.py | 74 ------ script/install-dexcli.sh | 8 +- .../public-api-consumer/consumer_test.go | 9 - script/update_dex_release.py | 153 ++++++++++++ script/update_dex_release_test.py | 129 ++++++++++ script/update_dex_versions.py | 117 --------- script/update_dex_versions_test.py | 98 -------- web/mcp-servers.example.yaml | 4 - web/src/App.test.tsx | 29 --- web/src/Conversation.tsx | 13 +- 37 files changed, 745 insertions(+), 1000 deletions(-) create mode 100644 .github/workflows/dex-release-upgrade.yml create mode 100644 dex-release.lock.json delete mode 100644 docs/adr/0014-registered-rpc-options.md create mode 100644 script/check_dex_release.py delete mode 100644 script/check_dex_versions.py create mode 100644 script/update_dex_release.py create mode 100644 script/update_dex_release_test.py delete mode 100644 script/update_dex_versions.py delete mode 100644 script/update_dex_versions_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b89f2e3..c6984ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: run: | set -euo pipefail dex_log="$RUNNER_TEMP/dexcli.log" - PATH="$PWD/.cache/temporal-v1.8.2:$PATH" .cache/dexcli-v0.10.0 dev \ + PATH="$PWD/.cache/temporal-v1.8.2:$PATH" .cache/dexcli-v0.9.0 dev \ -open=false \ -blob-store-dir "$RUNNER_TEMP/dex-blobs" \ -sqlite-db-filename "$RUNNER_TEMP/dex.sqlite.db" \ diff --git a/.github/workflows/dex-release-upgrade.yml b/.github/workflows/dex-release-upgrade.yml new file mode 100644 index 0000000..a1c8ef7 --- /dev/null +++ b/.github/workflows/dex-release-upgrade.yml @@ -0,0 +1,59 @@ +name: Prepare Dex release upgrade + +on: + repository_dispatch: + types: [dex-release-published] + +permissions: + contents: read + +concurrency: + group: superagent-dex-${{ github.event.client_payload.version }} + cancel-in-progress: false + +jobs: + upgrade: + if: github.event.sender.type == 'Bot' + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - name: Create repository-scoped release automation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_AUTOMATION_APP_ID }} + private-key: ${{ secrets.RELEASE_AUTOMATION_PRIVATE_KEY }} + owner: superdurable + repositories: superagent + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + - name: Update immutable Dex release pins + env: + DEX_MANIFEST_SHA256: ${{ github.event.client_payload.manifest_sha256 }} + DEX_MANIFEST_URL: ${{ github.event.client_payload.manifest_url }} + run: | + python3 script/update_dex_release.py \ + --manifest-url "${DEX_MANIFEST_URL}" \ + --manifest-sha256 "${DEX_MANIFEST_SHA256}" + GOWORK=off go mod tidy + python3 -m unittest script/update_dex_release_test.py + python3 script/check_dex_release.py + - name: Open draft upgrade pull request before compilation + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ steps.app-token.outputs.token }} + branch: automation/dex-v${{ github.event.client_payload.version }} + delete-branch: true + draft: true + commit-message: Upgrade SuperAgent to Dex ${{ github.event.client_payload.version }} + title: Upgrade SuperAgent to Dex ${{ github.event.client_payload.version }} + body: | + Automated from the immutable Dex compatibility manifest. + + Review `openFlowsCompatibility` before merging. It defaults to + `cancel-required`; automation never claims open-Flow compatibility. + + Normal pull-request CI owns compilation and tests. When a Dex SDK + API breaks, continue the required migration in this draft PR. diff --git a/.github/workflows/github-release-ui.yml b/.github/workflows/github-release-ui.yml index 6f6cdef..1785310 100644 --- a/.github/workflows/github-release-ui.yml +++ b/.github/workflows/github-release-ui.yml @@ -106,3 +106,71 @@ jobs: install_url="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}/${asset_name}" echo "Install with: npm install ${install_url} react" >> "${GITHUB_STEP_SUMMARY}" + + notify-downstreams: + name: Request IaC and SuperVerse upgrades + if: github.event_name == 'release' + needs: attach + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + - name: Validate release compatibility declaration + id: release + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + python3 script/check_dex_release.py + python3 - "${RELEASE_TAG}" "${GITHUB_OUTPUT}" <<'PY' + import json + from pathlib import Path + import re + import subprocess + import sys + + tag, output_path = sys.argv[1:] + if re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag) is None: + raise SystemExit("SuperAgent release tag is not stable semver") + lock = json.loads(Path("dex-release.lock.json").read_text(encoding="utf-8")) + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], check=True, capture_output=True, text=True + ).stdout.strip() + with Path(output_path).open("a", encoding="utf-8") as output: + for name, value in { + "dex_version": lock["release"], + "manifest_url": lock["manifest"]["url"], + "manifest_sha256": lock["manifest"]["sha256"], + "open_flows_compatibility": lock["openFlowsCompatibility"], + "superagent_commit": commit, + }.items(): + print(f"{name}={value}", file=output) + PY + - name: Create repository-scoped release automation token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.RELEASE_AUTOMATION_APP_ID }} + private-key: ${{ secrets.RELEASE_AUTOMATION_PRIVATE_KEY }} + owner: superdurable + repositories: iac,superverse + - name: Dispatch audited downstream upgrades + env: + DEX_MANIFEST_SHA256: ${{ steps.release.outputs.manifest_sha256 }} + DEX_MANIFEST_URL: ${{ steps.release.outputs.manifest_url }} + DEX_VERSION: ${{ steps.release.outputs.dex_version }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + OPEN_FLOWS_COMPATIBILITY: ${{ steps.release.outputs.open_flows_compatibility }} + SUPERAGENT_COMMIT: ${{ steps.release.outputs.superagent_commit }} + SUPERAGENT_RELEASE: ${{ github.event.release.tag_name }} + run: | + for repository in iac superverse; do + gh api --method POST "repos/superdurable/${repository}/dispatches" \ + -f event_type=superagent-release-published \ + -f "client_payload[dex_version]=${DEX_VERSION}" \ + -f "client_payload[manifest_url]=${DEX_MANIFEST_URL}" \ + -f "client_payload[manifest_sha256]=${DEX_MANIFEST_SHA256}" \ + -f "client_payload[superagent_release]=${SUPERAGENT_RELEASE}" \ + -f "client_payload[superagent_commit]=${SUPERAGENT_COMMIT}" \ + -f "client_payload[open_flows_compatibility]=${OPEN_FLOWS_COMPATIBILITY}" + done diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ea1fadc..6bf6c55 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -75,10 +75,8 @@ prompts, and cumulative context summaries are separate typed Attributes. Snapshot is the only durable current-interaction and reconciliation read model. Archive paging is an immutable history continuation. Each page uses one -read-only Flow RPC that loads `AgentState` and the retained archive map, then -returns one exact chunk without loading current interaction state or pending -Channels. Dex Go SDK `v0.10.0` fixes selective loads at RPC registration, so an -input-selected AttributeMap instance cannot be loaded independently. +read-only Flow RPC that loads `AgentState` and one exact archive chunk, without +loading current interaction state or pending Channels. Commands follow Dex's transactional RPC model. There is no permanent command receipt, caller request ID, payload fingerprint, global mutation revision, or @@ -330,7 +328,7 @@ Runtime metadata therefore remains stable for the logical call. `internal/app` owns every long-lived resource. Startup validates configuration, discovers MCP, constructs providers, opens BlobCache, starts the Worker, waits -for its listener, marks readiness, and then serves the API. The Dex Go SDK `v0.10.0` +for its listener, marks readiness, and then serves the API. The Dex `v0.9.0` Worker negotiates a compatible Server protocol before synchronizing indexes or binding. Any startup failure closes everything already constructed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b897ce6..8963fc8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,17 +12,19 @@ against the installed released SDK and a version-matched runnable example or real-server compile-contract test. Snapshot, Stream, Channel size snapshot, and Attribute wait code target Dex Go -SDK `v0.10.0`. Workers negotiate the Server protocol before binding. Recheck +SDK and Server `v0.9.0`. Version `v0.9.0` Workers negotiate the Server protocol +before binding, so deployments upgrade the Server before the Worker. Recheck the installed SDK source and the installed skill before changing resource projection or errors. Never infer an API from a design screenshot or unreleased branch. -Dex Go SDK and dexcli are independent direct dependencies. Run -`script/update_dex_versions.py` with explicit component versions, then run -`script/check_dex_versions.py`. The updater reads dexcli's native -`checksums.txt`; SuperAgent does not consume a cross-component compatibility -manifest. Resolve SDK API changes in the same pull request. Normal compilation, -real-Server integration, and browser E2E are the merge gates. +`dex-release.lock.json` binds the direct Go SDK requirement to one immutable +Dex manifest. A Dex publication opens an automated upgrade PR with open Flow +compatibility set to `cancel-required` for review. Publishing the subsequent +SuperAgent release dispatches the reviewed IaC and SuperVerse upgrades. +The automation opens the draft after asset validation and mechanical pin +updates, before product compilation. Resolve breaking SDK API migrations in +that draft; normal pull-request CI remains the merge gate. ## Deployment boundary diff --git a/Makefile b/Makefile index b72f2f7..1b9c406 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: audit-web build-api build-web check check-agent-rules check-dex-versions check-flow-definition \ +.PHONY: audit-web build-api build-web check check-agent-rules check-flow-definition \ check-generated copyright-check flow-visualize format-check fuzz generate \ generate-go generate-web governance-check install-dexcli install-osv-scanner install-temporal lint lint-go lint-web lint-workflows \ test test-agent test-api test-app test-config test-dex-integration test-mcp test-model test-openai-live \ @@ -6,7 +6,7 @@ GO_BUILD_CACHE := $(CURDIR)/.cache/go-build GO_PACKAGES := ./agent/... ./cmd/... ./internal/... ./model/... ./toolcontract/... -DEXCLI_VERSION := v0.10.0 +DEXCLI_VERSION := v0.9.0 DEXCLI_BINARY := $(CURDIR)/.cache/dexcli-$(DEXCLI_VERSION) OSV_SCANNER_VERSION := v2.5.1 OSV_SCANNER_BINARY := $(CURDIR)/.cache/osv-scanner-$(OSV_SCANNER_VERSION) @@ -48,9 +48,6 @@ copyright-check: governance-check: check-agent-rules copyright-check -check-dex-versions: - @python3 script/check_dex_versions.py - install-dexcli: $(DEXCLI_BINARY) $(DEXCLI_BINARY): script/install-dexcli.sh @@ -83,10 +80,6 @@ check-flow-definition: install-dexcli sed -n '/"diagnostics"/,$$p' "$${flow_definition}" >&2; \ exit 1; \ fi; \ - if grep -Fq '"name": "ExecuteTool"' "$${flow_definition}"; then \ - echo "Flow definition must not contain the removed ExecuteTool Step" >&2; \ - exit 1; \ - fi; \ for channel in answeredUserInputsChannel queuedUserMessagesChannel steeredUserMessagesChannel toolApprovalsChannel toolRecoveryDecisionsChannel parallelToolResultsChannel planExecutionsChannel; do \ if ! grep -Fq "\"id\": \"resource:channel:$${channel}\"" "$${flow_definition}" || \ ! grep -Fq "\"resourceId\": \"resource:channel:$${channel}\"" "$${flow_definition}"; then \ @@ -171,4 +164,4 @@ test-web: test-openai-live: @GOCACHE=$(GO_BUILD_CACHE) GOWORK=off go test -tags=live -count=1 -run '^TestLiveOpenAIResponses$$' ./internal/model -check: governance-check check-dex-versions check-generated format-check build-api build-web vet lint test test-race test-web vulnerability-check audit-web +check: governance-check check-generated format-check build-api build-web vet lint test test-race test-web vulnerability-check audit-web diff --git a/README.md b/README.md index 0cd111f..ee21b3b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ and resource model. - Go matching [`go.mod`](go.mod) - Node.js and npm compatible with [`web/package-lock.json`](web/package-lock.json) -- A Dex Server compatible with Dex Go SDK `v0.10.0` +- A Dex `v0.9.0` server - A writable directory for disposable Dex BlobCache data ## Quick start @@ -102,7 +102,9 @@ make build-api make build-web ``` -Start a compatible Dex server, then run the API and Worker: +Start a compatible Dex server. Dex `v0.9.0` Workers require the Server +compatibility RPC, so upgrade the Server before the Worker. Then run the API and +Worker: ```bash SUPERAGENT_HTTP_ALLOWED_ORIGINS=http://127.0.0.1:3000 ./bin/superagent @@ -150,12 +152,6 @@ persisted in Dex state or logged. Copy [`web/mcp-servers.example.yaml`](web/mcp-servers.example.yaml) to configure trusted MCP servers. -Each configured tool defaults to `running_type: short_running` and a 60-second -heartbeat timeout. Use `long_running` when more than half of expected calls -exceed five seconds. This is a Dex placement optimization, not a timeout or -SLA; short-running calls may fall back and complete normally. Keep the -heartbeat default unless a healthy tool can remain silent longer. - For a cross-origin frontend deployment, add its exact origin to `SUPERAGENT_HTTP_ALLOWED_ORIGINS`. Wildcards and credentialed cross-origin requests are intentionally unsupported. Serve `config.json` with diff --git a/agent/agent.go b/agent/agent.go index 7a84638..7952152 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -125,9 +125,6 @@ const ( ToolOutcomeKnownFailure = agentinternal.ToolOutcomeKnownFailure ToolOutcomeUnknown = agentinternal.ToolOutcomeUnknown - ToolRunningTypeShortRunning = agentinternal.ToolRunningTypeShortRunning - ToolRunningTypeLongRunning = agentinternal.ToolRunningTypeLongRunning - ToolRetryExhaustionPolicyManualRecovery = agentinternal.ToolRetryExhaustionPolicyManualRecovery ToolRetryExhaustionPolicyContinueWithUnknown = agentinternal.ToolRetryExhaustionPolicyContinueWithUnknown @@ -197,7 +194,6 @@ type ( EventKind = agentinternal.EventKind Provider = agentinternal.Provider ToolOutcome = agentinternal.ToolOutcome - ToolRunningType = agentinternal.ToolRunningType ToolRetryExhaustionPolicy = agentinternal.ToolRetryExhaustionPolicy ToolRecoveryResolution = agentinternal.ToolRecoveryResolution ToolRecoveryAction = agentinternal.ToolRecoveryAction diff --git a/dex-release.lock.json b/dex-release.lock.json new file mode 100644 index 0000000..b1e74ea --- /dev/null +++ b/dex-release.lock.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "release": "0.9.0", + "manifest": { + "url": "https://github.com/superdurable/dex/releases/download/server/v0.9.0/dex-compatibility-v0.9.0.json", + "sha256": "dc09203a2d785008d4449e23f70bd3598107e82d5f7934d86f49f1c534310906" + }, + "sourceCommit": "e93b803a829735292af8c81a0cc1c98b12aee7f7", + "sdkGoVersion": "0.9.0", + "protocol": { + "minimum": 1, + "maximum": 1 + }, + "runningFlowsCompatibility": "compatible", + "persistenceCompatibility": "compatible", + "openFlowsCompatibility": "cancel-required" +} diff --git a/docs/adr/0011-dex-owned-tool-retries.md b/docs/adr/0011-dex-owned-tool-retries.md index 17b7943..577c6d2 100644 --- a/docs/adr/0011-dex-owned-tool-retries.md +++ b/docs/adr/0011-dex-owned-tool-retries.md @@ -19,11 +19,6 @@ Go error and use Dex retry. Exhaustion follows the tool definition's recovery policy. It defaults to the manual boundary introduced by ADR 0013; explicitly configured tools may route to `RecoverToolExecution` and continue with unknown. -New Agent Flows default Step durability to ASYNC. Short-running tools inherit -that default and may fall back to regular execution. Long-running tools override -Execute durability to SYNC. Tool policy also supplies heartbeat timeout, while -attempt timeout bounds both Dex execution and the registry child context. - Approval and CallID remain stable across attempts. External effects promise recoverable at-least-once execution, not exactly-once execution. diff --git a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md index 80f06a2..e1890a6 100644 --- a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md +++ b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md @@ -74,6 +74,8 @@ Consumed IDs suppress stale queue data until durable history replaces the temporary projection. Snapshot remains the only authoritative durable reconciliation model. -Deployments must update the Worker and browser behavior together. The product -has not launched, so removed Agent schemas have no migration path. Runtime -protocol negotiation still rejects an unsupported Server. +Deployments must use Dex Server and Go SDK `v0.9.0`, and the matching Worker and +browser behavior together. The Server must be upgraded first because `v0.9.0` +Workers reject Servers without protocol negotiation. Deployments must stop or +clear Agent Flows created with the removed schema before rollout; there is no +old-Attribute or Runtime Lease compatibility shim. diff --git a/docs/adr/0014-registered-rpc-options.md b/docs/adr/0014-registered-rpc-options.md deleted file mode 100644 index 4b70086..0000000 --- a/docs/adr/0014-registered-rpc-options.md +++ /dev/null @@ -1,41 +0,0 @@ -# ADR 0014: Register immutable RPC execution options - -## Status - -Accepted on 2026-09-17. - -## Context - -Dex Go SDK `v0.10.0` replaces reflected RPC discovery with explicit `GetRPCs` -definitions. Timeout, locks, transactional execution, and selective collection -loads belong to the registered RPC definition. Callers can impose a shorter -context deadline, but cannot change those options per invocation. - -Most Agent RPCs always use the same resources. `GetArchivedMessages` is the -exception: its input selects one `ArchivedMessages` instance. The released SDK -cannot derive an AttributeMap instance load from RPC input. - -## Decision - -`AIAgentFlow.GetRPCs` explicitly registers every production RPC and its complete -execution policy. Client calls provide only the Flow ID, registered method, -typed input, and output destination. Snapshot uses a five-second registered -timeout. Commands and archive reads use twenty seconds. Existing Attribute -locks and transactional Channel mutations remain attached to their RPCs. - -`GetArchivedMessages` registers a whole-map `ArchivedMessages` load and returns -only the requested immutable ten-message chunk. It still excludes current -messages and pending Channels. Retention remains the bound on loaded archive -state. Integration-only RPCs use the same explicit registration contract. - -## Consequences - -Worker and Client registries share one visible RPC contract, and invalid loads -or locks fail during registry construction. Call sites cannot accidentally -weaken transactional behavior or select undeclared state. - -An archive page now hydrates every retained archive chunk before returning one -page. This is a known cost of the released `v0.10.0` contract, not an SLA change. -A future bounded design requires a new durable storage boundary or a released -SDK facility for input-derived instance selection; it must not emulate mutable -per-call options in application code. diff --git a/docs/flow-model.md b/docs/flow-model.md index e8f2bc2..0469b21 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -14,29 +14,15 @@ `GetArchivedMessages` - Browser synchronization Attribute: `WaitingInputRound` -The implementation requires Dex Go SDK `v0.10.0`. Each +The implementation requires Dex Go SDK and Server `v0.9.0`. Each `WaitFor`, `Execute`, and RPC invocation is an independent Dex atomic commit. Provider and MCP calls are external effects and are not part of a Dex transaction. -New Agent Flows set `FlowConfig.StepDurability` to ASYNC. Ordinary Steps inherit -that default. `CompactContext`, `CallModel`, and tools declared long-running -override Execute durability to SYNC. A short-running tool may fall back from -local to regular execution; that is an expected optimization path and does not -change its ASYNC durability. Registry policy supplies each tool's attempt, -heartbeat, retry, and recovery settings. Ordinary Step methods use a one-minute -timeout, while model methods retain their explicit ten-minute timeout and -five-minute heartbeat. - -The Worker negotiates the highest common protocol with the Server before -Attribute index synchronization or Worker binding. Startup fails when -`GetServerInfo` is missing, either interval is invalid, or the intervals do not -overlap. Release automation does not duplicate this runtime check. - -Snapshot reads retry inactive-run and server long-poll expiry errors within a -three-attempt budget. Snapshot is read-only, so these retries cannot duplicate -commands or external effects. Caller cancellation and other errors still return -immediately. +The `v0.9.0` Worker negotiates the highest common protocol with the Server before +Attribute index synchronization or Worker binding. Deploy the Server before the +Worker. Startup fails when `GetServerInfo` is missing, either interval is +invalid, or the intervals do not overlap. Renewable sandbox credentials are not an Agent Flow resource. A future, separately designed `SandboxLifecycleFlow` will own that lifecycle. @@ -69,6 +55,9 @@ AwaitToolApproval -> next tool or CompactContext (rejected) -> CompactContext (steered) +ExecuteTool + -> next tool or CompactContext (legacy open executions only) + ExecuteToolWithRetry -> next tool or CompactContext (success or known failure) -> RecoverToolExecution (configured automatic unknown) @@ -115,6 +104,7 @@ history, and makes the model replan. | `CheckSteered` | bounded steered batch | Apply steering at a safe boundary or route the explicit continuation | | `RouteTool` | none | Validate built-in arguments and select approval, MCP execution, timer, input, or next-call path | | `AwaitToolApproval` | exact call-ID approval or steering | Persist waiting status; consume one decision or replan on steering | +| `ExecuteTool` | none | Perform one external MCP effect with stable Flow/call identity, then persist its result | | `ExecuteToolWithRetry` | none | Perform one external tool attempt under dynamically selected Dex timeout and retry policy | | `RecoverToolExecution` | none | Record one unknown result for an explicitly configured automatic recovery, then continue | | `ExecuteParallelTool` | none | Perform one bounded-wave branch effect and publish exactly one typed result without shared-state mutation | @@ -124,7 +114,7 @@ history, and makes the model replan. | `AwaitManualToolRecovery` | exact recovery decision or steering | Persist recovery state; retry selected calls, continue unknowns, stop the sequence, or replan | | `DurableWait` | Timer or steering | Persist waiting status; record completion or interruption and continue | -Dex Server `v0.10.0` and Go SDK `v0.10.0` expose Channel size metadata in `WaitFor` and +Dex Server and Go SDK `v0.9.0` expose Channel size metadata in `WaitFor` and `Execute`. `AwaitUser.WaitFor` reads the sizes of `SteeredUserMessages`, `QueuedUserMessages`, and the current `PlanExecutions` instance without loading message payloads. It increments @@ -225,10 +215,8 @@ retained messages. Snapshot is one read-only Flow RPC that loads current history, the interaction description, and pending Channels. It returns `WaitingInputRound` and stable -application message IDs. Archive paging returns exactly one immutable chunk and -the bounded sequence metadata needed for continuation. Its registered -`v0.10.0` RPC options load the retained archive map because the requested chunk -key is an RPC input and invocation-specific selective loads no longer exist. +application message IDs. Archive paging loads exactly one immutable chunk and +the bounded sequence metadata needed for continuation. The browser begins with the Snapshot round, waits for `round > watermark`, uses the actual matched round as the next watermark, then refreshes Snapshot. A @@ -238,13 +226,6 @@ every completed Snapshot read. Hidden pages pause the timer and live reads. ## External effects and recovery - Tool execution policy is copied from `ToolDefinition` into Dex StepOptions. -- `short_running` is the default and inherits Flow ASYNC durability. Use - `long_running` when more than half of expected calls are likely to exceed five - seconds; it overrides Execute durability to SYNC. This classification is an - optimization hint, not a runtime guarantee. -- Tool heartbeat defaults to one minute. Increase it only when healthy regular - execution can remain silent for longer. `AttemptTimeout` also bounds the - registry context because ASYNC local execution ignores Dex method timeouts. - The `mock/dex` model alone exposes `simulate_tool_failure`; `/tool-failure` uses it to verify retry exhaustion and the manual recovery surface locally. - Known business failures return a normal tool result. Transient or ambiguous diff --git a/go.mod b/go.mod index 5f70fa1..b17e624 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/ogen-go/ogen v1.24.0 github.com/openai/openai-go/v3 v3.55.0 github.com/superdurable/dex/blob-cache-go v0.1.0 - github.com/superdurable/dex/sdk-go v0.10.0 + github.com/superdurable/dex/sdk-go v0.9.0 golang.org/x/net v0.58.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -54,5 +54,4 @@ require ( ) tool github.com/ogen-go/ogen/cmd/ogen - tool github.com/ogen-go/ogen/cmd/jschemagen diff --git a/go.sum b/go.sum index 3266885..ef24fa7 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/superdurable/dex/blob-cache-go v0.1.0 h1:+c3H5YBWG3DlICOHbgT9IUM5vTlfLYGP5Vd5sWr7WTY= github.com/superdurable/dex/blob-cache-go v0.1.0/go.mod h1:Atepb7+sztvDCztVKmlvEKCSKFCkHKtDhoFYjaFmtEw= -github.com/superdurable/dex/sdk-go v0.10.0 h1:TtXm17mxRE3ZdXI9hltIWDb3v6UsboHj/K5gEncO4Xg= -github.com/superdurable/dex/sdk-go v0.10.0/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= +github.com/superdurable/dex/sdk-go v0.9.0 h1:F1kJnQMGPMR6pXWiB3ZJk2FPdqpaQ8PQ0+ukfrJEvw4= +github.com/superdurable/dex/sdk-go v0.9.0/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= diff --git a/internal/agent/client.go b/internal/agent/client.go index 0c25fe5..e4bf339 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -30,9 +30,6 @@ import ( const ( defaultCommandTimeout = 20 * time.Second defaultEventPoll = 20 * time.Second - // Snapshot reads use a shorter budget so clients can retry across continue-as-new. - defaultSnapshotTimeout = 5 * time.Second - maximumSnapshotAttempts = 3 // MaximumRecentEventLimit matches Dex's default maximum Stream list page size. MaximumRecentEventLimit = 1_000 ) @@ -81,13 +78,10 @@ func (client *Client) Start(ctx context.Context, flowID FlowID, request StartReq if err != nil { return "", fmt.Errorf("encode Agent runtime metadata: %w", err) } - runID, err := client.sdk.StartFlow( - ctx, - client.flow, - string(flowID), - request.Config, - newAgentStartFlowOptions(initialMetadata), - ) + runID, err := client.sdk.StartFlow(ctx, client.flow, string(flowID), request.Config, dex.StartFlowOptions{ + IDReusePolicy: dex.IDReuseDisallow, + Attributes: []dex.InitialAttributeDef{initialMetadata}, + }) if err != nil { return "", err } @@ -97,17 +91,6 @@ func (client *Client) Start(ctx context.Context, flowID FlowID, request StartReq return RunID(runID), nil } -func newAgentStartFlowOptions(initialMetadata dex.InitialAttributeDef) dex.StartFlowOptions { - durability := dex.StepDurabilityAsync - return dex.StartFlowOptions{ - IDReusePolicy: dex.IDReuseDisallow, - Attributes: []dex.InitialAttributeDef{initialMetadata}, - ConfigOverride: &dex.FlowConfig{ - StepDurability: &durability, - }, - } -} - // SendMessage invokes the durable SendMessage command. func (client *Client) SendMessage(ctx context.Context, flowID FlowID, message UserMessage) error { if err := validateFlowID(flowID); err != nil { @@ -118,7 +101,10 @@ func (client *Client) SendMessage(ctx context.Context, flowID FlowID, message Us } pending := PendingUserMessage{MessageID: MessageID(uuid.NewString()), Value: message} accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SendMessage, pending, accepted) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SendMessage, pending, accepted, dex.InvokeOptions{ + Timeout: client.commandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, + }) }) if err != nil { return err @@ -139,7 +125,10 @@ func (client *Client) AnswerQuestions( return err } accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.AnswerQuestions, request, accepted) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.AnswerQuestions, request, accepted, dex.InvokeOptions{ + Timeout: client.commandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, + }) }) if err != nil { return err @@ -187,7 +176,12 @@ func (client *Client) SteerMessage(ctx context.Context, flowID FlowID, request S return err } accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SteerMessage, request, accepted) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SteerMessage, request, accepted, dex.InvokeOptions{ + Timeout: client.commandTimeout, + IsTransactional: true, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, + }) }) if err != nil { return err @@ -207,48 +201,30 @@ func (client *Client) GetSnapshot( return AgentSnapshot{}, err } current, statusErr := client.latestAgentRun(ctx, flowID) - if statusErr == nil && current != nil && isTerminalFlowStatus(current.Status) { + if statusErr == nil && current != nil && current.Status != dex.FlowRunning { return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) } - var retryErr error - for range maximumSnapshotAttempts { - snapshot, err := client.invokeSnapshotRPC(ctx, flowID) - if err == nil { - return snapshot, nil - } - var inactive *dex.FlowNotActiveError - var pollTimeout *dex.LongPollTimeoutError - if !errors.As(err, &inactive) && !errors.As(err, &pollTimeout) { - return AgentSnapshot{}, err - } - // Snapshot is read-only, so server long-poll expiry can safely retry within this bounded budget. - retryErr = err - current, statusErr = client.latestAgentRun(ctx, flowID) - if statusErr != nil { - return AgentSnapshot{}, errors.Join(err, statusErr) - } - if current == nil || !isTerminalFlowStatus(current.Status) { - continue - } - return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) + var snapshot AgentSnapshot + err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetSnapshot, nil, &snapshot, dex.InvokeOptions{ + Timeout: client.commandTimeout, + LoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, + LoadChannels: []dex.ChannelDef{ + queuedUserMessagesChannel, + steeredUserMessagesChannel, + }, + }) + if err == nil { + return snapshot, nil } - return AgentSnapshot{}, retryErr -} - -func isTerminalFlowStatus(status dex.FlowStatus) bool { - return (dex.FlowResult{Status: status}).IsTerminal() -} - -func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (AgentSnapshot, error) { - timeout := client.commandTimeout - if timeout <= 0 || timeout > defaultSnapshotTimeout { - timeout = defaultSnapshotTimeout + var inactive *dex.FlowNotActiveError + if !errors.As(err, &inactive) { + return AgentSnapshot{}, err } - rpcContext, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - var snapshot AgentSnapshot - err := client.sdk.InvokeRPC(rpcContext, string(flowID), client.flow.GetSnapshot, nil, &snapshot) - return snapshot, err + terminal, terminalErr := client.terminalSnapshot(ctx, flowID, "") + if terminalErr != nil { + return AgentSnapshot{}, errors.Join(err, terminalErr) + } + return terminal, nil } // GetArchivedMessages reads exactly one immutable history chunk before a sequence boundary. @@ -256,11 +232,17 @@ func (client *Client) GetArchivedMessages(ctx context.Context, flowID FlowID, be if err := validateFlowID(flowID); err != nil { return HistoryPage{}, err } - if _, isValid := archivedMessageChunkFirst(before); !isValid { + first, isValid := archivedMessageChunkFirst(before) + if !isValid { return HistoryPage{}, fmt.Errorf("before sequence must identify a %d-message boundary", archiveMessageChunkSize) } var result archivedMessagesRPCOutput - err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetArchivedMessages, before, &result) + err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetArchivedMessages, before, &result, dex.InvokeOptions{ + Timeout: client.commandTimeout, + LoadAttributeMapInstances: []dex.AttributeMapLoad{ + archivedMessagesAttribute.Load(sequenceKey(first)), + }, + }) if err != nil { return HistoryPage{}, err } @@ -432,6 +414,11 @@ func (client *Client) DeleteQueuedMessage(ctx context.Context, flowID FlowID, me client.flow.DeleteQueuedMessage, messageID, &deleted, + dex.InvokeOptions{ + Timeout: client.commandTimeout, + IsTransactional: true, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + }, ); err != nil { return err } @@ -450,7 +437,11 @@ func (client *Client) ApproveTool(ctx context.Context, flowID FlowID, request To return errors.New("call ID must not be empty") } var accepted bool - if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ApproveTool, request, &accepted); err != nil { + if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ApproveTool, request, &accepted, dex.InvokeOptions{ + Timeout: client.commandTimeout, + IsTransactional: true, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingApprovalAttribute)}, + }); err != nil { return err } return ensureAccepted(accepted, CommandApproveTool) @@ -486,6 +477,10 @@ func (client *Client) ResolveToolRecovery( client.flow.ResolveToolRecovery, request, accepted, + dex.InvokeOptions{ + Timeout: client.commandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, + }, ) }) if err != nil { @@ -503,7 +498,11 @@ func (client *Client) ExecutePlan(ctx context.Context, flowID FlowID, request Pl return errors.New("plan revision must be positive") } var accepted bool - if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ExecutePlan, request, &accepted); err != nil { + if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ExecutePlan, request, &accepted, dex.InvokeOptions{ + Timeout: client.commandTimeout, + IsTransactional: true, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(agentStateAttribute)}, + }); err != nil { return err } return ensureAccepted(accepted, CommandExecutePlan) diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index 6b76b67..37b05b3 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -16,22 +16,7 @@ package agent -import ( - "testing" - - "github.com/superdurable/dex/sdk-go/dex" -) - -func TestAgentStartFlowOptionsDefaultStepsToAsyncDurability(t *testing.T) { - t.Parallel() - options := newAgentStartFlowOptions(nil) - if options.ConfigOverride == nil || options.ConfigOverride.StepDurability == nil { - t.Fatalf("ConfigOverride = %+v", options.ConfigOverride) - } - if got := *options.ConfigOverride.StepDurability; got != dex.StepDurabilityAsync { - t.Fatalf("StepDurability = %v, want %v", got, dex.StepDurabilityAsync) - } -} +import "testing" func TestListRecentEventsRejectsInvalidLimits(t *testing.T) { t.Parallel() @@ -43,26 +28,3 @@ func TestListRecentEventsRejectsInvalidLimits(t *testing.T) { } } } - -func TestIsTerminalFlowStatusTreatsContinueAsNewAsActive(t *testing.T) { - t.Parallel() - for _, test := range []struct { - name string - status dex.FlowStatus - terminal bool - }{ - {name: "running", status: dex.FlowRunning}, - {name: "continued as new", status: dex.FlowContinuedAsNew}, - {name: "completed", status: dex.FlowCompleted, terminal: true}, - {name: "failed", status: dex.FlowFailed, terminal: true}, - {name: "canceled", status: dex.FlowCanceled, terminal: true}, - {name: "terminated", status: dex.FlowTerminated, terminal: true}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - if got := isTerminalFlowStatus(test.status); got != test.terminal { - t.Fatalf("isTerminalFlowStatus(%v) = %t, want %t", test.status, got, test.terminal) - } - }) - } -} diff --git a/internal/agent/flow.go b/internal/agent/flow.go index f051e9f..2397d96 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -17,11 +17,11 @@ package agent import ( - "context" "encoding/json" "errors" "fmt" "math" + "reflect" "slices" "strings" "time" @@ -56,9 +56,8 @@ var ( // Flow is the durable AI Agent state machine. type Flow struct { - modelClient ModelClient - tools ToolRegistry - rpcDefinitionsForTestOnly []dex.RPCDef + modelClient ModelClient + tools ToolRegistry } var _ dex.Flow = (*Flow)(nil) @@ -92,6 +91,7 @@ func (flow *Flow) GetSteps() []dex.StepDef { dex.DefineStep(checkSteeredStep{flow: flow}), dex.DefineStep(routeToolStep{flow: flow}), dex.DefineStep(awaitToolApprovalStep{flow: flow}), + dex.DefineStep(executeToolStep{flow: flow}), dex.DefineStep(executeToolWithRetryStep{flow: flow}), dex.DefineStep(recoverToolExecutionStep{flow: flow}), dex.DefineStep(executeParallelToolStep{flow: flow}), @@ -103,58 +103,6 @@ func (flow *Flow) GetSteps() []dex.StepDef { } } -// GetRPCs registers synchronous Agent reads and commands with immutable execution policy. -func (flow *Flow) GetRPCs() []dex.RPCDef { - definitions := []dex.RPCDef{ - dex.DefineRPC(flow.SendMessage, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, - }), - dex.DefineRPC(flow.AnswerQuestions, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, - }), - dex.DefineRPC(flow.SteerMessage, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, - IsTransactional: true, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - }), - dex.DefineRPC(flow.GetSnapshot, &dex.RPCOptions{ - Timeout: defaultSnapshotTimeout, - LoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, - LoadChannels: []dex.ChannelDef{ - queuedUserMessagesChannel, - steeredUserMessagesChannel, - }, - }), - dex.DefineRPC(flow.GetArchivedMessages, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LoadAttributeMaps: []dex.AttributeDef{archivedMessagesAttribute}, - }), - dex.DefineRPC(flow.DeleteQueuedMessage, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - IsTransactional: true, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - }), - dex.DefineRPC(flow.ApproveTool, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingApprovalAttribute)}, - IsTransactional: true, - }), - dex.DefineRPC(flow.ResolveToolRecovery, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, - }), - dex.DefineRPC(flow.ExecutePlan, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(agentStateAttribute)}, - IsTransactional: true, - }), - } - return append(definitions, flow.rpcDefinitionsForTestOnly...) -} - // GetPersistenceSchema registers every durable value and best-effort stream. func (*Flow) GetPersistenceSchema() dex.PersistenceSchema { return dex.PersistenceSchema{ @@ -608,10 +556,6 @@ func (flow *Flow) validateConfig(config AgentConfig) error { func validateToolExecutionPolicy(definition ToolDefinition) error { policy := definition.RetryExhaustionPolicy.Effective() - runningType := definition.RunningType.Effective() - if err := runningType.Validate(); err != nil { - return err - } switch { case definition.MaximumAttempts <= 0: return errors.New("maximum attempts must be positive") @@ -619,8 +563,6 @@ func validateToolExecutionPolicy(definition ToolDefinition) error { return errors.New("maximum attempts exceeds the Dex limit") case definition.AttemptTimeout < 0: return errors.New("attempt timeout must not be negative") - case definition.HeartbeatTimeout < 0: - return errors.New("heartbeat timeout must not be negative") case definition.RetryTotalDuration < 0: return errors.New("retry total duration must not be negative") default: @@ -664,8 +606,7 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { } return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), - ExecuteDurability: toolExecuteDurability(definition), + HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, ExecuteLoadAttributeMaps: toolStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ // #nosec G115 -- validateToolExecutionPolicy rejects values outside int32. @@ -679,33 +620,18 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { func (flow *Flow) parallelToolStepOptions(definition ToolDefinition) *dex.StepOptions { return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), - ExecuteDurability: toolExecuteDurability(definition), + HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: int32(definition.MaximumAttempts), // #nosec G115 -- validated before scheduling. TotalDuration: definition.RetryTotalDuration, }, ExecuteFailure: dex.ProceedToOnExecuteFailure( recoverParallelToolExecutionStep{flow: flow}, - defaultStepOptions, + nil, ), } } -func effectiveToolHeartbeatTimeout(definition ToolDefinition) time.Duration { - if definition.HeartbeatTimeout == 0 { - return time.Minute - } - return definition.HeartbeatTimeout -} - -func toolExecuteDurability(definition ToolDefinition) dex.StepDurability { - if definition.RunningType.Effective() == ToolRunningTypeLongRunning { - return dex.StepDurabilitySync - } - return dex.StepDurabilityDefault -} - func (flow *Flow) parallelToolMovements( config AgentConfig, state AgentState, @@ -797,27 +723,11 @@ func (flow *Flow) invocationToolDefinition(config AgentConfig, state AgentState, return ToolDefinition{}, fmt.Errorf("unknown or disabled tool %q", name) } -func (flow *Flow) executeTool( - ctx dex.Context, - definition ToolDefinition, - invocation ToolInvocation, -) (ToolExecutionResult, error) { +func (flow *Flow) executeTool(ctx dex.Context, invocation ToolInvocation) (ToolExecutionResult, error) { if invocation.Name == ToolNameSimulateFailure { return ToolExecutionResult{}, simulatedToolFailureError{} } - executionContext, cancel := newToolExecutionContext(ctx, definition.AttemptTimeout) - defer cancel() - return flow.tools.Execute(executionContext, invocation) -} - -func newToolExecutionContext( - ctx context.Context, - attemptTimeout time.Duration, -) (context.Context, context.CancelFunc) { - if attemptTimeout == 0 { - return ctx, func() {} - } - return context.WithTimeout(ctx, attemptTimeout) + return flow.tools.Execute(ctx, invocation) } func (flow *Flow) beginUserTurn(ctx dex.Context, message UserMessage) (Sequence, error) { @@ -1666,6 +1576,7 @@ const ( continueCompactContext continuation = "compact_context" continueRouteTool continuation = "route_tool" continueAwaitToolApproval continuation = "await_tool_approval" + continueExecuteTool continuation = "execute_tool" continueExecuteToolRetry continuation = "execute_tool_with_retry" continueDurableWait continuation = "durable_wait" @@ -1677,6 +1588,7 @@ const ( stepTypeCheckSteered stepType = "CheckSteered" stepTypeRouteTool stepType = "RouteTool" stepTypeAwaitApproval stepType = "AwaitToolApproval" + stepTypeExecuteTool stepType = "ExecuteTool" stepTypeExecuteRetry stepType = "ExecuteToolWithRetry" stepTypeRecoverTool stepType = "RecoverToolExecution" stepTypeExecuteParallel stepType = "ExecuteParallelTool" @@ -1756,13 +1668,7 @@ type awaitParallelToolResultsInput struct { } var ( - defaultStepOptions = &dex.StepOptions{ - WaitForMethodTimeout: time.Minute, - ExecuteMethodTimeout: time.Minute, - } messageMutationStepOptions = &dex.StepOptions{ - WaitForMethodTimeout: time.Minute, - ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, ExecuteLockAttributes: []dex.AttributeLock{ dex.LockAttribute(pendingUserInputAttribute), @@ -1771,8 +1677,6 @@ var ( }, } awaitUserStepOptions = &dex.StepOptions{ - WaitForMethodTimeout: time.Minute, - ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{ currentMessagesAttribute, }, @@ -1783,8 +1687,6 @@ var ( }, } messageContextStepOptions = &dex.StepOptions{ - WaitForMethodTimeout: time.Minute, - ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{ currentMessagesAttribute, archivedMessagesAttribute, @@ -1797,7 +1699,6 @@ var ( modelStepOptions = &dex.StepOptions{ ExecuteMethodTimeout: 10 * time.Minute, HeartbeatTimeout: 5 * time.Minute, - ExecuteDurability: dex.StepDurabilitySync, ExecuteLoadAttributeMaps: messageContextStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: 3, @@ -1806,15 +1707,13 @@ var ( } toolStepOptions = &dex.StepOptions{ ExecuteMethodTimeout: 2 * time.Hour, - HeartbeatTimeout: time.Minute, + HeartbeatTimeout: 5 * time.Minute, ExecuteLoadAttributeMaps: messageMutationStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: 1, }, } manualToolRecoveryStepOptions = &dex.StepOptions{ - WaitForMethodTimeout: time.Minute, - ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: messageMutationStepOptions.ExecuteLoadAttributeMaps, ExecuteLockAttributes: []dex.AttributeLock{ dex.LockAttribute(pendingToolRecoveryAttribute), @@ -1831,8 +1730,6 @@ var _ dex.Step[AgentConfig] = initStep{} func (initStep) GetStepType() string { return string(stepTypeInit) } -func (initStep) GetStepOptions() *dex.StepOptions { return defaultStepOptions } - func (step initStep) Execute(ctx dex.Context, input AgentConfig) (*dex.StepDecision, error) { if err := step.flow.validateConfig(input); err != nil { return nil, err @@ -2304,6 +2201,8 @@ func (step checkSteeredStep) Execute(ctx dex.Context, input continuation) (*dex. return dex.GoTo(routeToolStep{flow: step.flow}, nil), nil case continueAwaitToolApproval: return dex.GoTo(awaitToolApprovalStep{flow: step.flow}, nil), nil + case continueExecuteTool: + return dex.GoTo(executeToolStep{flow: step.flow}, nil), nil case continueExecuteToolRetry: options, err := step.flow.currentToolStepOptions(ctx) if err != nil { @@ -2620,6 +2519,65 @@ func (step awaitToolApprovalStep) Execute(ctx dex.Context, _ dex.None) (*dex.Ste return dex.GoTo(checkSteeredStep{flow: step.flow}, continueCompactContext), nil } +type executeToolStep struct { + dex.StepDefaultsNoWaitFor[dex.None] + flow *Flow +} + +var _ dex.Step[dex.None] = executeToolStep{} + +func (executeToolStep) GetStepType() string { return string(stepTypeExecuteTool) } + +func (executeToolStep) GetStepOptions() *dex.StepOptions { return toolStepOptions } + +func (step executeToolStep) Execute(ctx dex.Context, _ dex.None) (*dex.StepDecision, error) { + if statusErr := step.flow.updateStatus(ctx, AgentStatusExecutingTool); statusErr != nil { + return nil, statusErr + } + call, callErr := step.flow.currentToolCall(ctx) + if callErr != nil { + return nil, callErr + } + config, configErr := agentConfigAttribute.Get(ctx) + if configErr != nil { + return nil, configErr + } + runtimeMetadata, metadataErr := agentRuntimeMetadataAttribute.Get(ctx) + if isAttributeNotFound(metadataErr) { + runtimeMetadata = MustJSONObject(`{}`) + } else if metadataErr != nil { + return nil, metadataErr + } + progress := toolProgress{ctx: ctx, flow: step.flow, call: call} + result, executeErr := step.flow.executeTool(ctx, ToolInvocation{ + FlowID: FlowID(ctx.FlowID()), + RuntimeMetadata: runtimeMetadata, + Name: call.Name, + Arguments: call.Arguments, + EnabledServers: config.EnabledMCPServers, + WriteProgress: progress.write, + CallID: call.ID, + Attempt: ctx.Attempt(), + FirstAttemptAt: ctx.FirstAttemptAt(), + }) + if executeErr != nil { + failureResult, encodeErr := encodeToolResult(toolResultPayload{ + Status: toolResultStatusFailed, + Outcome: ToolOutcomeUnknown, + ErrorType: errorTypeName(executeErr), + }, ToolOutcomeUnknown, true) + if encodeErr != nil { + return nil, errors.Join(executeErr, encodeErr) + } + result = failureResult + } + next, finishErr := step.flow.finishToolExecution(ctx, call, result) + if finishErr != nil { + return nil, finishErr + } + return dex.GoTo(checkSteeredStep{flow: step.flow}, next), nil +} + func (flow *Flow) finishToolExecution( ctx dex.Context, call ToolCall, @@ -2747,14 +2705,6 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if err != nil { return nil, err } - state, err := agentStateAttribute.Get(ctx) - if err != nil { - return nil, err - } - definition, err := step.flow.invocationToolDefinition(config, state, call.Name) - if err != nil { - return nil, err - } runtimeMetadata, err := agentRuntimeMetadataAttribute.Get(ctx) if isAttributeNotFound(err) { runtimeMetadata = MustJSONObject(`{}`) @@ -2765,7 +2715,7 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if writeErr := progress.write(fmt.Sprintf("Calling %s (attempt %d).", call.Name, ctx.Attempt())); writeErr != nil { return nil, writeErr } - result, err := step.flow.executeTool(ctx, definition, ToolInvocation{ + result, err := step.flow.executeTool(ctx, ToolInvocation{ FlowID: FlowID(ctx.FlowID()), RuntimeMetadata: runtimeMetadata, Name: call.Name, @@ -2782,6 +2732,14 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if validationErr := result.Outcome.Validate(); validationErr != nil { return nil, fmt.Errorf("tool %q outcome: %w", call.Name, validationErr) } + state, err := agentStateAttribute.Get(ctx) + if err != nil { + return nil, err + } + definition, err := step.flow.invocationToolDefinition(config, state, call.Name) + if err != nil { + return nil, err + } if result.Outcome == ToolOutcomeUnknown && definition.RetryExhaustionPolicy.Effective() == ToolRetryExhaustionPolicyManualRecovery { resultCopy := result @@ -2863,7 +2821,7 @@ func (step executeParallelToolStep) GetStepOptions() *dex.StepOptions { ExecuteRetry: toolStepOptions.ExecuteRetry, ExecuteFailure: dex.ProceedToOnExecuteFailure( recoverParallelToolExecutionStep{flow: step.flow}, - defaultStepOptions, + nil, ), } } @@ -2876,14 +2834,6 @@ func (step executeParallelToolStep) Execute( if configErr != nil { return nil, configErr } - state, stateErr := agentStateAttribute.Get(ctx) - if stateErr != nil { - return nil, stateErr - } - definition, definitionErr := step.flow.invocationToolDefinition(config, state, input.Call.Name) - if definitionErr != nil { - return nil, definitionErr - } runtimeMetadata, metadataErr := agentRuntimeMetadataAttribute.Get(ctx) if isAttributeNotFound(metadataErr) { runtimeMetadata = MustJSONObject(`{}`) @@ -2894,7 +2844,7 @@ func (step executeParallelToolStep) Execute( if progressErr := progress.write(fmt.Sprintf("Calling %s (attempt %d).", input.Call.Name, ctx.Attempt())); progressErr != nil { return nil, progressErr } - result, executeErr := step.flow.executeTool(ctx, definition, ToolInvocation{ + result, executeErr := step.flow.executeTool(ctx, ToolInvocation{ FlowID: FlowID(ctx.FlowID()), RuntimeMetadata: runtimeMetadata, Name: input.Call.Name, @@ -2938,9 +2888,7 @@ func (recoverParallelToolExecutionStep) GetStepType() string { return string(stepTypeRecoverParallel) } -func (recoverParallelToolExecutionStep) GetStepOptions() *dex.StepOptions { - return defaultStepOptions -} +func (recoverParallelToolExecutionStep) GetStepOptions() *dex.StepOptions { return nil } func (recoverParallelToolExecutionStep) Execute( ctx dex.Context, @@ -3459,3 +3407,17 @@ func toolProgressMessage(tool ToolName, message string) string { } return condenseActivityMessage(message) } + +func errorTypeName(err error) string { + value := reflect.TypeOf(err) + if value == nil { + return "error" + } + for value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Name() == "" { + return "error" + } + return value.Name() +} diff --git a/internal/agent/flow_integration_test.go b/internal/agent/flow_integration_test.go index 1f0db01..2d34b03 100644 --- a/internal/agent/flow_integration_test.go +++ b/internal/agent/flow_integration_test.go @@ -1577,32 +1577,10 @@ type agentIntegrationEnvironment struct { agent *Client } -func registerRPCDefinitionsForTestOnly(flow *Flow) { - flow.rpcDefinitionsForTestOnly = []dex.RPCDef{ - dex.DefineRPC(flow.GetFlowStateForTestOnly, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - }), - dex.DefineRPC(flow.GetPlanExecutionMessagesForTestOnly, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LoadChannelMaps: []dex.ChannelDef{planExecutionsChannel}, - }), - dex.DefineRPC(flow.GetMessagesAfterForTestOnly, &dex.RPCOptions{ - Timeout: defaultCommandTimeout, - LoadAttributeMaps: []dex.AttributeDef{ - currentMessagesAttribute, - archivedMessagesAttribute, - }, - }), - } -} - func newAgentIntegrationEnvironment(t *testing.T, modelClient ModelClient, tools ToolRegistry) *agentIntegrationEnvironment { t.Helper() - flow := NewFlow(modelClient, tools) - registerRPCDefinitionsForTestOnly(flow) environment := &agentIntegrationEnvironment{ - flow: flow, + flow: NewFlow(modelClient, tools), address: availableLocalAddress(t, t.Context()), serverAddress: os.Getenv("DEX_FLOW_SERVICE_ADDRESS"), } diff --git a/internal/agent/history_test.go b/internal/agent/history_test.go index bea54d1..d8bd655 100644 --- a/internal/agent/history_test.go +++ b/internal/agent/history_test.go @@ -105,6 +105,10 @@ func (client *Client) GetFlowStateForTestOnly( client.flow.GetFlowStateForTestOnly, nil, &result, + dex.InvokeOptions{ + Timeout: client.commandTimeout, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + }, ) return result, err } @@ -137,6 +141,12 @@ func (client *Client) GetPlanExecutionMessagesForTestOnly( client.flow.GetPlanExecutionMessagesForTestOnly, revision, &messages, + dex.InvokeOptions{ + Timeout: client.commandTimeout, + LoadChannelMapInstances: []dex.ChannelMapLoad{ + planExecutionsChannel.LoadMessages(planRevisionKey(revision)), + }, + }, ) return messages, err } @@ -221,6 +231,13 @@ func (client *Client) GetMessagesAfterForTestOnly( client.flow.GetMessagesAfterForTestOnly, getMessagesAfterInputForTestOnly{After: after, Limit: limit}, &page, + dex.InvokeOptions{ + Timeout: client.commandTimeout, + LoadAttributeMaps: []dex.AttributeDef{ + currentMessagesAttribute, + archivedMessagesAttribute, + }, + }, ) return page, err } diff --git a/internal/agent/tool_recovery_test.go b/internal/agent/tool_recovery_test.go index 929f1be..ae7ce8f 100644 --- a/internal/agent/tool_recovery_test.go +++ b/internal/agent/tool_recovery_test.go @@ -18,11 +18,8 @@ package agent import ( "context" - "errors" "testing" "time" - - "github.com/superdurable/dex/sdk-go/dex" ) func TestParallelToolMovementsUseBoundedContiguousSafeWave(t *testing.T) { @@ -98,72 +95,6 @@ func TestToolDefinitionsExposeFailureSimulationOnlyToLocalMock(t *testing.T) { } } -func TestToolStepOptionsMapRunningTypeAndHeartbeat(t *testing.T) { - flow := &Flow{} - short := parallelDefinitionForTestOnly("short") - shortOptions := flow.toolStepOptions(short) - if shortOptions.ExecuteDurability != dex.StepDurabilityDefault || - shortOptions.HeartbeatTimeout != time.Minute { - t.Fatalf("short options = %+v", shortOptions) - } - parallelShortOptions := flow.parallelToolStepOptions(short) - if parallelShortOptions.ExecuteDurability != dex.StepDurabilityDefault || - parallelShortOptions.HeartbeatTimeout != time.Minute { - t.Fatalf("parallel short options = %+v", parallelShortOptions) - } - - long := short - long.RunningType = ToolRunningTypeLongRunning - long.HeartbeatTimeout = 15 * time.Minute - longOptions := flow.toolStepOptions(long) - if longOptions.ExecuteDurability != dex.StepDurabilitySync || - longOptions.HeartbeatTimeout != 15*time.Minute { - t.Fatalf("long options = %+v", longOptions) - } - parallelLongOptions := flow.parallelToolStepOptions(long) - if parallelLongOptions.ExecuteDurability != dex.StepDurabilitySync || - parallelLongOptions.HeartbeatTimeout != 15*time.Minute { - t.Fatalf("parallel long options = %+v", parallelLongOptions) - } -} - -func TestRegisteredStepOptionsUseBoundedTimeoutsAndModelSyncDurability(t *testing.T) { - if defaultStepOptions.WaitForMethodTimeout != time.Minute || - defaultStepOptions.ExecuteMethodTimeout != time.Minute { - t.Fatalf("default Step options = %+v", defaultStepOptions) - } - if modelStepOptions.ExecuteDurability != dex.StepDurabilitySync || - modelStepOptions.ExecuteMethodTimeout != 10*time.Minute || - modelStepOptions.HeartbeatTimeout != 5*time.Minute { - t.Fatalf("model Step options = %+v", modelStepOptions) - } -} - -func TestValidateToolExecutionPolicyRejectsUnknownRunningType(t *testing.T) { - definition := parallelDefinitionForTestOnly("invalid") - definition.RunningType = "sometimes" - err := validateToolExecutionPolicy(definition) - var validationErr *EnumValidationError - if !errors.As(err, &validationErr) || validationErr.Type != "ToolRunningType" { - t.Fatalf("validation error = %T %v", err, err) - } -} - -func TestToolExecutionContextUsesDeclaredAttemptTimeout(t *testing.T) { - started := time.Now() - ctx, cancel := newToolExecutionContext(context.Background(), time.Minute) - defer cancel() - deadline, found := ctx.Deadline() - if !found || deadline.Before(started.Add(59*time.Second)) || deadline.After(started.Add(61*time.Second)) { - t.Fatalf("deadline = %v, found = %t", deadline, found) - } - withoutDeadline, cancelWithoutDeadline := newToolExecutionContext(context.Background(), 0) - defer cancelWithoutDeadline() - if _, found := withoutDeadline.Deadline(); found { - t.Fatal("zero attempt timeout added a deadline") - } -} - func TestValidateToolRecoveryResolutionRequiresAtomicCompleteDecision(t *testing.T) { pending := PendingToolRecovery{ RecoveryID: "recovery-1", diff --git a/internal/agent/types.go b/internal/agent/types.go index 1e07fe8..7289ea2 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -443,39 +443,6 @@ func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error { return decodeEnum(data, outcome, ToolOutcome.Validate) } -// ToolRunningType selects the preferred Dex execution path for one tool. -type ToolRunningType string - -const ( - // ToolRunningTypeShortRunning optimizes for ASYNC local execution with regular fallback. - ToolRunningTypeShortRunning ToolRunningType = "short_running" - // ToolRunningTypeLongRunning starts directly as a regular SYNC activity. - ToolRunningTypeLongRunning ToolRunningType = "long_running" -) - -// Validate rejects unknown tool running types. -func (runningType ToolRunningType) Validate() error { - switch runningType { - case ToolRunningTypeShortRunning, ToolRunningTypeLongRunning: - return nil - default: - return newEnumValidationError("ToolRunningType", string(runningType)) - } -} - -// Effective returns the short-running default for an omitted registry policy. -func (runningType ToolRunningType) Effective() ToolRunningType { - if runningType == "" { - return ToolRunningTypeShortRunning - } - return runningType -} - -// UnmarshalJSON decodes and validates a tool running type. -func (runningType *ToolRunningType) UnmarshalJSON(data []byte) error { - return decodeEnum(data, runningType, ToolRunningType.Validate) -} - // ToolRetryExhaustionPolicy controls what happens after a tool's Dex retries are exhausted. type ToolRetryExhaustionPolicy string @@ -1164,9 +1131,7 @@ type ToolDefinition struct { Description string InputSchema JSONObject RequiresApproval bool - RunningType ToolRunningType AttemptTimeout time.Duration - HeartbeatTimeout time.Duration MaximumAttempts int RetryTotalDuration time.Duration SupportsParallelExecution bool diff --git a/internal/mcp/config.go b/internal/mcp/config.go index de9c56c..0408f6f 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -34,7 +34,6 @@ import ( const ( maximumToolAttempts = 10 maximumToolTimeout = 24 * 60 * 60 - maximumHeartbeatTimeout = 24 * 60 * 60 maximumToolRetrySeconds = 7 * 24 * 60 * 60 ) @@ -118,48 +117,12 @@ func (policy *RetryExhaustionPolicy) UnmarshalYAML(node *yaml.Node) error { return nil } -// RunningType selects the preferred Dex execution path for one MCP tool. -type RunningType string - -const ( - RunningTypeShortRunning RunningType = "short_running" - RunningTypeLongRunning RunningType = "long_running" -) - -// Validate rejects unknown running types. -func (runningType RunningType) Validate() error { - switch runningType { - case RunningTypeShortRunning, RunningTypeLongRunning: - return nil - default: - return fmt.Errorf("unsupported running type %q", runningType) - } -} - -// UnmarshalYAML decodes and validates one running type. -func (runningType *RunningType) UnmarshalYAML(node *yaml.Node) error { - var value string - if err := node.Decode(&value); err != nil { - return err - } - decoded := RunningType(value) - if err := decoded.Validate(); err != nil { - return err - } - *runningType = decoded - return nil -} - // ToolPolicy configures safety and bounded retries for one tool. type ToolPolicy struct { // ReadOnly overrides the tool annotation; nil means unknown. ReadOnly *bool `yaml:"read_only"` // TimeoutSeconds defaults to 60 and bounds one attempt. TimeoutSeconds float64 `yaml:"timeout_seconds"` - // RunningType defaults to short_running for ASYNC local execution with fallback. - RunningType RunningType `yaml:"running_type"` - // HeartbeatTimeoutSeconds defaults to 60 for regular execution. - HeartbeatTimeoutSeconds *float64 `yaml:"heartbeat_timeout_seconds"` // MaximumAttempts defaults to three for trusted reads and one otherwise. MaximumAttempts *int `yaml:"maximum_attempts"` // RetryTotalSeconds defaults to 300 and bounds all attempts. @@ -254,13 +217,6 @@ func applyDefaults(server *ServerConfig) { if policy.RetryTotalSeconds == 0 { policy.RetryTotalSeconds = 300 } - if policy.RunningType == "" { - policy.RunningType = RunningTypeShortRunning - } - if policy.HeartbeatTimeoutSeconds == nil { - value := float64(60) - policy.HeartbeatTimeoutSeconds = &value - } if policy.RetryExhaustionPolicy == "" { policy.RetryExhaustionPolicy = RetryExhaustionPolicyManualRecovery } @@ -312,17 +268,6 @@ func validateServer(server ServerConfig) error { if !isFinitePositive(policy.TimeoutSeconds) || policy.TimeoutSeconds > maximumToolTimeout { return fmt.Errorf("timeout_seconds for %q must be positive and at most %d", name, maximumToolTimeout) } - if err := policy.RunningType.Validate(); err != nil { - return fmt.Errorf("running_type for %q: %w", name, err) - } - if policy.HeartbeatTimeoutSeconds == nil || !isFinitePositive(*policy.HeartbeatTimeoutSeconds) || - *policy.HeartbeatTimeoutSeconds > maximumHeartbeatTimeout { - return fmt.Errorf( - "heartbeat_timeout_seconds for %q must be positive and at most %d", - name, - maximumHeartbeatTimeout, - ) - } if !isFinitePositive(policy.RetryTotalSeconds) || policy.RetryTotalSeconds > maximumToolRetrySeconds { return fmt.Errorf("retry_total_seconds for %q must be positive and at most %d", name, maximumToolRetrySeconds) } @@ -382,10 +327,6 @@ func cloneToolPolicies(source map[string]ToolPolicy) map[string]ToolPolicy { attempts := *policy.MaximumAttempts policy.MaximumAttempts = &attempts } - if policy.HeartbeatTimeoutSeconds != nil { - heartbeatTimeout := *policy.HeartbeatTimeoutSeconds - policy.HeartbeatTimeoutSeconds = &heartbeatTimeout - } if policy.ReadOnly != nil { readOnly := *policy.ReadOnly policy.ReadOnly = &readOnly diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go index 8702fa3..0964d94 100644 --- a/internal/mcp/config_test.go +++ b/internal/mcp/config_test.go @@ -41,49 +41,12 @@ func TestLoadConfigAppliesSafeDefaults(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } policy := servers[0].Tools["query"] - if policy.TimeoutSeconds != 60 || policy.RunningType != RunningTypeShortRunning || - policy.HeartbeatTimeoutSeconds == nil || *policy.HeartbeatTimeoutSeconds != 60 || - policy.RetryTotalSeconds != 300 || + if policy.TimeoutSeconds != 60 || policy.RetryTotalSeconds != 300 || policy.RetryExhaustionPolicy != RetryExhaustionPolicyManualRecovery { t.Fatalf("policy defaults = %+v", policy) } } -func TestLoadConfigAcceptsLongRunningToolPolicy(t *testing.T) { - path := writeConfig(t, `servers: - - name: build - transport: stdio - command: build-server - tools: - compile: - running_type: long_running - heartbeat_timeout_seconds: 900 -`) - servers, err := LoadConfig(path) - if err != nil { - t.Fatal(err) - } - policy := servers[0].Tools["compile"] - if policy.RunningType != RunningTypeLongRunning || policy.HeartbeatTimeoutSeconds == nil || - *policy.HeartbeatTimeoutSeconds != 900 { - t.Fatalf("policy = %+v", policy) - } -} - -func TestLoadConfigRejectsUnknownRunningType(t *testing.T) { - path := writeConfig(t, `servers: - - name: build - transport: stdio - command: build-server - tools: - compile: - running_type: sometimes -`) - if _, err := LoadConfig(path); err == nil { - t.Fatal("LoadConfig() error = nil") - } -} - func TestLoadConfigAcceptsAutomaticUnknownRecovery(t *testing.T) { path := writeConfig(t, `servers: - name: search @@ -194,22 +157,6 @@ func TestLoadConfigRejectsUnsafeRetryPolicy(t *testing.T) { } } -func TestLoadConfigRejectsUnsafeHeartbeatTimeout(t *testing.T) { - for _, value := range []string{".nan", "0", "-1"} { - path := writeConfig(t, `servers: - - name: search - transport: stdio - command: search-server - tools: - query: - heartbeat_timeout_seconds: `+value+` -`) - if _, err := LoadConfig(path); err == nil { - t.Fatalf("heartbeat_timeout_seconds %s: LoadConfig() error = nil", value) - } - } -} - func TestResolveEnvironmentRequiresEveryConfiguredSource(t *testing.T) { const missing = "SUPERAGENT_TEST_MISSING_MCP_SECRET" t.Setenv(missing, "present") diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 3447e54..d469d8f 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -39,10 +39,9 @@ import ( ) const ( - defaultToolTimeout = 60 * time.Second - defaultToolHeartbeatTimeout = time.Minute - defaultRetryDuration = 5 * time.Minute - maximumPublicNameSize = 64 + defaultToolTimeout = 60 * time.Second + defaultRetryDuration = 5 * time.Minute + maximumPublicNameSize = 64 ) var invalidNameCharacter = regexp.MustCompile(`[^A-Za-z0-9_]`) @@ -442,11 +441,9 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo policy, configured := server.Tools[tool.Name] if !configured { policy = ToolPolicy{ - TimeoutSeconds: 60, - RunningType: RunningTypeShortRunning, - HeartbeatTimeoutSeconds: float64Pointer(60), - RetryTotalSeconds: 300, - RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, + TimeoutSeconds: 60, + RetryTotalSeconds: 300, + RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, } } readOnly := policy.ReadOnly @@ -469,26 +466,13 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo if attemptTimeout == 0 { attemptTimeout = defaultToolTimeout } - heartbeatTimeout := defaultToolHeartbeatTimeout - if policy.HeartbeatTimeoutSeconds != nil { - heartbeatTimeout = time.Duration(*policy.HeartbeatTimeoutSeconds * float64(time.Second)) - } retryDuration := time.Duration(policy.RetryTotalSeconds * float64(time.Second)) if retryDuration == 0 { retryDuration = defaultRetryDuration } - if maximumAttempts <= 0 || attemptTimeout <= 0 || heartbeatTimeout <= 0 || retryDuration <= 0 { + if maximumAttempts <= 0 || attemptTimeout <= 0 || retryDuration <= 0 { return agent.RegisteredTool{}, fmt.Errorf("invalid policy for %q", publicName) } - var runningType agent.ToolRunningType - switch policy.RunningType { - case "", RunningTypeShortRunning: - runningType = agent.ToolRunningTypeShortRunning - case RunningTypeLongRunning: - runningType = agent.ToolRunningTypeLongRunning - default: - return agent.RegisteredTool{}, fmt.Errorf("invalid running type %q for %q", policy.RunningType, publicName) - } inputSchema, err := schemaObject(tool.InputSchema) if err != nil { return agent.RegisteredTool{}, fmt.Errorf("tool %q input schema: %w", publicName, err) @@ -505,9 +489,7 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo Description: description, InputSchema: inputSchema, RequiresApproval: readOnly == nil || !*readOnly, - RunningType: runningType, AttemptTimeout: attemptTimeout, - HeartbeatTimeout: heartbeatTimeout, MaximumAttempts: maximumAttempts, RetryTotalDuration: retryDuration, SupportsParallelExecution: readOnly != nil && *readOnly, @@ -516,10 +498,6 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo }, nil } -func float64Pointer(value float64) *float64 { - return &value -} - func schemaObject(value any) (agent.JSONObject, error) { encoded, err := json.Marshal(value) if err != nil { diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index effb8b3..baf3351 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -21,10 +21,8 @@ import ( "log/slog" "strings" "testing" - "time" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/superdurable/superagent/internal/agent" ) func TestRegisteredToolDefaultsWritesToOneAttemptAndApproval(t *testing.T) { @@ -41,34 +39,11 @@ func TestRegisteredToolDefaultsWritesToOneAttemptAndApproval(t *testing.T) { if err != nil { t.Fatalf("registeredTool() error = %v", err) } - if !registered.Definition.RequiresApproval || registered.Definition.MaximumAttempts != 1 || - registered.Definition.RunningType != agent.ToolRunningTypeShortRunning || - registered.Definition.HeartbeatTimeout != time.Minute { + if !registered.Definition.RequiresApproval || registered.Definition.MaximumAttempts != 1 { t.Fatalf("unsafe defaults = %+v", registered.Definition) } } -func TestRegisteredToolProjectsLongRunningPolicy(t *testing.T) { - registered, err := registeredTool(ServerConfig{ - Name: "build", - Transport: TransportStdio, - Command: "server", - Tools: map[string]ToolPolicy{ - "compile": { - RunningType: RunningTypeLongRunning, - HeartbeatTimeoutSeconds: float64Pointer(900), - }, - }, - }, &mcpsdk.Tool{Name: "compile", InputSchema: map[string]any{"type": "object"}}) - if err != nil { - t.Fatal(err) - } - if registered.Definition.RunningType != agent.ToolRunningTypeLongRunning || - registered.Definition.HeartbeatTimeout != 15*time.Minute { - t.Fatalf("definition = %+v", registered.Definition) - } -} - func TestRegisteredToolTrustsReadOnlyAnnotationOnlyWhenConfigured(t *testing.T) { readOnly := &mcpsdk.Tool{ Name: "search", diff --git a/script/check_dex_release.py b/script/check_dex_release.py new file mode 100644 index 0000000..50f47c7 --- /dev/null +++ b/script/check_dex_release.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify SuperAgent's direct Dex dependency against its immutable release lock.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re + +import update_dex_release + + +ROOT = Path(__file__).resolve().parents[1] + + +def main() -> None: + lock = json.loads((ROOT / "dex-release.lock.json").read_text(encoding="utf-8")) + if set(lock) != { + "schemaVersion", + "release", + "manifest", + "sourceCommit", + "sdkGoVersion", + "protocol", + "runningFlowsCompatibility", + "persistenceCompatibility", + "openFlowsCompatibility", + }: + raise update_dex_release.UpgradeError("Dex release lock has unexpected fields") + content = update_dex_release.download(lock["manifest"]["url"]) + manifest = update_dex_release.validate_manifest( + lock["manifest"]["url"], lock["manifest"]["sha256"], content + ) + requirements = dict( + re.findall(r"(?m)^\s*([^\s()]+)\s+(v[^\s]+)(?:\s+//.*)?$", (ROOT / "go.mod").read_text(encoding="utf-8")) + ) + update_dex_release.require(lock["schemaVersion"] == 1, "unsupported Dex release lock") + update_dex_release.require(lock["release"] == manifest["release"], "Dex release mismatch") + update_dex_release.require( + lock["sourceCommit"] == manifest["sourceCommit"], "Dex source commit mismatch" + ) + update_dex_release.require( + lock["sdkGoVersion"] == manifest["components"]["sdkGo"]["version"], + "Dex Go SDK version mismatch", + ) + update_dex_release.require( + requirements.get("github.com/superdurable/dex/sdk-go") == f'v{lock["sdkGoVersion"]}', + "SuperAgent must directly require the locked Dex Go SDK", + ) + update_dex_release.require( + lock["protocol"] == manifest["protocol"]["clients"]["sdkGo"], + "Dex protocol mismatch", + ) + for field in ("runningFlowsCompatibility", "persistenceCompatibility"): + update_dex_release.require(lock[field] == manifest[field], f"Dex {field} mismatch") + update_dex_release.require( + lock["openFlowsCompatibility"] in {"compatible", "cancel-required"}, + "invalid open Flow compatibility", + ) + print(f'SuperAgent directly requires locked Dex {lock["release"]}') + + +if __name__ == "__main__": + main() diff --git a/script/check_dex_versions.py b/script/check_dex_versions.py deleted file mode 100644 index 58179b9..0000000 --- a/script/check_dex_versions.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -"""Verify SuperAgent's direct Dex Go SDK and dexcli version pins.""" - -from __future__ import annotations - -from pathlib import Path -import re - - -ROOT = Path(__file__).resolve().parents[1] -SEMVER = r"[0-9]+\.[0-9]+\.[0-9]+" - - -class DexVersionError(RuntimeError): - """A Dex component version pin is missing or inconsistent.""" - - -def require_match(pattern: str, content: str, label: str) -> re.Match[str]: - match = re.search(pattern, content, flags=re.MULTILINE) - if match is None: - raise DexVersionError(f"{label} version pin is missing") - return match - - -def read_versions(root: Path) -> tuple[str, str]: - go_mod = (root / "go.mod").read_text(encoding="utf-8") - sdk_version = require_match( - rf"^\s*github\.com/superdurable/dex/sdk-go\s+v({SEMVER})\s*$", - go_mod, - "Dex Go SDK", - ).group(1) - - makefile = (root / "Makefile").read_text(encoding="utf-8") - dexcli_version = require_match( - rf"^DEXCLI_VERSION := v({SEMVER})$", makefile, "dexcli Makefile" - ).group(1) - - installer = (root / "script/install-dexcli.sh").read_text(encoding="utf-8") - archives = re.findall( - r"dexcli_v([0-9]+\.[0-9]+\.[0-9]+)_(?:darwin|linux)_(?:amd64|arm64)\.tar\.gz", - installer, - ) - if len(archives) != 4 or set(archives) != {dexcli_version}: - raise DexVersionError("dexcli installer versions do not match the Makefile") - - workflow = (root / ".github/workflows/ci.yml").read_text(encoding="utf-8") - expected_binary = f".cache/dexcli-v{dexcli_version} dev" - if expected_binary not in workflow: - raise DexVersionError("dexcli CI version does not match the Makefile") - return sdk_version, dexcli_version - - -def main() -> None: - sdk_version, dexcli_version = read_versions(ROOT) - print(f"SuperAgent uses Dex Go SDK {sdk_version} and dexcli {dexcli_version}") - - -if __name__ == "__main__": - main() diff --git a/script/install-dexcli.sh b/script/install-dexcli.sh index cfb2c0a..4aef5d3 100755 --- a/script/install-dexcli.sh +++ b/script/install-dexcli.sh @@ -22,10 +22,10 @@ esac archive_name="dexcli_${version}_${operating_system}_${architecture}.tar.gz" case "$archive_name" in - dexcli_v0.10.0_darwin_amd64.tar.gz) checksum=927d48d360da5183b4956823e827f890fde6da8756a954e5f647098e0e6c348a ;; - dexcli_v0.10.0_darwin_arm64.tar.gz) checksum=1f9c12be1a8b4c7f65af57b93db2a125ff70be63daaf98e902a2b792b095bf97 ;; - dexcli_v0.10.0_linux_amd64.tar.gz) checksum=0cee3b0795147b581c45d2258b0c2d581cd35ce75c515027e2d9b294ce364e2d ;; - dexcli_v0.10.0_linux_arm64.tar.gz) checksum=6ce2d4cdc8a2d91b6fba0c549bdf238ef8d210de69cd140bf60deef59c1412f4 ;; + dexcli_v0.9.0_darwin_amd64.tar.gz) checksum=071f530422e869554b2e2a2dc10ce5d917e1093a38a5af4e1438192e9c532408 ;; + dexcli_v0.9.0_darwin_arm64.tar.gz) checksum=4ee2df39d0218169b5fe0fc581e9cac2c1f40e24a011ac5c5ba441eccdfd1f51 ;; + dexcli_v0.9.0_linux_amd64.tar.gz) checksum=0df459cdde367191e7c962b819a1491073b614da93f5459129f38f90970a7016 ;; + dexcli_v0.9.0_linux_arm64.tar.gz) checksum=68f5771cde6ae4a1cfb8c78efb35881765273d4727d6353de41d6b4252476d67 ;; *) echo "no checksum is pinned for $archive_name" >&2; exit 1 ;; esac diff --git a/script/testdata/public-api-consumer/consumer_test.go b/script/testdata/public-api-consumer/consumer_test.go index 361462d..29c30b7 100644 --- a/script/testdata/public-api-consumer/consumer_test.go +++ b/script/testdata/public-api-consumer/consumer_test.go @@ -20,7 +20,6 @@ import ( "context" "net/http" "testing" - "time" "github.com/superdurable/dex/sdk-go/dex" "github.com/superdurable/superagent/agent" @@ -79,7 +78,6 @@ var ( _ agent.EventKind = agent.EventKindPlanTaskUpdated _ agent.EventKind = agent.EventKindInputConsumed _ agent.EventKind = agent.EventKindSnapshotRequired - _ agent.ToolRunningType = agent.ToolRunningTypeShortRunning _ agent.PlanTaskIndex = 0 ) @@ -107,13 +105,6 @@ func TestExternalModuleCanConstructAndRegisterAgent(t *testing.T) { if agent.MaximumRuntimeMetadataBytes != 16<<10 { t.Fatal("public runtime metadata limit is unavailable") } - definition := agent.ToolDefinition{ - RunningType: agent.ToolRunningTypeLongRunning, - HeartbeatTimeout: time.Minute, - } - if definition.RunningType != agent.ToolRunningTypeLongRunning { - t.Fatal("public tool running policy is unavailable") - } } func TestExternalModuleCanConstructProviderRouter(t *testing.T) { diff --git a/script/update_dex_release.py b/script/update_dex_release.py new file mode 100644 index 0000000..cca51a5 --- /dev/null +++ b/script/update_dex_release.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare a reviewed SuperAgent upgrade from one immutable Dex manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +import re +import urllib.request +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") +SHA256 = re.compile(r"[0-9a-f]{64}") +DEX_MANIFEST_URL = re.compile( + r"https://github\.com/superdurable/dex/releases/download/server/v" + r"([0-9]+\.[0-9]+\.[0-9]+)/dex-compatibility-v\1\.json" +) + + +class UpgradeError(RuntimeError): + """The requested Dex upgrade is incomplete or inconsistent.""" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise UpgradeError(message) + + +def download(url: str) -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "superagent-dex-upgrade/1"}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read() + + +def replace_once(path: Path, pattern: str, replacement: str) -> None: + content = path.read_text(encoding="utf-8") + updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) + require(count == 1, f"expected one version pin in {path}") + path.write_text(updated, encoding="utf-8") + + +def validate_manifest( + manifest_url: str, + manifest_sha256: str, + content: bytes, +) -> dict[str, Any]: + match = DEX_MANIFEST_URL.fullmatch(manifest_url) + require(match is not None, "manifest URL is not an immutable Dex Server release asset") + require(SHA256.fullmatch(manifest_sha256) is not None, "manifest SHA-256 is invalid") + require(hashlib.sha256(content).hexdigest() == manifest_sha256, "manifest SHA-256 mismatch") + manifest = json.loads(content) + version = match.group(1) + require(manifest["release"] == version, "manifest release does not match its URL") + require(manifest["rolloutOrder"] == "server-first", "Dex rollout must be server-first") + require( + manifest["components"]["sdkGo"]["version"] == version, + "Dex Go SDK version does not match the release", + ) + server_protocol = manifest["protocol"]["server"] + go_protocol = manifest["protocol"]["clients"]["sdkGo"] + require( + max(server_protocol["minimum"], go_protocol["minimum"]) + <= min(server_protocol["maximum"], go_protocol["maximum"]), + "Dex Go SDK and Server protocols are incompatible", + ) + require(manifest["persistenceCompatibility"] == "compatible", "Dex persistence is incompatible") + return manifest + + +def update_repository( + root: Path, + manifest_url: str, + manifest_sha256: str, + manifest: dict[str, Any], +) -> None: + version = manifest["release"] + checksums = manifest["components"]["cli"]["checksums"] + archives = tuple( + f"dexcli_v{version}_{platform}_{architecture}.tar.gz" + for platform in ("darwin", "linux") + for architecture in ("amd64", "arm64") + ) + require(set(checksums) == set(archives), "Dex CLI checksums are incomplete") + replace_once( + root / "go.mod", + r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", + rf"\g<1>v{version}", + ) + replace_once(root / "Makefile", r"^DEXCLI_VERSION := v[^\s]+$", f"DEXCLI_VERSION := v{version}") + installer = root / "script/install-dexcli.sh" + installer_content = installer.read_text(encoding="utf-8") + cases = "\n".join( + f" {archive}) checksum={checksums[archive]} ;;" + for archive in archives + ) + updated, count = re.subn( + r" dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+", + cases, + installer_content, + count=1, + ) + require(count == 1, "expected four Dex CLI checksum pins") + installer.write_text(updated, encoding="utf-8") + + lock = { + "schemaVersion": 1, + "release": version, + "manifest": {"url": manifest_url, "sha256": manifest_sha256}, + "sourceCommit": manifest["sourceCommit"], + "sdkGoVersion": manifest["components"]["sdkGo"]["version"], + "protocol": manifest["protocol"]["clients"]["sdkGo"], + "runningFlowsCompatibility": manifest["runningFlowsCompatibility"], + "persistenceCompatibility": manifest["persistenceCompatibility"], + "openFlowsCompatibility": "cancel-required", + } + (root / "dex-release.lock.json").write_text( + json.dumps(lock, indent=2) + "\n", + encoding="utf-8", + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--manifest-url", required=True) + parser.add_argument("--manifest-sha256", required=True) + args = parser.parse_args() + content = download(args.manifest_url) + manifest = validate_manifest(args.manifest_url, args.manifest_sha256, content) + update_repository(ROOT, args.manifest_url, args.manifest_sha256, manifest) + print(f'Prepared SuperAgent for Dex {manifest["release"]}; open Flows require review') + + +if __name__ == "__main__": + main() diff --git a/script/update_dex_release_test.py b/script/update_dex_release_test.py new file mode 100644 index 0000000..77be304 --- /dev/null +++ b/script/update_dex_release_test.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Super Durable, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import copy +import hashlib +import importlib.util +import json +from pathlib import Path +import sys +import tempfile +import unittest + + +MODULE_PATH = Path(__file__).with_name("update_dex_release.py") +SPEC = importlib.util.spec_from_file_location("update_dex_release", MODULE_PATH) +assert SPEC and SPEC.loader +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +def manifest() -> dict[str, object]: + return { + "release": "1.2.3", + "sourceCommit": "a" * 40, + "rolloutOrder": "server-first", + "runningFlowsCompatibility": "compatible", + "persistenceCompatibility": "compatible", + "protocol": { + "server": {"minimum": 2, "maximum": 3}, + "clients": {"sdkGo": {"minimum": 2, "maximum": 3}}, + }, + "components": { + "sdkGo": {"version": "1.2.3"}, + "cli": { + "checksums": { + "dexcli_v1.2.3_darwin_amd64.tar.gz": "1" * 64, + "dexcli_v1.2.3_darwin_arm64.tar.gz": "2" * 64, + "dexcli_v1.2.3_linux_amd64.tar.gz": "3" * 64, + "dexcli_v1.2.3_linux_arm64.tar.gz": "4" * 64, + } + }, + }, + } + + +class UpdateDexReleaseTests(unittest.TestCase): + def test_validates_and_updates_all_superagent_pins(self) -> None: + value = manifest() + content = (json.dumps(value) + "\n").encode() + digest = hashlib.sha256(content).hexdigest() + url = ( + "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" + "dex-compatibility-v1.2.3.json" + ) + validated = MODULE.validate_manifest(url, digest, content) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "script").mkdir() + (root / "go.mod").write_text( + "require github.com/superdurable/dex/sdk-go v0.9.0\n", encoding="utf-8" + ) + (root / "Makefile").write_text("DEXCLI_VERSION := v0.9.0\n", encoding="utf-8") + (root / "script/install-dexcli.sh").write_text( + "case x in\n" + + "\n".join(f" dexcli_v0.9.0_{name}.tar.gz) checksum=old ;;" for name in ( + "darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64" + )) + + "\nesac\n", + encoding="utf-8", + ) + MODULE.update_repository(root, url, digest, validated) + lock = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) + self.assertEqual(lock["release"], "1.2.3") + self.assertEqual(lock["protocol"], {"minimum": 2, "maximum": 3}) + self.assertEqual(lock["openFlowsCompatibility"], "cancel-required") + self.assertIn("sdk-go v1.2.3", (root / "go.mod").read_text(encoding="utf-8")) + self.assertIn("DEXCLI_VERSION := v1.2.3", (root / "Makefile").read_text(encoding="utf-8")) + self.assertIn("checksum=" + "4" * 64, (root / "script/install-dexcli.sh").read_text(encoding="utf-8")) + + def test_rejects_tampering_and_incompatible_protocol(self) -> None: + value = manifest() + content = (json.dumps(value) + "\n").encode() + url = ( + "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" + "dex-compatibility-v1.2.3.json" + ) + with self.assertRaisesRegex(MODULE.UpgradeError, "SHA-256 mismatch"): + MODULE.validate_manifest(url, "0" * 64, content) + incompatible = copy.deepcopy(value) + incompatible["protocol"]["clients"]["sdkGo"] = {"minimum": 4, "maximum": 4} + incompatible_content = (json.dumps(incompatible) + "\n").encode() + with self.assertRaisesRegex(MODULE.UpgradeError, "protocols are incompatible"): + MODULE.validate_manifest( + url, + hashlib.sha256(incompatible_content).hexdigest(), + incompatible_content, + ) + + def test_upgrade_workflow_opens_a_draft_before_product_ci(self) -> None: + workflow = (MODULE.ROOT / ".github/workflows/dex-release-upgrade.yml").read_text( + encoding="utf-8" + ) + self.assertIn("draft: true", workflow) + self.assertIn("Normal pull-request CI owns compilation and tests", workflow) + self.assertNotIn("make governance-check format-check vet test", workflow) + self.assertLess( + workflow.index("Update immutable Dex release pins"), + workflow.index("Open draft upgrade pull request before compilation"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/script/update_dex_versions.py b/script/update_dex_versions.py deleted file mode 100644 index d0e486e..0000000 --- a/script/update_dex_versions.py +++ /dev/null @@ -1,117 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -"""Update SuperAgent's independent Dex Go SDK and dexcli pins.""" - -from __future__ import annotations - -import argparse -from pathlib import Path -import re -import urllib.request - - -ROOT = Path(__file__).resolve().parents[1] -SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") -PLATFORMS = ("darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64") - - -class DexVersionError(RuntimeError): - """A requested Dex component version or release asset is invalid.""" - - -def require(condition: bool, message: str) -> None: - if not condition: - raise DexVersionError(message) - - -def replace_once(path: Path, pattern: str, replacement: str) -> None: - content = path.read_text(encoding="utf-8") - updated, count = re.subn(pattern, replacement, content, count=1, flags=re.MULTILINE) - require(count == 1, f"expected one version pin in {path}") - path.write_text(updated, encoding="utf-8") - - -def download_cli_checksums(version: str) -> bytes: - url = f"https://github.com/superdurable/dex/releases/download/cli-v{version}/checksums.txt" - request = urllib.request.Request(url, headers={"User-Agent": "superagent-dexcli-upgrade/1"}) - with urllib.request.urlopen(request, timeout=30) as response: - return response.read() - - -def parse_cli_checksums(version: str, content: bytes) -> dict[str, str]: - checksums: dict[str, str] = {} - for line in content.decode("utf-8").splitlines(): - match = re.fullmatch(r"([0-9a-f]{64})\s+\*?\.?/?(dexcli_v[^/\s]+\.tar\.gz)", line) - if match is not None: - checksums[match.group(2)] = match.group(1) - expected = {f"dexcli_v{version}_{platform}.tar.gz" for platform in PLATFORMS} - require(set(checksums) == expected, "dexcli checksums.txt does not contain the four release archives") - return checksums - - -def update_repository( - root: Path, - sdk_go_version: str, - dexcli_version: str, - cli_checksums: dict[str, str], -) -> None: - require(SEMVER.fullmatch(sdk_go_version) is not None, "invalid Dex Go SDK version") - require(SEMVER.fullmatch(dexcli_version) is not None, "invalid dexcli version") - replace_once( - root / "go.mod", - r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", - rf"\g<1>v{sdk_go_version}", - ) - replace_once( - root / "Makefile", r"^DEXCLI_VERSION := v[^\s]+$", f"DEXCLI_VERSION := v{dexcli_version}" - ) - installer = root / "script/install-dexcli.sh" - cases = "\n".join( - f" {archive}) checksum={cli_checksums[archive]} ;;" - for archive in sorted(cli_checksums) - ) - content = installer.read_text(encoding="utf-8") - updated, count = re.subn( - r" dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+\n dexcli_v[^\n]+", - cases, - content, - count=1, - ) - require(count == 1, "expected four dexcli checksum pins") - installer.write_text(updated, encoding="utf-8") - - -def main() -> None: - parser = argparse.ArgumentParser() - parser.add_argument("--sdk-go-version", required=True) - parser.add_argument("--dexcli-version", required=True) - parser.add_argument("--checksums-file", type=Path) - arguments = parser.parse_args() - checksum_content = ( - arguments.checksums_file.read_bytes() - if arguments.checksums_file is not None - else download_cli_checksums(arguments.dexcli_version) - ) - checksums = parse_cli_checksums(arguments.dexcli_version, checksum_content) - update_repository(ROOT, arguments.sdk_go_version, arguments.dexcli_version, checksums) - print( - f"Updated Dex Go SDK to {arguments.sdk_go_version} and dexcli to {arguments.dexcli_version}" - ) - - -if __name__ == "__main__": - main() diff --git a/script/update_dex_versions_test.py b/script/update_dex_versions_test.py deleted file mode 100644 index 6e5df35..0000000 --- a/script/update_dex_versions_test.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -# Copyright (c) 2026 Super Durable, Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import importlib.util -from pathlib import Path -import sys -import tempfile -import unittest - - -def load_module(name: str): - path = Path(__file__).with_name(f"{name}.py") - spec = importlib.util.spec_from_file_location(name, path) - assert spec and spec.loader - module = importlib.util.module_from_spec(spec) - sys.modules[spec.name] = module - spec.loader.exec_module(module) - return module - - -CHECK = load_module("check_dex_versions") -UPDATE = load_module("update_dex_versions") - - -def checksums(version: str) -> bytes: - return "\n".join( - f"{'1234abcd' * 8} ./dexcli_v{version}_{platform}.tar.gz" - for platform in UPDATE.PLATFORMS - ).encode() - - -class DexVersionTests(unittest.TestCase): - def test_updates_and_checks_independent_component_versions(self) -> None: - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "script").mkdir() - (root / ".github/workflows").mkdir(parents=True) - (root / "go.mod").write_text( - "require (\n\tgithub.com/superdurable/dex/sdk-go v0.9.0\n)\n", encoding="utf-8" - ) - (root / "Makefile").write_text("DEXCLI_VERSION := v0.9.0\n", encoding="utf-8") - (root / "script/install-dexcli.sh").write_text( - "\n".join( - f" dexcli_v0.9.0_{platform}.tar.gz) checksum=old ;;" - for platform in UPDATE.PLATFORMS - ) - + "\n", - encoding="utf-8", - ) - (root / ".github/workflows/ci.yml").write_text( - ".cache/dexcli-v1.2.4 dev\n", encoding="utf-8" - ) - parsed = UPDATE.parse_cli_checksums("1.2.4", checksums("1.2.4")) - UPDATE.update_repository(root, "1.2.3", "1.2.4", parsed) - self.assertEqual(CHECK.read_versions(root), ("1.2.3", "1.2.4")) - - def test_rejects_incomplete_checksums_and_version_drift(self) -> None: - with self.assertRaisesRegex(UPDATE.DexVersionError, "four release archives"): - UPDATE.parse_cli_checksums("1.2.3", b"") - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - (root / "script").mkdir() - (root / ".github/workflows").mkdir(parents=True) - (root / "go.mod").write_text( - "require (\n\tgithub.com/superdurable/dex/sdk-go v1.2.3\n)\n", encoding="utf-8" - ) - (root / "Makefile").write_text("DEXCLI_VERSION := v1.2.3\n", encoding="utf-8") - (root / "script/install-dexcli.sh").write_text( - "\n".join( - f" dexcli_v1.2.2_{platform}.tar.gz) checksum=old ;;" - for platform in UPDATE.PLATFORMS - ), - encoding="utf-8", - ) - (root / ".github/workflows/ci.yml").write_text( - ".cache/dexcli-v1.2.3 dev\n", encoding="utf-8" - ) - with self.assertRaisesRegex(CHECK.DexVersionError, "installer versions"): - CHECK.read_versions(root) - - -if __name__ == "__main__": - unittest.main() diff --git a/web/mcp-servers.example.yaml b/web/mcp-servers.example.yaml index 194c9aa..6446842 100644 --- a/web/mcp-servers.example.yaml +++ b/web/mcp-servers.example.yaml @@ -9,11 +9,7 @@ servers: tools: brave_web_search: read_only: true - # Defaults to short_running. Use long_running when most calls exceed five seconds. - running_type: short_running timeout_seconds: 30 - # Defaults to 60. Raise only for healthy tools with longer silent intervals. - heartbeat_timeout_seconds: 60 maximum_attempts: 3 retry_total_seconds: 120 # Defaults to manual_recovery. Use this only when automatic progress is safe. diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 4c8c4dc..e2bcae0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -291,35 +291,6 @@ describe("App", () => { }); }); - it("reconciles when a recovered Stream tail requires a Snapshot", async () => { - vi.mocked(listRecentEvents).mockImplementation(({ query }) => - Promise.resolve({ - events: - query.stream === EventStream.ACTIVITY - ? [ - activityEvent( - "snapshot-required-recovered", - EventKind.SNAPSHOT_REQUIRED, - "Durable interaction state changed.", - "2026-09-03T00:01:00Z", - ), - ] - : [], - }), - ); - window.history.replaceState({}, "", "/?flowId=flow-existing"); - - render(); - - await screen.findByRole("heading", { name: "SuperAgent" }); - await waitFor(() => { - expect(getAgentSnapshot).toHaveBeenCalledTimes(3); - }); - expect( - screen.queryByText("Durable interaction state changed."), - ).not.toBeInTheDocument(); - }); - it("starts through the generated client and loads one Snapshot", async () => { render(); const button = await screen.findByRole("button", { name: "Start agent" }); diff --git a/web/src/Conversation.tsx b/web/src/Conversation.tsx index d9eda7c..6291208 100644 --- a/web/src/Conversation.tsx +++ b/web/src/Conversation.tsx @@ -174,15 +174,10 @@ export function Conversation({ const newest = recent.events.at(-1); resumeToken = newest?.resumeToken; resumeTokens.current[stream] = resumeToken; - const updates = recent.events.map((event) => - liveUpdate(stream, event), - ); - dispatch({ type: "stream-recovered", updates }); - if (updates.some(shouldReconcileAfter)) { - requestSnapshot({ blocking: false }); - // Stream visibility can precede the durable wait commit. - requestSnapshot({ blocking: false }); - } + dispatch({ + type: "stream-recovered", + updates: recent.events.map((event) => liveUpdate(stream, event)), + }); } catch (reason: unknown) { if (isAbortError(reason)) return; isCurrent = false; From 1bfd02f1ee81cef78894b3276fa3c1cfe9b657e1 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 18:15:17 -0700 Subject: [PATCH 2/8] feat: optimize step durability and tool execution --- Makefile | 4 + README.md | 6 + agent/agent.go | 4 + docs/adr/0011-dex-owned-tool-retries.md | 5 + docs/flow-model.md | 20 +- internal/agent/client.go | 72 +++++-- internal/agent/client_test.go | 17 +- internal/agent/flow.go | 179 ++++++++---------- internal/agent/tool_recovery_test.go | 69 +++++++ internal/agent/types.go | 35 ++++ internal/mcp/config.go | 59 ++++++ internal/mcp/config_test.go | 55 +++++- internal/mcp/registry.go | 36 +++- internal/mcp/registry_test.go | 27 ++- .../public-api-consumer/consumer_test.go | 9 + web/mcp-servers.example.yaml | 4 + 16 files changed, 472 insertions(+), 129 deletions(-) diff --git a/Makefile b/Makefile index 1b9c406..00b7d03 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,10 @@ check-flow-definition: install-dexcli sed -n '/"diagnostics"/,$$p' "$${flow_definition}" >&2; \ exit 1; \ fi; \ + if grep -Fq '"name": "ExecuteTool"' "$${flow_definition}"; then \ + echo "Flow definition must not contain the removed ExecuteTool Step" >&2; \ + exit 1; \ + fi; \ for channel in answeredUserInputsChannel queuedUserMessagesChannel steeredUserMessagesChannel toolApprovalsChannel toolRecoveryDecisionsChannel parallelToolResultsChannel planExecutionsChannel; do \ if ! grep -Fq "\"id\": \"resource:channel:$${channel}\"" "$${flow_definition}" || \ ! grep -Fq "\"resourceId\": \"resource:channel:$${channel}\"" "$${flow_definition}"; then \ diff --git a/README.md b/README.md index ee21b3b..5f27704 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,12 @@ persisted in Dex state or logged. Copy [`web/mcp-servers.example.yaml`](web/mcp-servers.example.yaml) to configure trusted MCP servers. +Each configured tool defaults to `running_type: short_running` and a 60-second +heartbeat timeout. Use `long_running` when more than half of expected calls +exceed five seconds. This is a Dex placement optimization, not a timeout or +SLA; short-running calls may fall back and complete normally. Keep the +heartbeat default unless a healthy tool can remain silent longer. + For a cross-origin frontend deployment, add its exact origin to `SUPERAGENT_HTTP_ALLOWED_ORIGINS`. Wildcards and credentialed cross-origin requests are intentionally unsupported. Serve `config.json` with diff --git a/agent/agent.go b/agent/agent.go index 7952152..7a84638 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -125,6 +125,9 @@ const ( ToolOutcomeKnownFailure = agentinternal.ToolOutcomeKnownFailure ToolOutcomeUnknown = agentinternal.ToolOutcomeUnknown + ToolRunningTypeShortRunning = agentinternal.ToolRunningTypeShortRunning + ToolRunningTypeLongRunning = agentinternal.ToolRunningTypeLongRunning + ToolRetryExhaustionPolicyManualRecovery = agentinternal.ToolRetryExhaustionPolicyManualRecovery ToolRetryExhaustionPolicyContinueWithUnknown = agentinternal.ToolRetryExhaustionPolicyContinueWithUnknown @@ -194,6 +197,7 @@ type ( EventKind = agentinternal.EventKind Provider = agentinternal.Provider ToolOutcome = agentinternal.ToolOutcome + ToolRunningType = agentinternal.ToolRunningType ToolRetryExhaustionPolicy = agentinternal.ToolRetryExhaustionPolicy ToolRecoveryResolution = agentinternal.ToolRecoveryResolution ToolRecoveryAction = agentinternal.ToolRecoveryAction diff --git a/docs/adr/0011-dex-owned-tool-retries.md b/docs/adr/0011-dex-owned-tool-retries.md index 577c6d2..17b7943 100644 --- a/docs/adr/0011-dex-owned-tool-retries.md +++ b/docs/adr/0011-dex-owned-tool-retries.md @@ -19,6 +19,11 @@ Go error and use Dex retry. Exhaustion follows the tool definition's recovery policy. It defaults to the manual boundary introduced by ADR 0013; explicitly configured tools may route to `RecoverToolExecution` and continue with unknown. +New Agent Flows default Step durability to ASYNC. Short-running tools inherit +that default and may fall back to regular execution. Long-running tools override +Execute durability to SYNC. Tool policy also supplies heartbeat timeout, while +attempt timeout bounds both Dex execution and the registry child context. + Approval and CallID remain stable across attempts. External effects promise recoverable at-least-once execution, not exactly-once execution. diff --git a/docs/flow-model.md b/docs/flow-model.md index 0469b21..5a02fa1 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -19,6 +19,15 @@ The implementation requires Dex Go SDK and Server `v0.9.0`. Each Provider and MCP calls are external effects and are not part of a Dex transaction. +New Agent Flows set `FlowConfig.StepDurability` to ASYNC. Ordinary Steps inherit +that default. `CompactContext`, `CallModel`, and tools declared long-running +override Execute durability to SYNC. A short-running tool may fall back from +local to regular execution; that is an expected optimization path and does not +change its ASYNC durability. Registry policy supplies each tool's attempt, +heartbeat, retry, and recovery settings. Ordinary Step methods use a one-minute +timeout, while model methods retain their explicit ten-minute timeout and +five-minute heartbeat. + The `v0.9.0` Worker negotiates the highest common protocol with the Server before Attribute index synchronization or Worker binding. Deploy the Server before the Worker. Startup fails when `GetServerInfo` is missing, either interval is @@ -55,9 +64,6 @@ AwaitToolApproval -> next tool or CompactContext (rejected) -> CompactContext (steered) -ExecuteTool - -> next tool or CompactContext (legacy open executions only) - ExecuteToolWithRetry -> next tool or CompactContext (success or known failure) -> RecoverToolExecution (configured automatic unknown) @@ -104,7 +110,6 @@ history, and makes the model replan. | `CheckSteered` | bounded steered batch | Apply steering at a safe boundary or route the explicit continuation | | `RouteTool` | none | Validate built-in arguments and select approval, MCP execution, timer, input, or next-call path | | `AwaitToolApproval` | exact call-ID approval or steering | Persist waiting status; consume one decision or replan on steering | -| `ExecuteTool` | none | Perform one external MCP effect with stable Flow/call identity, then persist its result | | `ExecuteToolWithRetry` | none | Perform one external tool attempt under dynamically selected Dex timeout and retry policy | | `RecoverToolExecution` | none | Record one unknown result for an explicitly configured automatic recovery, then continue | | `ExecuteParallelTool` | none | Perform one bounded-wave branch effect and publish exactly one typed result without shared-state mutation | @@ -226,6 +231,13 @@ every completed Snapshot read. Hidden pages pause the timer and live reads. ## External effects and recovery - Tool execution policy is copied from `ToolDefinition` into Dex StepOptions. +- `short_running` is the default and inherits Flow ASYNC durability. Use + `long_running` when more than half of expected calls are likely to exceed five + seconds; it overrides Execute durability to SYNC. This classification is an + optimization hint, not a runtime guarantee. +- Tool heartbeat defaults to one minute. Increase it only when healthy regular + execution can remain silent for longer. `AttemptTimeout` also bounds the + registry context because ASYNC local execution ignores Dex method timeouts. - The `mock/dex` model alone exposes `simulate_tool_failure`; `/tool-failure` uses it to verify retry exhaustion and the manual recovery surface locally. - Known business failures return a normal tool result. Transient or ambiguous diff --git a/internal/agent/client.go b/internal/agent/client.go index e4bf339..97ea1a8 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -30,6 +30,9 @@ import ( const ( defaultCommandTimeout = 20 * time.Second defaultEventPoll = 20 * time.Second + // Snapshot reads use a shorter budget so clients can retry across continue-as-new. + defaultSnapshotTimeout = 5 * time.Second + maximumSnapshotAttempts = 3 // MaximumRecentEventLimit matches Dex's default maximum Stream list page size. MaximumRecentEventLimit = 1_000 ) @@ -78,10 +81,13 @@ func (client *Client) Start(ctx context.Context, flowID FlowID, request StartReq if err != nil { return "", fmt.Errorf("encode Agent runtime metadata: %w", err) } - runID, err := client.sdk.StartFlow(ctx, client.flow, string(flowID), request.Config, dex.StartFlowOptions{ - IDReusePolicy: dex.IDReuseDisallow, - Attributes: []dex.InitialAttributeDef{initialMetadata}, - }) + runID, err := client.sdk.StartFlow( + ctx, + client.flow, + string(flowID), + request.Config, + newAgentStartFlowOptions(initialMetadata), + ) if err != nil { return "", err } @@ -91,6 +97,17 @@ func (client *Client) Start(ctx context.Context, flowID FlowID, request StartReq return RunID(runID), nil } +func newAgentStartFlowOptions(initialMetadata dex.InitialAttributeDef) dex.StartFlowOptions { + durability := dex.StepDurabilityAsync + return dex.StartFlowOptions{ + IDReusePolicy: dex.IDReuseDisallow, + Attributes: []dex.InitialAttributeDef{initialMetadata}, + ConfigOverride: &dex.FlowConfig{ + StepDurability: &durability, + }, + } +} + // SendMessage invokes the durable SendMessage command. func (client *Client) SendMessage(ctx context.Context, flowID FlowID, message UserMessage) error { if err := validateFlowID(flowID); err != nil { @@ -204,27 +221,46 @@ func (client *Client) GetSnapshot( if statusErr == nil && current != nil && current.Status != dex.FlowRunning { return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) } + var inactiveErr error + for range maximumSnapshotAttempts { + snapshot, err := client.invokeSnapshotRPC(ctx, flowID) + if err == nil { + return snapshot, nil + } + var inactive *dex.FlowNotActiveError + if !errors.As(err, &inactive) { + return AgentSnapshot{}, err + } + inactiveErr = err + current, statusErr = client.latestAgentRun(ctx, flowID) + if statusErr != nil { + return AgentSnapshot{}, errors.Join(err, statusErr) + } + if current == nil || current.Status == dex.FlowRunning { + continue + } + return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) + } + return AgentSnapshot{}, inactiveErr +} + +func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (AgentSnapshot, error) { + timeout := client.commandTimeout + if timeout <= 0 || timeout > defaultSnapshotTimeout { + timeout = defaultSnapshotTimeout + } + rpcContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() var snapshot AgentSnapshot - err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetSnapshot, nil, &snapshot, dex.InvokeOptions{ - Timeout: client.commandTimeout, + err := client.sdk.InvokeRPC(rpcContext, string(flowID), client.flow.GetSnapshot, nil, &snapshot, dex.InvokeOptions{ + Timeout: timeout, LoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, LoadChannels: []dex.ChannelDef{ queuedUserMessagesChannel, steeredUserMessagesChannel, }, }) - if err == nil { - return snapshot, nil - } - var inactive *dex.FlowNotActiveError - if !errors.As(err, &inactive) { - return AgentSnapshot{}, err - } - terminal, terminalErr := client.terminalSnapshot(ctx, flowID, "") - if terminalErr != nil { - return AgentSnapshot{}, errors.Join(err, terminalErr) - } - return terminal, nil + return snapshot, err } // GetArchivedMessages reads exactly one immutable history chunk before a sequence boundary. diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index 37b05b3..d19abd1 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -16,7 +16,22 @@ package agent -import "testing" +import ( + "testing" + + "github.com/superdurable/dex/sdk-go/dex" +) + +func TestAgentStartFlowOptionsDefaultStepsToAsyncDurability(t *testing.T) { + t.Parallel() + options := newAgentStartFlowOptions(nil) + if options.ConfigOverride == nil || options.ConfigOverride.StepDurability == nil { + t.Fatalf("ConfigOverride = %+v", options.ConfigOverride) + } + if got := *options.ConfigOverride.StepDurability; got != dex.StepDurabilityAsync { + t.Fatalf("StepDurability = %v, want %v", got, dex.StepDurabilityAsync) + } +} func TestListRecentEventsRejectsInvalidLimits(t *testing.T) { t.Parallel() diff --git a/internal/agent/flow.go b/internal/agent/flow.go index 2397d96..15983a1 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -17,11 +17,11 @@ package agent import ( + "context" "encoding/json" "errors" "fmt" "math" - "reflect" "slices" "strings" "time" @@ -91,7 +91,6 @@ func (flow *Flow) GetSteps() []dex.StepDef { dex.DefineStep(checkSteeredStep{flow: flow}), dex.DefineStep(routeToolStep{flow: flow}), dex.DefineStep(awaitToolApprovalStep{flow: flow}), - dex.DefineStep(executeToolStep{flow: flow}), dex.DefineStep(executeToolWithRetryStep{flow: flow}), dex.DefineStep(recoverToolExecutionStep{flow: flow}), dex.DefineStep(executeParallelToolStep{flow: flow}), @@ -556,6 +555,10 @@ func (flow *Flow) validateConfig(config AgentConfig) error { func validateToolExecutionPolicy(definition ToolDefinition) error { policy := definition.RetryExhaustionPolicy.Effective() + runningType := definition.RunningType.Effective() + if err := runningType.Validate(); err != nil { + return err + } switch { case definition.MaximumAttempts <= 0: return errors.New("maximum attempts must be positive") @@ -563,6 +566,8 @@ func validateToolExecutionPolicy(definition ToolDefinition) error { return errors.New("maximum attempts exceeds the Dex limit") case definition.AttemptTimeout < 0: return errors.New("attempt timeout must not be negative") + case definition.HeartbeatTimeout < 0: + return errors.New("heartbeat timeout must not be negative") case definition.RetryTotalDuration < 0: return errors.New("retry total duration must not be negative") default: @@ -606,7 +611,8 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { } return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, + HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), + ExecuteDurability: toolExecuteDurability(definition), ExecuteLoadAttributeMaps: toolStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ // #nosec G115 -- validateToolExecutionPolicy rejects values outside int32. @@ -620,18 +626,33 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { func (flow *Flow) parallelToolStepOptions(definition ToolDefinition) *dex.StepOptions { return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, + HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), + ExecuteDurability: toolExecuteDurability(definition), ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: int32(definition.MaximumAttempts), // #nosec G115 -- validated before scheduling. TotalDuration: definition.RetryTotalDuration, }, ExecuteFailure: dex.ProceedToOnExecuteFailure( recoverParallelToolExecutionStep{flow: flow}, - nil, + defaultStepOptions, ), } } +func effectiveToolHeartbeatTimeout(definition ToolDefinition) time.Duration { + if definition.HeartbeatTimeout == 0 { + return time.Minute + } + return definition.HeartbeatTimeout +} + +func toolExecuteDurability(definition ToolDefinition) dex.StepDurability { + if definition.RunningType.Effective() == ToolRunningTypeLongRunning { + return dex.StepDurabilitySync + } + return dex.StepDurabilityDefault +} + func (flow *Flow) parallelToolMovements( config AgentConfig, state AgentState, @@ -723,11 +744,27 @@ func (flow *Flow) invocationToolDefinition(config AgentConfig, state AgentState, return ToolDefinition{}, fmt.Errorf("unknown or disabled tool %q", name) } -func (flow *Flow) executeTool(ctx dex.Context, invocation ToolInvocation) (ToolExecutionResult, error) { +func (flow *Flow) executeTool( + ctx dex.Context, + definition ToolDefinition, + invocation ToolInvocation, +) (ToolExecutionResult, error) { if invocation.Name == ToolNameSimulateFailure { return ToolExecutionResult{}, simulatedToolFailureError{} } - return flow.tools.Execute(ctx, invocation) + executionContext, cancel := newToolExecutionContext(ctx, definition.AttemptTimeout) + defer cancel() + return flow.tools.Execute(executionContext, invocation) +} + +func newToolExecutionContext( + ctx context.Context, + attemptTimeout time.Duration, +) (context.Context, context.CancelFunc) { + if attemptTimeout == 0 { + return ctx, func() {} + } + return context.WithTimeout(ctx, attemptTimeout) } func (flow *Flow) beginUserTurn(ctx dex.Context, message UserMessage) (Sequence, error) { @@ -1576,7 +1613,6 @@ const ( continueCompactContext continuation = "compact_context" continueRouteTool continuation = "route_tool" continueAwaitToolApproval continuation = "await_tool_approval" - continueExecuteTool continuation = "execute_tool" continueExecuteToolRetry continuation = "execute_tool_with_retry" continueDurableWait continuation = "durable_wait" @@ -1588,7 +1624,6 @@ const ( stepTypeCheckSteered stepType = "CheckSteered" stepTypeRouteTool stepType = "RouteTool" stepTypeAwaitApproval stepType = "AwaitToolApproval" - stepTypeExecuteTool stepType = "ExecuteTool" stepTypeExecuteRetry stepType = "ExecuteToolWithRetry" stepTypeRecoverTool stepType = "RecoverToolExecution" stepTypeExecuteParallel stepType = "ExecuteParallelTool" @@ -1668,7 +1703,13 @@ type awaitParallelToolResultsInput struct { } var ( + defaultStepOptions = &dex.StepOptions{ + WaitForMethodTimeout: time.Minute, + ExecuteMethodTimeout: time.Minute, + } messageMutationStepOptions = &dex.StepOptions{ + WaitForMethodTimeout: time.Minute, + ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, ExecuteLockAttributes: []dex.AttributeLock{ dex.LockAttribute(pendingUserInputAttribute), @@ -1677,6 +1718,8 @@ var ( }, } awaitUserStepOptions = &dex.StepOptions{ + WaitForMethodTimeout: time.Minute, + ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{ currentMessagesAttribute, }, @@ -1687,6 +1730,8 @@ var ( }, } messageContextStepOptions = &dex.StepOptions{ + WaitForMethodTimeout: time.Minute, + ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: []dex.AttributeDef{ currentMessagesAttribute, archivedMessagesAttribute, @@ -1699,6 +1744,7 @@ var ( modelStepOptions = &dex.StepOptions{ ExecuteMethodTimeout: 10 * time.Minute, HeartbeatTimeout: 5 * time.Minute, + ExecuteDurability: dex.StepDurabilitySync, ExecuteLoadAttributeMaps: messageContextStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: 3, @@ -1707,13 +1753,15 @@ var ( } toolStepOptions = &dex.StepOptions{ ExecuteMethodTimeout: 2 * time.Hour, - HeartbeatTimeout: 5 * time.Minute, + HeartbeatTimeout: time.Minute, ExecuteLoadAttributeMaps: messageMutationStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: 1, }, } manualToolRecoveryStepOptions = &dex.StepOptions{ + WaitForMethodTimeout: time.Minute, + ExecuteMethodTimeout: time.Minute, ExecuteLoadAttributeMaps: messageMutationStepOptions.ExecuteLoadAttributeMaps, ExecuteLockAttributes: []dex.AttributeLock{ dex.LockAttribute(pendingToolRecoveryAttribute), @@ -1730,6 +1778,8 @@ var _ dex.Step[AgentConfig] = initStep{} func (initStep) GetStepType() string { return string(stepTypeInit) } +func (initStep) GetStepOptions() *dex.StepOptions { return defaultStepOptions } + func (step initStep) Execute(ctx dex.Context, input AgentConfig) (*dex.StepDecision, error) { if err := step.flow.validateConfig(input); err != nil { return nil, err @@ -2201,8 +2251,6 @@ func (step checkSteeredStep) Execute(ctx dex.Context, input continuation) (*dex. return dex.GoTo(routeToolStep{flow: step.flow}, nil), nil case continueAwaitToolApproval: return dex.GoTo(awaitToolApprovalStep{flow: step.flow}, nil), nil - case continueExecuteTool: - return dex.GoTo(executeToolStep{flow: step.flow}, nil), nil case continueExecuteToolRetry: options, err := step.flow.currentToolStepOptions(ctx) if err != nil { @@ -2519,65 +2567,6 @@ func (step awaitToolApprovalStep) Execute(ctx dex.Context, _ dex.None) (*dex.Ste return dex.GoTo(checkSteeredStep{flow: step.flow}, continueCompactContext), nil } -type executeToolStep struct { - dex.StepDefaultsNoWaitFor[dex.None] - flow *Flow -} - -var _ dex.Step[dex.None] = executeToolStep{} - -func (executeToolStep) GetStepType() string { return string(stepTypeExecuteTool) } - -func (executeToolStep) GetStepOptions() *dex.StepOptions { return toolStepOptions } - -func (step executeToolStep) Execute(ctx dex.Context, _ dex.None) (*dex.StepDecision, error) { - if statusErr := step.flow.updateStatus(ctx, AgentStatusExecutingTool); statusErr != nil { - return nil, statusErr - } - call, callErr := step.flow.currentToolCall(ctx) - if callErr != nil { - return nil, callErr - } - config, configErr := agentConfigAttribute.Get(ctx) - if configErr != nil { - return nil, configErr - } - runtimeMetadata, metadataErr := agentRuntimeMetadataAttribute.Get(ctx) - if isAttributeNotFound(metadataErr) { - runtimeMetadata = MustJSONObject(`{}`) - } else if metadataErr != nil { - return nil, metadataErr - } - progress := toolProgress{ctx: ctx, flow: step.flow, call: call} - result, executeErr := step.flow.executeTool(ctx, ToolInvocation{ - FlowID: FlowID(ctx.FlowID()), - RuntimeMetadata: runtimeMetadata, - Name: call.Name, - Arguments: call.Arguments, - EnabledServers: config.EnabledMCPServers, - WriteProgress: progress.write, - CallID: call.ID, - Attempt: ctx.Attempt(), - FirstAttemptAt: ctx.FirstAttemptAt(), - }) - if executeErr != nil { - failureResult, encodeErr := encodeToolResult(toolResultPayload{ - Status: toolResultStatusFailed, - Outcome: ToolOutcomeUnknown, - ErrorType: errorTypeName(executeErr), - }, ToolOutcomeUnknown, true) - if encodeErr != nil { - return nil, errors.Join(executeErr, encodeErr) - } - result = failureResult - } - next, finishErr := step.flow.finishToolExecution(ctx, call, result) - if finishErr != nil { - return nil, finishErr - } - return dex.GoTo(checkSteeredStep{flow: step.flow}, next), nil -} - func (flow *Flow) finishToolExecution( ctx dex.Context, call ToolCall, @@ -2705,6 +2694,14 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if err != nil { return nil, err } + state, err := agentStateAttribute.Get(ctx) + if err != nil { + return nil, err + } + definition, err := step.flow.invocationToolDefinition(config, state, call.Name) + if err != nil { + return nil, err + } runtimeMetadata, err := agentRuntimeMetadataAttribute.Get(ctx) if isAttributeNotFound(err) { runtimeMetadata = MustJSONObject(`{}`) @@ -2715,7 +2712,7 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if writeErr := progress.write(fmt.Sprintf("Calling %s (attempt %d).", call.Name, ctx.Attempt())); writeErr != nil { return nil, writeErr } - result, err := step.flow.executeTool(ctx, ToolInvocation{ + result, err := step.flow.executeTool(ctx, definition, ToolInvocation{ FlowID: FlowID(ctx.FlowID()), RuntimeMetadata: runtimeMetadata, Name: call.Name, @@ -2732,14 +2729,6 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. if validationErr := result.Outcome.Validate(); validationErr != nil { return nil, fmt.Errorf("tool %q outcome: %w", call.Name, validationErr) } - state, err := agentStateAttribute.Get(ctx) - if err != nil { - return nil, err - } - definition, err := step.flow.invocationToolDefinition(config, state, call.Name) - if err != nil { - return nil, err - } if result.Outcome == ToolOutcomeUnknown && definition.RetryExhaustionPolicy.Effective() == ToolRetryExhaustionPolicyManualRecovery { resultCopy := result @@ -2821,7 +2810,7 @@ func (step executeParallelToolStep) GetStepOptions() *dex.StepOptions { ExecuteRetry: toolStepOptions.ExecuteRetry, ExecuteFailure: dex.ProceedToOnExecuteFailure( recoverParallelToolExecutionStep{flow: step.flow}, - nil, + defaultStepOptions, ), } } @@ -2834,6 +2823,14 @@ func (step executeParallelToolStep) Execute( if configErr != nil { return nil, configErr } + state, stateErr := agentStateAttribute.Get(ctx) + if stateErr != nil { + return nil, stateErr + } + definition, definitionErr := step.flow.invocationToolDefinition(config, state, input.Call.Name) + if definitionErr != nil { + return nil, definitionErr + } runtimeMetadata, metadataErr := agentRuntimeMetadataAttribute.Get(ctx) if isAttributeNotFound(metadataErr) { runtimeMetadata = MustJSONObject(`{}`) @@ -2844,7 +2841,7 @@ func (step executeParallelToolStep) Execute( if progressErr := progress.write(fmt.Sprintf("Calling %s (attempt %d).", input.Call.Name, ctx.Attempt())); progressErr != nil { return nil, progressErr } - result, executeErr := step.flow.executeTool(ctx, ToolInvocation{ + result, executeErr := step.flow.executeTool(ctx, definition, ToolInvocation{ FlowID: FlowID(ctx.FlowID()), RuntimeMetadata: runtimeMetadata, Name: input.Call.Name, @@ -2888,7 +2885,9 @@ func (recoverParallelToolExecutionStep) GetStepType() string { return string(stepTypeRecoverParallel) } -func (recoverParallelToolExecutionStep) GetStepOptions() *dex.StepOptions { return nil } +func (recoverParallelToolExecutionStep) GetStepOptions() *dex.StepOptions { + return defaultStepOptions +} func (recoverParallelToolExecutionStep) Execute( ctx dex.Context, @@ -3407,17 +3406,3 @@ func toolProgressMessage(tool ToolName, message string) string { } return condenseActivityMessage(message) } - -func errorTypeName(err error) string { - value := reflect.TypeOf(err) - if value == nil { - return "error" - } - for value.Kind() == reflect.Pointer { - value = value.Elem() - } - if value.Name() == "" { - return "error" - } - return value.Name() -} diff --git a/internal/agent/tool_recovery_test.go b/internal/agent/tool_recovery_test.go index ae7ce8f..929f1be 100644 --- a/internal/agent/tool_recovery_test.go +++ b/internal/agent/tool_recovery_test.go @@ -18,8 +18,11 @@ package agent import ( "context" + "errors" "testing" "time" + + "github.com/superdurable/dex/sdk-go/dex" ) func TestParallelToolMovementsUseBoundedContiguousSafeWave(t *testing.T) { @@ -95,6 +98,72 @@ func TestToolDefinitionsExposeFailureSimulationOnlyToLocalMock(t *testing.T) { } } +func TestToolStepOptionsMapRunningTypeAndHeartbeat(t *testing.T) { + flow := &Flow{} + short := parallelDefinitionForTestOnly("short") + shortOptions := flow.toolStepOptions(short) + if shortOptions.ExecuteDurability != dex.StepDurabilityDefault || + shortOptions.HeartbeatTimeout != time.Minute { + t.Fatalf("short options = %+v", shortOptions) + } + parallelShortOptions := flow.parallelToolStepOptions(short) + if parallelShortOptions.ExecuteDurability != dex.StepDurabilityDefault || + parallelShortOptions.HeartbeatTimeout != time.Minute { + t.Fatalf("parallel short options = %+v", parallelShortOptions) + } + + long := short + long.RunningType = ToolRunningTypeLongRunning + long.HeartbeatTimeout = 15 * time.Minute + longOptions := flow.toolStepOptions(long) + if longOptions.ExecuteDurability != dex.StepDurabilitySync || + longOptions.HeartbeatTimeout != 15*time.Minute { + t.Fatalf("long options = %+v", longOptions) + } + parallelLongOptions := flow.parallelToolStepOptions(long) + if parallelLongOptions.ExecuteDurability != dex.StepDurabilitySync || + parallelLongOptions.HeartbeatTimeout != 15*time.Minute { + t.Fatalf("parallel long options = %+v", parallelLongOptions) + } +} + +func TestRegisteredStepOptionsUseBoundedTimeoutsAndModelSyncDurability(t *testing.T) { + if defaultStepOptions.WaitForMethodTimeout != time.Minute || + defaultStepOptions.ExecuteMethodTimeout != time.Minute { + t.Fatalf("default Step options = %+v", defaultStepOptions) + } + if modelStepOptions.ExecuteDurability != dex.StepDurabilitySync || + modelStepOptions.ExecuteMethodTimeout != 10*time.Minute || + modelStepOptions.HeartbeatTimeout != 5*time.Minute { + t.Fatalf("model Step options = %+v", modelStepOptions) + } +} + +func TestValidateToolExecutionPolicyRejectsUnknownRunningType(t *testing.T) { + definition := parallelDefinitionForTestOnly("invalid") + definition.RunningType = "sometimes" + err := validateToolExecutionPolicy(definition) + var validationErr *EnumValidationError + if !errors.As(err, &validationErr) || validationErr.Type != "ToolRunningType" { + t.Fatalf("validation error = %T %v", err, err) + } +} + +func TestToolExecutionContextUsesDeclaredAttemptTimeout(t *testing.T) { + started := time.Now() + ctx, cancel := newToolExecutionContext(context.Background(), time.Minute) + defer cancel() + deadline, found := ctx.Deadline() + if !found || deadline.Before(started.Add(59*time.Second)) || deadline.After(started.Add(61*time.Second)) { + t.Fatalf("deadline = %v, found = %t", deadline, found) + } + withoutDeadline, cancelWithoutDeadline := newToolExecutionContext(context.Background(), 0) + defer cancelWithoutDeadline() + if _, found := withoutDeadline.Deadline(); found { + t.Fatal("zero attempt timeout added a deadline") + } +} + func TestValidateToolRecoveryResolutionRequiresAtomicCompleteDecision(t *testing.T) { pending := PendingToolRecovery{ RecoveryID: "recovery-1", diff --git a/internal/agent/types.go b/internal/agent/types.go index 7289ea2..1e07fe8 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -443,6 +443,39 @@ func (outcome *ToolOutcome) UnmarshalJSON(data []byte) error { return decodeEnum(data, outcome, ToolOutcome.Validate) } +// ToolRunningType selects the preferred Dex execution path for one tool. +type ToolRunningType string + +const ( + // ToolRunningTypeShortRunning optimizes for ASYNC local execution with regular fallback. + ToolRunningTypeShortRunning ToolRunningType = "short_running" + // ToolRunningTypeLongRunning starts directly as a regular SYNC activity. + ToolRunningTypeLongRunning ToolRunningType = "long_running" +) + +// Validate rejects unknown tool running types. +func (runningType ToolRunningType) Validate() error { + switch runningType { + case ToolRunningTypeShortRunning, ToolRunningTypeLongRunning: + return nil + default: + return newEnumValidationError("ToolRunningType", string(runningType)) + } +} + +// Effective returns the short-running default for an omitted registry policy. +func (runningType ToolRunningType) Effective() ToolRunningType { + if runningType == "" { + return ToolRunningTypeShortRunning + } + return runningType +} + +// UnmarshalJSON decodes and validates a tool running type. +func (runningType *ToolRunningType) UnmarshalJSON(data []byte) error { + return decodeEnum(data, runningType, ToolRunningType.Validate) +} + // ToolRetryExhaustionPolicy controls what happens after a tool's Dex retries are exhausted. type ToolRetryExhaustionPolicy string @@ -1131,7 +1164,9 @@ type ToolDefinition struct { Description string InputSchema JSONObject RequiresApproval bool + RunningType ToolRunningType AttemptTimeout time.Duration + HeartbeatTimeout time.Duration MaximumAttempts int RetryTotalDuration time.Duration SupportsParallelExecution bool diff --git a/internal/mcp/config.go b/internal/mcp/config.go index 0408f6f..de9c56c 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -34,6 +34,7 @@ import ( const ( maximumToolAttempts = 10 maximumToolTimeout = 24 * 60 * 60 + maximumHeartbeatTimeout = 24 * 60 * 60 maximumToolRetrySeconds = 7 * 24 * 60 * 60 ) @@ -117,12 +118,48 @@ func (policy *RetryExhaustionPolicy) UnmarshalYAML(node *yaml.Node) error { return nil } +// RunningType selects the preferred Dex execution path for one MCP tool. +type RunningType string + +const ( + RunningTypeShortRunning RunningType = "short_running" + RunningTypeLongRunning RunningType = "long_running" +) + +// Validate rejects unknown running types. +func (runningType RunningType) Validate() error { + switch runningType { + case RunningTypeShortRunning, RunningTypeLongRunning: + return nil + default: + return fmt.Errorf("unsupported running type %q", runningType) + } +} + +// UnmarshalYAML decodes and validates one running type. +func (runningType *RunningType) UnmarshalYAML(node *yaml.Node) error { + var value string + if err := node.Decode(&value); err != nil { + return err + } + decoded := RunningType(value) + if err := decoded.Validate(); err != nil { + return err + } + *runningType = decoded + return nil +} + // ToolPolicy configures safety and bounded retries for one tool. type ToolPolicy struct { // ReadOnly overrides the tool annotation; nil means unknown. ReadOnly *bool `yaml:"read_only"` // TimeoutSeconds defaults to 60 and bounds one attempt. TimeoutSeconds float64 `yaml:"timeout_seconds"` + // RunningType defaults to short_running for ASYNC local execution with fallback. + RunningType RunningType `yaml:"running_type"` + // HeartbeatTimeoutSeconds defaults to 60 for regular execution. + HeartbeatTimeoutSeconds *float64 `yaml:"heartbeat_timeout_seconds"` // MaximumAttempts defaults to three for trusted reads and one otherwise. MaximumAttempts *int `yaml:"maximum_attempts"` // RetryTotalSeconds defaults to 300 and bounds all attempts. @@ -217,6 +254,13 @@ func applyDefaults(server *ServerConfig) { if policy.RetryTotalSeconds == 0 { policy.RetryTotalSeconds = 300 } + if policy.RunningType == "" { + policy.RunningType = RunningTypeShortRunning + } + if policy.HeartbeatTimeoutSeconds == nil { + value := float64(60) + policy.HeartbeatTimeoutSeconds = &value + } if policy.RetryExhaustionPolicy == "" { policy.RetryExhaustionPolicy = RetryExhaustionPolicyManualRecovery } @@ -268,6 +312,17 @@ func validateServer(server ServerConfig) error { if !isFinitePositive(policy.TimeoutSeconds) || policy.TimeoutSeconds > maximumToolTimeout { return fmt.Errorf("timeout_seconds for %q must be positive and at most %d", name, maximumToolTimeout) } + if err := policy.RunningType.Validate(); err != nil { + return fmt.Errorf("running_type for %q: %w", name, err) + } + if policy.HeartbeatTimeoutSeconds == nil || !isFinitePositive(*policy.HeartbeatTimeoutSeconds) || + *policy.HeartbeatTimeoutSeconds > maximumHeartbeatTimeout { + return fmt.Errorf( + "heartbeat_timeout_seconds for %q must be positive and at most %d", + name, + maximumHeartbeatTimeout, + ) + } if !isFinitePositive(policy.RetryTotalSeconds) || policy.RetryTotalSeconds > maximumToolRetrySeconds { return fmt.Errorf("retry_total_seconds for %q must be positive and at most %d", name, maximumToolRetrySeconds) } @@ -327,6 +382,10 @@ func cloneToolPolicies(source map[string]ToolPolicy) map[string]ToolPolicy { attempts := *policy.MaximumAttempts policy.MaximumAttempts = &attempts } + if policy.HeartbeatTimeoutSeconds != nil { + heartbeatTimeout := *policy.HeartbeatTimeoutSeconds + policy.HeartbeatTimeoutSeconds = &heartbeatTimeout + } if policy.ReadOnly != nil { readOnly := *policy.ReadOnly policy.ReadOnly = &readOnly diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go index 0964d94..8702fa3 100644 --- a/internal/mcp/config_test.go +++ b/internal/mcp/config_test.go @@ -41,12 +41,49 @@ func TestLoadConfigAppliesSafeDefaults(t *testing.T) { t.Fatalf("LoadConfig() error = %v", err) } policy := servers[0].Tools["query"] - if policy.TimeoutSeconds != 60 || policy.RetryTotalSeconds != 300 || + if policy.TimeoutSeconds != 60 || policy.RunningType != RunningTypeShortRunning || + policy.HeartbeatTimeoutSeconds == nil || *policy.HeartbeatTimeoutSeconds != 60 || + policy.RetryTotalSeconds != 300 || policy.RetryExhaustionPolicy != RetryExhaustionPolicyManualRecovery { t.Fatalf("policy defaults = %+v", policy) } } +func TestLoadConfigAcceptsLongRunningToolPolicy(t *testing.T) { + path := writeConfig(t, `servers: + - name: build + transport: stdio + command: build-server + tools: + compile: + running_type: long_running + heartbeat_timeout_seconds: 900 +`) + servers, err := LoadConfig(path) + if err != nil { + t.Fatal(err) + } + policy := servers[0].Tools["compile"] + if policy.RunningType != RunningTypeLongRunning || policy.HeartbeatTimeoutSeconds == nil || + *policy.HeartbeatTimeoutSeconds != 900 { + t.Fatalf("policy = %+v", policy) + } +} + +func TestLoadConfigRejectsUnknownRunningType(t *testing.T) { + path := writeConfig(t, `servers: + - name: build + transport: stdio + command: build-server + tools: + compile: + running_type: sometimes +`) + if _, err := LoadConfig(path); err == nil { + t.Fatal("LoadConfig() error = nil") + } +} + func TestLoadConfigAcceptsAutomaticUnknownRecovery(t *testing.T) { path := writeConfig(t, `servers: - name: search @@ -157,6 +194,22 @@ func TestLoadConfigRejectsUnsafeRetryPolicy(t *testing.T) { } } +func TestLoadConfigRejectsUnsafeHeartbeatTimeout(t *testing.T) { + for _, value := range []string{".nan", "0", "-1"} { + path := writeConfig(t, `servers: + - name: search + transport: stdio + command: search-server + tools: + query: + heartbeat_timeout_seconds: `+value+` +`) + if _, err := LoadConfig(path); err == nil { + t.Fatalf("heartbeat_timeout_seconds %s: LoadConfig() error = nil", value) + } + } +} + func TestResolveEnvironmentRequiresEveryConfiguredSource(t *testing.T) { const missing = "SUPERAGENT_TEST_MISSING_MCP_SECRET" t.Setenv(missing, "present") diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index d469d8f..3447e54 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -39,9 +39,10 @@ import ( ) const ( - defaultToolTimeout = 60 * time.Second - defaultRetryDuration = 5 * time.Minute - maximumPublicNameSize = 64 + defaultToolTimeout = 60 * time.Second + defaultToolHeartbeatTimeout = time.Minute + defaultRetryDuration = 5 * time.Minute + maximumPublicNameSize = 64 ) var invalidNameCharacter = regexp.MustCompile(`[^A-Za-z0-9_]`) @@ -441,9 +442,11 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo policy, configured := server.Tools[tool.Name] if !configured { policy = ToolPolicy{ - TimeoutSeconds: 60, - RetryTotalSeconds: 300, - RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, + TimeoutSeconds: 60, + RunningType: RunningTypeShortRunning, + HeartbeatTimeoutSeconds: float64Pointer(60), + RetryTotalSeconds: 300, + RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, } } readOnly := policy.ReadOnly @@ -466,13 +469,26 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo if attemptTimeout == 0 { attemptTimeout = defaultToolTimeout } + heartbeatTimeout := defaultToolHeartbeatTimeout + if policy.HeartbeatTimeoutSeconds != nil { + heartbeatTimeout = time.Duration(*policy.HeartbeatTimeoutSeconds * float64(time.Second)) + } retryDuration := time.Duration(policy.RetryTotalSeconds * float64(time.Second)) if retryDuration == 0 { retryDuration = defaultRetryDuration } - if maximumAttempts <= 0 || attemptTimeout <= 0 || retryDuration <= 0 { + if maximumAttempts <= 0 || attemptTimeout <= 0 || heartbeatTimeout <= 0 || retryDuration <= 0 { return agent.RegisteredTool{}, fmt.Errorf("invalid policy for %q", publicName) } + var runningType agent.ToolRunningType + switch policy.RunningType { + case "", RunningTypeShortRunning: + runningType = agent.ToolRunningTypeShortRunning + case RunningTypeLongRunning: + runningType = agent.ToolRunningTypeLongRunning + default: + return agent.RegisteredTool{}, fmt.Errorf("invalid running type %q for %q", policy.RunningType, publicName) + } inputSchema, err := schemaObject(tool.InputSchema) if err != nil { return agent.RegisteredTool{}, fmt.Errorf("tool %q input schema: %w", publicName, err) @@ -489,7 +505,9 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo Description: description, InputSchema: inputSchema, RequiresApproval: readOnly == nil || !*readOnly, + RunningType: runningType, AttemptTimeout: attemptTimeout, + HeartbeatTimeout: heartbeatTimeout, MaximumAttempts: maximumAttempts, RetryTotalDuration: retryDuration, SupportsParallelExecution: readOnly != nil && *readOnly, @@ -498,6 +516,10 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo }, nil } +func float64Pointer(value float64) *float64 { + return &value +} + func schemaObject(value any) (agent.JSONObject, error) { encoded, err := json.Marshal(value) if err != nil { diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index baf3351..effb8b3 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -21,8 +21,10 @@ import ( "log/slog" "strings" "testing" + "time" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/superdurable/superagent/internal/agent" ) func TestRegisteredToolDefaultsWritesToOneAttemptAndApproval(t *testing.T) { @@ -39,11 +41,34 @@ func TestRegisteredToolDefaultsWritesToOneAttemptAndApproval(t *testing.T) { if err != nil { t.Fatalf("registeredTool() error = %v", err) } - if !registered.Definition.RequiresApproval || registered.Definition.MaximumAttempts != 1 { + if !registered.Definition.RequiresApproval || registered.Definition.MaximumAttempts != 1 || + registered.Definition.RunningType != agent.ToolRunningTypeShortRunning || + registered.Definition.HeartbeatTimeout != time.Minute { t.Fatalf("unsafe defaults = %+v", registered.Definition) } } +func TestRegisteredToolProjectsLongRunningPolicy(t *testing.T) { + registered, err := registeredTool(ServerConfig{ + Name: "build", + Transport: TransportStdio, + Command: "server", + Tools: map[string]ToolPolicy{ + "compile": { + RunningType: RunningTypeLongRunning, + HeartbeatTimeoutSeconds: float64Pointer(900), + }, + }, + }, &mcpsdk.Tool{Name: "compile", InputSchema: map[string]any{"type": "object"}}) + if err != nil { + t.Fatal(err) + } + if registered.Definition.RunningType != agent.ToolRunningTypeLongRunning || + registered.Definition.HeartbeatTimeout != 15*time.Minute { + t.Fatalf("definition = %+v", registered.Definition) + } +} + func TestRegisteredToolTrustsReadOnlyAnnotationOnlyWhenConfigured(t *testing.T) { readOnly := &mcpsdk.Tool{ Name: "search", diff --git a/script/testdata/public-api-consumer/consumer_test.go b/script/testdata/public-api-consumer/consumer_test.go index 29c30b7..361462d 100644 --- a/script/testdata/public-api-consumer/consumer_test.go +++ b/script/testdata/public-api-consumer/consumer_test.go @@ -20,6 +20,7 @@ import ( "context" "net/http" "testing" + "time" "github.com/superdurable/dex/sdk-go/dex" "github.com/superdurable/superagent/agent" @@ -78,6 +79,7 @@ var ( _ agent.EventKind = agent.EventKindPlanTaskUpdated _ agent.EventKind = agent.EventKindInputConsumed _ agent.EventKind = agent.EventKindSnapshotRequired + _ agent.ToolRunningType = agent.ToolRunningTypeShortRunning _ agent.PlanTaskIndex = 0 ) @@ -105,6 +107,13 @@ func TestExternalModuleCanConstructAndRegisterAgent(t *testing.T) { if agent.MaximumRuntimeMetadataBytes != 16<<10 { t.Fatal("public runtime metadata limit is unavailable") } + definition := agent.ToolDefinition{ + RunningType: agent.ToolRunningTypeLongRunning, + HeartbeatTimeout: time.Minute, + } + if definition.RunningType != agent.ToolRunningTypeLongRunning { + t.Fatal("public tool running policy is unavailable") + } } func TestExternalModuleCanConstructProviderRouter(t *testing.T) { diff --git a/web/mcp-servers.example.yaml b/web/mcp-servers.example.yaml index 6446842..194c9aa 100644 --- a/web/mcp-servers.example.yaml +++ b/web/mcp-servers.example.yaml @@ -9,7 +9,11 @@ servers: tools: brave_web_search: read_only: true + # Defaults to short_running. Use long_running when most calls exceed five seconds. + running_type: short_running timeout_seconds: 30 + # Defaults to 60. Raise only for healthy tools with longer silent intervals. + heartbeat_timeout_seconds: 60 maximum_attempts: 3 retry_total_seconds: 120 # Defaults to manual_recovery. Use this only when automatic progress is safe. From 996b025ef3afb10a64869416a575d84284040f4b Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 20:58:47 -0700 Subject: [PATCH 3/8] fix: prepare server-only Dex upgrades and retry snapshot expiry --- CONTRIBUTING.md | 10 ++++-- docs/dex-v0.10-upgrade.md | 56 +++++++++++++++++++++++++++++++ docs/flow-model.md | 5 +++ internal/agent/client.go | 10 +++--- script/check_dex_release.py | 20 ++++++++--- script/update_dex_release.py | 30 +++++++++++++---- script/update_dex_release_test.py | 24 +++++++++++++ 7 files changed, 139 insertions(+), 16 deletions(-) create mode 100644 docs/dex-v0.10-upgrade.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8963fc8..559d800 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,8 +18,14 @@ the installed SDK source and the installed skill before changing resource projection or errors. Never infer an API from a design screenshot or unreleased branch. -`dex-release.lock.json` binds the direct Go SDK requirement to one immutable -Dex manifest. A Dex publication opens an automated upgrade PR with open Flow +`dex-release.lock.json` binds Server and Go SDK versions to immutable +Dex manifests. For a Server-only upgrade, run `script/update_dex_release.py` +with `--server-only`, `--manifest-url`, and `--manifest-sha256`. This preserves +the Go SDK and records its original manifest in `sdkManifest`. Validation checks +both manifests and requires the SDK and Server protocol intervals to overlap. +The Go SDK remains at `v0.9.0` because `v0.10.0` changes RPC registration and +removes invocation-specific archive loading; that migration needs separate design. +A Dex publication opens an automated upgrade PR with open Flow compatibility set to `cancel-required` for review. Publishing the subsequent SuperAgent release dispatches the reviewed IaC and SuperVerse upgrades. The automation opens the draft after asset validation and mechanical pin diff --git a/docs/dex-v0.10-upgrade.md b/docs/dex-v0.10-upgrade.md new file mode 100644 index 0000000..e65e2a6 --- /dev/null +++ b/docs/dex-v0.10-upgrade.md @@ -0,0 +1,56 @@ +# Dex Server v0.10.0 upgrade + +Status: blocked before changing the production release lock. + +## Scope + +Upgrade Server and CLI to v0.10.0. Retain Go SDK v0.9.0 for this Server-only +change. SDK v0.10.0 removes invocation-specific RPC options, including the +single-instance archive load used by GetArchivedMessages. Its migration needs +a separate design that preserves bounded history reads. + +The new `--server-only` upgrade option preserves the SDK's original immutable +manifest. Release validation verifies both manifests and their protocol overlap. + +## Release prerequisite + +The [v0.10.0 publication](https://github.com/superdurable/dex/actions/runs/35303789151) +succeeded, but skipped its compatibility manifest job. That job requires every +SDK to be selected for publication. Java, Python, Rust, and TypeScript were +unchanged and skipped. The Server release therefore has no +`dex-compatibility-v0.10.0.json` asset. + +The audited upgrade needs that official manifest and its SHA-256. Keep the +current release pins until partial-component releases can publish a manifest +recording the actual versions of all components. + +## Tests + +Verified the published CLI v0.10.0 archive checksum and started its embedded +Server using isolated databases. Go SDK v0.9.0 passed Agent and HTTP integration +tests against that Server. Flow visualization completed without diagnostics. +Server-only updater tests, formatting, workflow lint, Agent unit tests, and vet +passed. + +Browser E2E exposed a read-only Snapshot long-poll expiry returning HTTP 503. +Snapshot now retries that typed error within its existing three-attempt budget. +The affected browser reconciliation scenario passed after the fix. The last +full E2E run passed 18 of 19 tests; the archive-history scenario still timed +out. Preserve that failure and resolve it before release. The run log is +`/tmp/superagent-dex-v010-e2e-retry-fix.log` on the verification host. + +After the manifest is available, update the immutable pins, validate the release +lock, rerun real-Server integration and the complete browser suite, and commit +the final version change before publishing SuperAgent v0.4.0. + +## Documentation + +CONTRIBUTING documents Server-only manifest validation. The Flow model documents +bounded Snapshot retry behavior. Update the prerequisites and version references +when the release lock can be finalized. + +## UI/UX + +No controls change. Verify that post-command Snapshot reconciliation restores +the composer, and that archive pagination, scrolling, focus, and keyboard +behavior pass through the real HTTP API. diff --git a/docs/flow-model.md b/docs/flow-model.md index 5a02fa1..d4abc09 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -33,6 +33,11 @@ Attribute index synchronization or Worker binding. Deploy the Server before the Worker. Startup fails when `GetServerInfo` is missing, either interval is invalid, or the intervals do not overlap. +Snapshot reads retry inactive-run and server long-poll expiry errors within a +three-attempt budget. Snapshot is read-only, so these retries cannot duplicate +commands or external effects. Caller cancellation and other errors still return +immediately. + Renewable sandbox credentials are not an Agent Flow resource. A future, separately designed `SandboxLifecycleFlow` will own that lifecycle. diff --git a/internal/agent/client.go b/internal/agent/client.go index 97ea1a8..2626116 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -221,17 +221,19 @@ func (client *Client) GetSnapshot( if statusErr == nil && current != nil && current.Status != dex.FlowRunning { return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) } - var inactiveErr error + var retryErr error for range maximumSnapshotAttempts { snapshot, err := client.invokeSnapshotRPC(ctx, flowID) if err == nil { return snapshot, nil } var inactive *dex.FlowNotActiveError - if !errors.As(err, &inactive) { + var pollTimeout *dex.LongPollTimeoutError + if !errors.As(err, &inactive) && !errors.As(err, &pollTimeout) { return AgentSnapshot{}, err } - inactiveErr = err + // Snapshot is read-only, so server long-poll expiry can safely retry within this bounded budget. + retryErr = err current, statusErr = client.latestAgentRun(ctx, flowID) if statusErr != nil { return AgentSnapshot{}, errors.Join(err, statusErr) @@ -241,7 +243,7 @@ func (client *Client) GetSnapshot( } return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) } - return AgentSnapshot{}, inactiveErr + return AgentSnapshot{}, retryErr } func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (AgentSnapshot, error) { diff --git a/script/check_dex_release.py b/script/check_dex_release.py index 50f47c7..11baa25 100644 --- a/script/check_dex_release.py +++ b/script/check_dex_release.py @@ -30,7 +30,7 @@ def main() -> None: lock = json.loads((ROOT / "dex-release.lock.json").read_text(encoding="utf-8")) - if set(lock) != { + if set(lock) - {"sdkManifest"} != { "schemaVersion", "release", "manifest", @@ -46,6 +46,12 @@ def main() -> None: manifest = update_dex_release.validate_manifest( lock["manifest"]["url"], lock["manifest"]["sha256"], content ) + sdk_manifest = manifest + if "sdkManifest" in lock: + source = lock["sdkManifest"] + sdk_manifest = update_dex_release.validate_manifest( + source["url"], source["sha256"], update_dex_release.download(source["url"]) + ) requirements = dict( re.findall(r"(?m)^\s*([^\s()]+)\s+(v[^\s]+)(?:\s+//.*)?$", (ROOT / "go.mod").read_text(encoding="utf-8")) ) @@ -55,7 +61,7 @@ def main() -> None: lock["sourceCommit"] == manifest["sourceCommit"], "Dex source commit mismatch" ) update_dex_release.require( - lock["sdkGoVersion"] == manifest["components"]["sdkGo"]["version"], + lock["sdkGoVersion"] == sdk_manifest["components"]["sdkGo"]["version"], "Dex Go SDK version mismatch", ) update_dex_release.require( @@ -63,16 +69,22 @@ def main() -> None: "SuperAgent must directly require the locked Dex Go SDK", ) update_dex_release.require( - lock["protocol"] == manifest["protocol"]["clients"]["sdkGo"], + lock["protocol"] == sdk_manifest["protocol"]["clients"]["sdkGo"], "Dex protocol mismatch", ) + server_protocol = manifest["protocol"]["server"] + update_dex_release.require( + max(lock["protocol"]["minimum"], server_protocol["minimum"]) + <= min(lock["protocol"]["maximum"], server_protocol["maximum"]), + "locked Go SDK and Server protocols are incompatible", + ) for field in ("runningFlowsCompatibility", "persistenceCompatibility"): update_dex_release.require(lock[field] == manifest[field], f"Dex {field} mismatch") update_dex_release.require( lock["openFlowsCompatibility"] in {"compatible", "cancel-required"}, "invalid open Flow compatibility", ) - print(f'SuperAgent directly requires locked Dex {lock["release"]}') + print(f'SuperAgent locks Dex Server {lock["release"]} and Go SDK {lock["sdkGoVersion"]}') if __name__ == "__main__": diff --git a/script/update_dex_release.py b/script/update_dex_release.py index cca51a5..f2126f1 100644 --- a/script/update_dex_release.py +++ b/script/update_dex_release.py @@ -91,8 +91,20 @@ def update_repository( manifest_url: str, manifest_sha256: str, manifest: dict[str, Any], + *, + server_only: bool = False, ) -> None: version = manifest["release"] + previous = None + if server_only: + previous = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) + server_protocol = manifest["protocol"]["server"] + sdk_protocol = previous["protocol"] + require( + max(server_protocol["minimum"], sdk_protocol["minimum"]) + <= min(server_protocol["maximum"], sdk_protocol["maximum"]), + "retained Go SDK and new Server protocols are incompatible", + ) checksums = manifest["components"]["cli"]["checksums"] archives = tuple( f"dexcli_v{version}_{platform}_{architecture}.tar.gz" @@ -100,11 +112,12 @@ def update_repository( for architecture in ("amd64", "arm64") ) require(set(checksums) == set(archives), "Dex CLI checksums are incomplete") - replace_once( - root / "go.mod", - r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", - rf"\g<1>v{version}", - ) + if not server_only: + replace_once( + root / "go.mod", + r"(github\.com/superdurable/dex/sdk-go\s+)v[^\s]+", + rf"\g<1>v{version}", + ) replace_once(root / "Makefile", r"^DEXCLI_VERSION := v[^\s]+$", f"DEXCLI_VERSION := v{version}") installer = root / "script/install-dexcli.sh" installer_content = installer.read_text(encoding="utf-8") @@ -132,6 +145,10 @@ def update_repository( "persistenceCompatibility": manifest["persistenceCompatibility"], "openFlowsCompatibility": "cancel-required", } + if previous is not None: + lock["sdkGoVersion"] = previous["sdkGoVersion"] + lock["protocol"] = previous["protocol"] + lock["sdkManifest"] = previous.get("sdkManifest", previous["manifest"]) (root / "dex-release.lock.json").write_text( json.dumps(lock, indent=2) + "\n", encoding="utf-8", @@ -142,10 +159,11 @@ def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--manifest-url", required=True) parser.add_argument("--manifest-sha256", required=True) + parser.add_argument("--server-only", action="store_true", help="Retain the locked Go SDK and verify protocol overlap") args = parser.parse_args() content = download(args.manifest_url) manifest = validate_manifest(args.manifest_url, args.manifest_sha256, content) - update_repository(ROOT, args.manifest_url, args.manifest_sha256, manifest) + update_repository(ROOT, args.manifest_url, args.manifest_sha256, manifest, server_only=args.server_only) print(f'Prepared SuperAgent for Dex {manifest["release"]}; open Flows require review') diff --git a/script/update_dex_release_test.py b/script/update_dex_release_test.py index 77be304..3b23d8b 100644 --- a/script/update_dex_release_test.py +++ b/script/update_dex_release_test.py @@ -93,6 +93,30 @@ def test_validates_and_updates_all_superagent_pins(self) -> None: self.assertIn("DEXCLI_VERSION := v1.2.3", (root / "Makefile").read_text(encoding="utf-8")) self.assertIn("checksum=" + "4" * 64, (root / "script/install-dexcli.sh").read_text(encoding="utf-8")) + newer = copy.deepcopy(validated) + newer["release"] = "1.2.4" + newer["components"]["sdkGo"]["version"] = "1.2.4" + newer["components"]["cli"]["checksums"] = { + name.replace("1.2.3", "1.2.4"): checksum + for name, checksum in newer["components"]["cli"]["checksums"].items() + } + newer_url = url.replace("1.2.3", "1.2.4") + newer_digest = hashlib.sha256(json.dumps(newer).encode()).hexdigest() + MODULE.update_repository(root, newer_url, newer_digest, newer, server_only=True) + retained = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) + self.assertEqual(retained["release"], "1.2.4") + self.assertEqual(retained["sdkGoVersion"], "1.2.3") + self.assertEqual(retained["sdkManifest"], lock["manifest"]) + self.assertEqual(retained["protocol"], lock["protocol"]) + self.assertIn("sdk-go v1.2.3", (root / "go.mod").read_text(encoding="utf-8")) + self.assertIn("DEXCLI_VERSION := v1.2.4", (root / "Makefile").read_text(encoding="utf-8")) + newer["protocol"]["server"] = {"minimum": 4, "maximum": 4} + with self.assertRaisesRegex(MODULE.UpgradeError, "retained Go SDK.*incompatible"): + MODULE.update_repository(root, newer_url, newer_digest, newer, server_only=True) + self.assertEqual( + json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")), retained + ) + def test_rejects_tampering_and_incompatible_protocol(self) -> None: value = manifest() content = (json.dumps(value) + "\n").encode() From 48ea55439177ccfdaa6ce8ce8167a5cc5bc268af Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 21:37:35 -0700 Subject: [PATCH 4/8] feat: adopt Dex Go SDK v0.9.1 RPC contracts --- .github/workflows/ci.yml | 2 +- ARCHITECTURE.md | 8 ++- CONTRIBUTING.md | 18 ++--- Makefile | 2 +- README.md | 4 +- dex-release.lock.json | 18 +++-- ...rk-and-input-consumption-reconciliation.md | 4 +- docs/adr/0014-registered-rpc-options.md | 45 +++++++++++++ docs/dex-v0.10-upgrade.md | 58 +++++++--------- docs/flow-model.md | 12 ++-- go.mod | 2 +- go.sum | 4 +- internal/agent/client.go | 65 ++++-------------- internal/agent/client_test.go | 23 +++++++ internal/agent/flow.go | 57 +++++++++++++++- internal/agent/flow_integration_test.go | 66 ++++++++++++++++++- internal/agent/history_test.go | 17 ----- script/check_dex_release.py | 60 ++++++++++++++--- script/install-dexcli.sh | 8 +-- script/update_dex_release.py | 5 +- script/update_dex_release_test.py | 42 ++++++++++++ 21 files changed, 369 insertions(+), 151 deletions(-) create mode 100644 docs/adr/0014-registered-rpc-options.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6984ad..b89f2e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: run: | set -euo pipefail dex_log="$RUNNER_TEMP/dexcli.log" - PATH="$PWD/.cache/temporal-v1.8.2:$PATH" .cache/dexcli-v0.9.0 dev \ + PATH="$PWD/.cache/temporal-v1.8.2:$PATH" .cache/dexcli-v0.10.0 dev \ -open=false \ -blob-store-dir "$RUNNER_TEMP/dex-blobs" \ -sqlite-db-filename "$RUNNER_TEMP/dex.sqlite.db" \ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6bf6c55..e78ab8b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -75,8 +75,10 @@ prompts, and cumulative context summaries are separate typed Attributes. Snapshot is the only durable current-interaction and reconciliation read model. Archive paging is an immutable history continuation. Each page uses one -read-only Flow RPC that loads `AgentState` and one exact archive chunk, without -loading current interaction state or pending Channels. +read-only Flow RPC that loads `AgentState` and the retained archive map, then +returns one exact chunk without loading current interaction state or pending +Channels. Dex Go SDK `v0.9.1` fixes selective loads at RPC registration, so an +input-selected AttributeMap instance cannot be loaded independently. Commands follow Dex's transactional RPC model. There is no permanent command receipt, caller request ID, payload fingerprint, global mutation revision, or @@ -328,7 +330,7 @@ Runtime metadata therefore remains stable for the logical call. `internal/app` owns every long-lived resource. Startup validates configuration, discovers MCP, constructs providers, opens BlobCache, starts the Worker, waits -for its listener, marks readiness, and then serves the API. The Dex `v0.9.0` +for its listener, marks readiness, and then serves the API. The Dex Go SDK `v0.9.1` Worker negotiates a compatible Server protocol before synchronizing indexes or binding. Any startup failure closes everything already constructed. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 559d800..d038827 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,19 +12,19 @@ against the installed released SDK and a version-matched runnable example or real-server compile-contract test. Snapshot, Stream, Channel size snapshot, and Attribute wait code target Dex Go -SDK and Server `v0.9.0`. Version `v0.9.0` Workers negotiate the Server protocol -before binding, so deployments upgrade the Server before the Worker. Recheck +SDK `v0.9.1` and Server `v0.10.0`. Workers negotiate the Server protocol before +binding, so deployments upgrade the Server before the Worker. Recheck the installed SDK source and the installed skill before changing resource projection or errors. Never infer an API from a design screenshot or unreleased branch. -`dex-release.lock.json` binds Server and Go SDK versions to immutable -Dex manifests. For a Server-only upgrade, run `script/update_dex_release.py` -with `--server-only`, `--manifest-url`, and `--manifest-sha256`. This preserves -the Go SDK and records its original manifest in `sdkManifest`. Validation checks -both manifests and requires the SDK and Server protocol intervals to overlap. -The Go SDK remains at `v0.9.0` because `v0.10.0` changes RPC registration and -removes invocation-specific archive loading; that migration needs separate design. +`dex-release.lock.json` binds the Server to its immutable compatibility manifest. +It binds an independently released Go SDK patch to its tag, source commit, and +Go module checksums. For a Server-only upgrade, run +`script/update_dex_release.py` with `--server-only`, `--manifest-url`, and +`--manifest-sha256`. This preserves either SDK lock form and requires the SDK +and Server protocol intervals to overlap. Dex Go SDK `v0.9.1` registers RPCs +explicitly and fixes their execution options at Flow registration. A Dex publication opens an automated upgrade PR with open Flow compatibility set to `cancel-required` for review. Publishing the subsequent SuperAgent release dispatches the reviewed IaC and SuperVerse upgrades. diff --git a/Makefile b/Makefile index 00b7d03..d15a6f0 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ GO_BUILD_CACHE := $(CURDIR)/.cache/go-build GO_PACKAGES := ./agent/... ./cmd/... ./internal/... ./model/... ./toolcontract/... -DEXCLI_VERSION := v0.9.0 +DEXCLI_VERSION := v0.10.0 DEXCLI_BINARY := $(CURDIR)/.cache/dexcli-$(DEXCLI_VERSION) OSV_SCANNER_VERSION := v2.5.1 OSV_SCANNER_BINARY := $(CURDIR)/.cache/osv-scanner-$(OSV_SCANNER_VERSION) diff --git a/README.md b/README.md index 5f27704..5a0b109 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ and resource model. - Go matching [`go.mod`](go.mod) - Node.js and npm compatible with [`web/package-lock.json`](web/package-lock.json) -- A Dex `v0.9.0` server +- A Dex Server `v0.10.0` - A writable directory for disposable Dex BlobCache data ## Quick start @@ -102,7 +102,7 @@ make build-api make build-web ``` -Start a compatible Dex server. Dex `v0.9.0` Workers require the Server +Start a compatible Dex server. Dex Go SDK `v0.9.1` Workers require the Server compatibility RPC, so upgrade the Server before the Worker. Then run the API and Worker: diff --git a/dex-release.lock.json b/dex-release.lock.json index b1e74ea..2064404 100644 --- a/dex-release.lock.json +++ b/dex-release.lock.json @@ -1,17 +1,23 @@ { "schemaVersion": 1, - "release": "0.9.0", + "release": "0.10.0", "manifest": { - "url": "https://github.com/superdurable/dex/releases/download/server/v0.9.0/dex-compatibility-v0.9.0.json", - "sha256": "dc09203a2d785008d4449e23f70bd3598107e82d5f7934d86f49f1c534310906" + "url": "https://github.com/superdurable/dex/releases/download/server/v0.10.0/dex-compatibility-v0.10.0.json", + "sha256": "dec8dd6d9a3734e8afbf14acfb740830dc63c361d4957d1fa9f4810a61f311d9" }, - "sourceCommit": "e93b803a829735292af8c81a0cc1c98b12aee7f7", - "sdkGoVersion": "0.9.0", + "sourceCommit": "90dbc4ef121d575e4f79505cde0889cf49583fc2", + "sdkGoVersion": "0.9.1", "protocol": { "minimum": 1, "maximum": 1 }, "runningFlowsCompatibility": "compatible", "persistenceCompatibility": "compatible", - "openFlowsCompatibility": "cancel-required" + "openFlowsCompatibility": "cancel-required", + "sdkGoRelease": { + "tag": "sdk-go/v0.9.1", + "sourceCommit": "81e0ddf014b40065f23cd1e9ada8849aa55df4ff", + "moduleChecksum": "h1:j7q+gpS1E8i0JvrR1gmpBUnWBHDZr6iTqYAOiwPzC2U=", + "goModChecksum": "h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8=" + } } diff --git a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md index e1890a6..0723a1c 100644 --- a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md +++ b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md @@ -74,8 +74,8 @@ Consumed IDs suppress stale queue data until durable history replaces the temporary projection. Snapshot remains the only authoritative durable reconciliation model. -Deployments must use Dex Server and Go SDK `v0.9.0`, and the matching Worker and -browser behavior together. The Server must be upgraded first because `v0.9.0` +Deployments must use Dex Server `v0.10.0` and Go SDK `v0.9.1`, and the matching +Worker and browser behavior together. The Server must be upgraded first because Workers reject Servers without protocol negotiation. Deployments must stop or clear Agent Flows created with the removed schema before rollout; there is no old-Attribute or Runtime Lease compatibility shim. diff --git a/docs/adr/0014-registered-rpc-options.md b/docs/adr/0014-registered-rpc-options.md new file mode 100644 index 0000000..45f1a32 --- /dev/null +++ b/docs/adr/0014-registered-rpc-options.md @@ -0,0 +1,45 @@ +# ADR 0014: Register immutable RPC execution options + +## Status + +Accepted on 2026-09-17. + +## Context + +Dex Go SDK `v0.9.1` replaces reflected RPC discovery with explicit `GetRPCs` +definitions. Timeout, locks, transactional execution, and selective collection +loads belong to the registered RPC definition. Callers can impose a shorter +context deadline, but cannot change those options per invocation. + +Most Agent RPCs always use the same resources. `GetArchivedMessages` is the +exception: its input selects one `ArchivedMessages` instance. The released SDK +cannot derive an AttributeMap instance load from RPC input. + +## Decision + +`AIAgentFlow.GetRPCs` explicitly registers every production RPC and its complete +execution policy. Client calls provide only the Flow ID, registered method, +typed input, and output destination. Snapshot uses a five-second registered +timeout. Commands and archive reads use twenty seconds. Existing Attribute +locks and transactional Channel mutations remain attached to their RPCs. + +`GetArchivedMessages` registers a whole-map `ArchivedMessages` load and returns +only the requested immutable ten-message chunk. It still excludes current +messages and pending Channels. Retention remains the bound on loaded archive +state. Integration-only RPCs use the same explicit registration contract. + +The release lock records Server `v0.10.0` through its compatibility manifest. +Because `sdk-go/v0.9.1` is an SDK-only release without a Server manifest, the +lock records its tag, source commit, and Go module checksums separately. + +## Consequences + +Worker and Client registries share one visible RPC contract, and invalid loads +or locks fail during registry construction. Call sites cannot accidentally +weaken transactional behavior or select undeclared state. + +An archive page now hydrates every retained archive chunk before returning one +page. This is a known cost of the released `v0.9.1` contract, not an SLA change. +A future bounded design requires a new durable storage boundary or a released +SDK facility for input-derived instance selection; it must not emulate mutable +per-call options in application code. diff --git a/docs/dex-v0.10-upgrade.md b/docs/dex-v0.10-upgrade.md index e65e2a6..35c94d4 100644 --- a/docs/dex-v0.10-upgrade.md +++ b/docs/dex-v0.10-upgrade.md @@ -1,53 +1,43 @@ # Dex Server v0.10.0 upgrade -Status: blocked before changing the production release lock. +Status: implementation and verification in progress. ## Scope -Upgrade Server and CLI to v0.10.0. Retain Go SDK v0.9.0 for this Server-only -change. SDK v0.10.0 removes invocation-specific RPC options, including the -single-instance archive load used by GetArchivedMessages. Its migration needs -a separate design that preserves bounded history reads. +Upgrade Server and CLI to v0.10.0 and Go SDK to v0.9.1. The SDK explicitly +registers RPCs and fixes their execution options at registration. It removes +invocation-specific selective loads, including the former single-instance load +used by `GetArchivedMessages`. -The new `--server-only` upgrade option preserves the SDK's original immutable -manifest. Release validation verifies both manifests and their protocol overlap. +The Server lock uses its immutable compatibility manifest. The SDK-only patch +has no Server manifest, so its lock records the release tag, source commit, and +Go module checksums. Validation also requires the SDK and Server protocol +intervals to overlap. ## Release prerequisite -The [v0.10.0 publication](https://github.com/superdurable/dex/actions/runs/35303789151) -succeeded, but skipped its compatibility manifest job. That job requires every -SDK to be selected for publication. Java, Python, Rust, and TypeScript were -unchanged and skipped. The Server release therefore has no -`dex-compatibility-v0.10.0.json` asset. - -The audited upgrade needs that official manifest and its SHA-256. Keep the -current release pins until partial-component releases can publish a manifest -recording the actual versions of all components. +The missing Server v0.10.0 manifest was backfilled after the Dex partial-release +workflow was corrected. SuperAgent pins that asset and its SHA-256. The +`sdk-go/v0.9.1` release is pinned independently because compatibility manifests +are Server release contracts and the patch published only the Go SDK. ## Tests -Verified the published CLI v0.10.0 archive checksum and started its embedded -Server using isolated databases. Go SDK v0.9.0 passed Agent and HTTP integration -tests against that Server. Flow visualization completed without diagnostics. -Server-only updater tests, formatting, workflow lint, Agent unit tests, and vet -passed. - -Browser E2E exposed a read-only Snapshot long-poll expiry returning HTTP 503. -Snapshot now retries that typed error within its existing three-attempt budget. -The affected browser reconciliation scenario passed after the fix. The last -full E2E run passed 18 of 19 tests; the archive-history scenario still timed -out. Preserve that failure and resolve it before release. The run log is -`/tmp/superagent-dex-v010-e2e-retry-fix.log` on the verification host. +The published CLI v0.10.0 archive checksums and SDK v0.9.1 module checksums are +locked. Unit compilation verifies the explicit RPC registration API. Real +Server integration, visualization, complete checks, and browser E2E must pass +before release. -After the manifest is available, update the immutable pins, validate the release -lock, rerun real-Server integration and the complete browser suite, and commit -the final version change before publishing SuperAgent v0.4.0. +Earlier browser E2E exposed Snapshot long-poll expiry and continue-as-new +visibility races. Snapshot retries typed read-only expiry errors and does not +treat `ContinuedAsNew` as terminal. The regression scenarios remain release +gates. ## Documentation -CONTRIBUTING documents Server-only manifest validation. The Flow model documents -bounded Snapshot retry behavior. Update the prerequisites and version references -when the release lock can be finalized. +CONTRIBUTING documents the mixed Server/SDK lock. The Flow model and ADR 0014 +document immutable registered RPC options and the whole-map archive load imposed +by the v0.9.1 contract. ## UI/UX diff --git a/docs/flow-model.md b/docs/flow-model.md index d4abc09..17288b8 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -14,7 +14,7 @@ `GetArchivedMessages` - Browser synchronization Attribute: `WaitingInputRound` -The implementation requires Dex Go SDK and Server `v0.9.0`. Each +The implementation requires Dex Go SDK `v0.9.1` and Server `v0.10.0`. Each `WaitFor`, `Execute`, and RPC invocation is an independent Dex atomic commit. Provider and MCP calls are external effects and are not part of a Dex transaction. @@ -28,7 +28,7 @@ heartbeat, retry, and recovery settings. Ordinary Step methods use a one-minute timeout, while model methods retain their explicit ten-minute timeout and five-minute heartbeat. -The `v0.9.0` Worker negotiates the highest common protocol with the Server before +The Worker negotiates the highest common protocol with the Server before Attribute index synchronization or Worker binding. Deploy the Server before the Worker. Startup fails when `GetServerInfo` is missing, either interval is invalid, or the intervals do not overlap. @@ -124,7 +124,7 @@ history, and makes the model replan. | `AwaitManualToolRecovery` | exact recovery decision or steering | Persist recovery state; retry selected calls, continue unknowns, stop the sequence, or replan | | `DurableWait` | Timer or steering | Persist waiting status; record completion or interruption and continue | -Dex Server and Go SDK `v0.9.0` expose Channel size metadata in `WaitFor` and +Dex Server `v0.10.0` and Go SDK `v0.9.1` expose Channel size metadata in `WaitFor` and `Execute`. `AwaitUser.WaitFor` reads the sizes of `SteeredUserMessages`, `QueuedUserMessages`, and the current `PlanExecutions` instance without loading message payloads. It increments @@ -225,8 +225,10 @@ retained messages. Snapshot is one read-only Flow RPC that loads current history, the interaction description, and pending Channels. It returns `WaitingInputRound` and stable -application message IDs. Archive paging loads exactly one immutable chunk and -the bounded sequence metadata needed for continuation. +application message IDs. Archive paging returns exactly one immutable chunk and +the bounded sequence metadata needed for continuation. Its registered +`v0.9.1` RPC options load the retained archive map because the requested chunk +key is an RPC input and invocation-specific selective loads no longer exist. The browser begins with the Snapshot round, waits for `round > watermark`, uses the actual matched round as the next watermark, then refreshes Snapshot. A diff --git a/go.mod b/go.mod index b17e624..99be94b 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/ogen-go/ogen v1.24.0 github.com/openai/openai-go/v3 v3.55.0 github.com/superdurable/dex/blob-cache-go v0.1.0 - github.com/superdurable/dex/sdk-go v0.9.0 + github.com/superdurable/dex/sdk-go v0.9.1 golang.org/x/net v0.58.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index ef24fa7..a46d42f 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/superdurable/dex/blob-cache-go v0.1.0 h1:+c3H5YBWG3DlICOHbgT9IUM5vTlfLYGP5Vd5sWr7WTY= github.com/superdurable/dex/blob-cache-go v0.1.0/go.mod h1:Atepb7+sztvDCztVKmlvEKCSKFCkHKtDhoFYjaFmtEw= -github.com/superdurable/dex/sdk-go v0.9.0 h1:F1kJnQMGPMR6pXWiB3ZJk2FPdqpaQ8PQ0+ukfrJEvw4= -github.com/superdurable/dex/sdk-go v0.9.0/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= +github.com/superdurable/dex/sdk-go v0.9.1 h1:j7q+gpS1E8i0JvrR1gmpBUnWBHDZr6iTqYAOiwPzC2U= +github.com/superdurable/dex/sdk-go v0.9.1/go.mod h1:8Wj5wPf9dyb7hDnA40j8xISR/zjhX57NrUDVcXgf5x8= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= diff --git a/internal/agent/client.go b/internal/agent/client.go index 2626116..0c25fe5 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -118,10 +118,7 @@ func (client *Client) SendMessage(ctx context.Context, flowID FlowID, message Us } pending := PendingUserMessage{MessageID: MessageID(uuid.NewString()), Value: message} accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SendMessage, pending, accepted, dex.InvokeOptions{ - Timeout: client.commandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, - }) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SendMessage, pending, accepted) }) if err != nil { return err @@ -142,10 +139,7 @@ func (client *Client) AnswerQuestions( return err } accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.AnswerQuestions, request, accepted, dex.InvokeOptions{ - Timeout: client.commandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, - }) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.AnswerQuestions, request, accepted) }) if err != nil { return err @@ -193,12 +187,7 @@ func (client *Client) SteerMessage(ctx context.Context, flowID FlowID, request S return err } accepted, err := invokeLockedCommand(ctx, client.commandTimeout, func(ctx context.Context, accepted *bool) error { - return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SteerMessage, request, accepted, dex.InvokeOptions{ - Timeout: client.commandTimeout, - IsTransactional: true, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, - }) + return client.sdk.InvokeRPC(ctx, string(flowID), client.flow.SteerMessage, request, accepted) }) if err != nil { return err @@ -218,7 +207,7 @@ func (client *Client) GetSnapshot( return AgentSnapshot{}, err } current, statusErr := client.latestAgentRun(ctx, flowID) - if statusErr == nil && current != nil && current.Status != dex.FlowRunning { + if statusErr == nil && current != nil && isTerminalFlowStatus(current.Status) { return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) } var retryErr error @@ -238,7 +227,7 @@ func (client *Client) GetSnapshot( if statusErr != nil { return AgentSnapshot{}, errors.Join(err, statusErr) } - if current == nil || current.Status == dex.FlowRunning { + if current == nil || !isTerminalFlowStatus(current.Status) { continue } return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) @@ -246,6 +235,10 @@ func (client *Client) GetSnapshot( return AgentSnapshot{}, retryErr } +func isTerminalFlowStatus(status dex.FlowStatus) bool { + return (dex.FlowResult{Status: status}).IsTerminal() +} + func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (AgentSnapshot, error) { timeout := client.commandTimeout if timeout <= 0 || timeout > defaultSnapshotTimeout { @@ -254,14 +247,7 @@ func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (Age rpcContext, cancel := context.WithTimeout(ctx, timeout) defer cancel() var snapshot AgentSnapshot - err := client.sdk.InvokeRPC(rpcContext, string(flowID), client.flow.GetSnapshot, nil, &snapshot, dex.InvokeOptions{ - Timeout: timeout, - LoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, - LoadChannels: []dex.ChannelDef{ - queuedUserMessagesChannel, - steeredUserMessagesChannel, - }, - }) + err := client.sdk.InvokeRPC(rpcContext, string(flowID), client.flow.GetSnapshot, nil, &snapshot) return snapshot, err } @@ -270,17 +256,11 @@ func (client *Client) GetArchivedMessages(ctx context.Context, flowID FlowID, be if err := validateFlowID(flowID); err != nil { return HistoryPage{}, err } - first, isValid := archivedMessageChunkFirst(before) - if !isValid { + if _, isValid := archivedMessageChunkFirst(before); !isValid { return HistoryPage{}, fmt.Errorf("before sequence must identify a %d-message boundary", archiveMessageChunkSize) } var result archivedMessagesRPCOutput - err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetArchivedMessages, before, &result, dex.InvokeOptions{ - Timeout: client.commandTimeout, - LoadAttributeMapInstances: []dex.AttributeMapLoad{ - archivedMessagesAttribute.Load(sequenceKey(first)), - }, - }) + err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetArchivedMessages, before, &result) if err != nil { return HistoryPage{}, err } @@ -452,11 +432,6 @@ func (client *Client) DeleteQueuedMessage(ctx context.Context, flowID FlowID, me client.flow.DeleteQueuedMessage, messageID, &deleted, - dex.InvokeOptions{ - Timeout: client.commandTimeout, - IsTransactional: true, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - }, ); err != nil { return err } @@ -475,11 +450,7 @@ func (client *Client) ApproveTool(ctx context.Context, flowID FlowID, request To return errors.New("call ID must not be empty") } var accepted bool - if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ApproveTool, request, &accepted, dex.InvokeOptions{ - Timeout: client.commandTimeout, - IsTransactional: true, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingApprovalAttribute)}, - }); err != nil { + if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ApproveTool, request, &accepted); err != nil { return err } return ensureAccepted(accepted, CommandApproveTool) @@ -515,10 +486,6 @@ func (client *Client) ResolveToolRecovery( client.flow.ResolveToolRecovery, request, accepted, - dex.InvokeOptions{ - Timeout: client.commandTimeout, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, - }, ) }) if err != nil { @@ -536,11 +503,7 @@ func (client *Client) ExecutePlan(ctx context.Context, flowID FlowID, request Pl return errors.New("plan revision must be positive") } var accepted bool - if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ExecutePlan, request, &accepted, dex.InvokeOptions{ - Timeout: client.commandTimeout, - IsTransactional: true, - LockAttributes: []dex.AttributeLock{dex.LockAttribute(agentStateAttribute)}, - }); err != nil { + if err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.ExecutePlan, request, &accepted); err != nil { return err } return ensureAccepted(accepted, CommandExecutePlan) diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index d19abd1..6b76b67 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -43,3 +43,26 @@ func TestListRecentEventsRejectsInvalidLimits(t *testing.T) { } } } + +func TestIsTerminalFlowStatusTreatsContinueAsNewAsActive(t *testing.T) { + t.Parallel() + for _, test := range []struct { + name string + status dex.FlowStatus + terminal bool + }{ + {name: "running", status: dex.FlowRunning}, + {name: "continued as new", status: dex.FlowContinuedAsNew}, + {name: "completed", status: dex.FlowCompleted, terminal: true}, + {name: "failed", status: dex.FlowFailed, terminal: true}, + {name: "canceled", status: dex.FlowCanceled, terminal: true}, + {name: "terminated", status: dex.FlowTerminated, terminal: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := isTerminalFlowStatus(test.status); got != test.terminal { + t.Fatalf("isTerminalFlowStatus(%v) = %t, want %t", test.status, got, test.terminal) + } + }) + } +} diff --git a/internal/agent/flow.go b/internal/agent/flow.go index 15983a1..f051e9f 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -56,8 +56,9 @@ var ( // Flow is the durable AI Agent state machine. type Flow struct { - modelClient ModelClient - tools ToolRegistry + modelClient ModelClient + tools ToolRegistry + rpcDefinitionsForTestOnly []dex.RPCDef } var _ dex.Flow = (*Flow)(nil) @@ -102,6 +103,58 @@ func (flow *Flow) GetSteps() []dex.StepDef { } } +// GetRPCs registers synchronous Agent reads and commands with immutable execution policy. +func (flow *Flow) GetRPCs() []dex.RPCDef { + definitions := []dex.RPCDef{ + dex.DefineRPC(flow.SendMessage, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, + }), + dex.DefineRPC(flow.AnswerQuestions, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingUserInputAttribute)}, + }), + dex.DefineRPC(flow.SteerMessage, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, + IsTransactional: true, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + }), + dex.DefineRPC(flow.GetSnapshot, &dex.RPCOptions{ + Timeout: defaultSnapshotTimeout, + LoadAttributeMaps: []dex.AttributeDef{currentMessagesAttribute}, + LoadChannels: []dex.ChannelDef{ + queuedUserMessagesChannel, + steeredUserMessagesChannel, + }, + }), + dex.DefineRPC(flow.GetArchivedMessages, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LoadAttributeMaps: []dex.AttributeDef{archivedMessagesAttribute}, + }), + dex.DefineRPC(flow.DeleteQueuedMessage, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + IsTransactional: true, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + }), + dex.DefineRPC(flow.ApproveTool, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingApprovalAttribute)}, + IsTransactional: true, + }), + dex.DefineRPC(flow.ResolveToolRecovery, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(pendingToolRecoveryAttribute)}, + }), + dex.DefineRPC(flow.ExecutePlan, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LockAttributes: []dex.AttributeLock{dex.LockAttribute(agentStateAttribute)}, + IsTransactional: true, + }), + } + return append(definitions, flow.rpcDefinitionsForTestOnly...) +} + // GetPersistenceSchema registers every durable value and best-effort stream. func (*Flow) GetPersistenceSchema() dex.PersistenceSchema { return dex.PersistenceSchema{ diff --git a/internal/agent/flow_integration_test.go b/internal/agent/flow_integration_test.go index 2d34b03..7c26f91 100644 --- a/internal/agent/flow_integration_test.go +++ b/internal/agent/flow_integration_test.go @@ -1499,6 +1499,48 @@ func TestAgentTerminalSnapshotIntegration(t *testing.T) { } } +func TestAgentSnapshotAfterContinueAsNewIntegration(t *testing.T) { + environment := newAgentIntegrationEnvironment(t, integrationModel{}, newIntegrationToolRegistry()) + flowID := FlowID("agent-continue-as-new-" + randomLocalID(t)) + firstRunID, err := environment.agent.Start(t.Context(), flowID, StartRequest{Config: NewAgentConfig()}) + if err != nil { + t.Fatal(err) + } + waitForAgentState(t, environment, flowID, func(state AgentState) bool { + return state.Status == AgentStatusWaitingForMessage + }) + if err := environment.sdk.TriggerContinueAsNew(t.Context(), string(flowID)); err != nil { + t.Fatal(err) + } + waitUntil(t, environment, "continued Agent run", func() (bool, error) { + page, searchErr := environment.sdk.SearchFlows( + t.Context(), + "WorkflowId="+visibilityString(string(flowID)), + 100, + "", + ) + if searchErr != nil { + return false, searchErr + } + for _, candidate := range page.Flows { + if candidate.RunID == string(firstRunID) && candidate.Status == dex.FlowContinuedAsNew { + return true, nil + } + } + return false, nil + }) + + snapshot := readSnapshot(t, environment, flowID) + if snapshot.RunID == firstRunID || snapshot.FlowStatus != FlowStatusRunning || snapshot.Description == nil { + t.Fatalf("Snapshot after continue-as-new = %#v", snapshot) + } + environment.replaceWorker(t, flowID) + replaced := readSnapshot(t, environment, flowID) + if replaced.RunID != snapshot.RunID || replaced.FlowStatus != FlowStatusRunning { + t.Fatalf("Snapshot after Worker replacement = %#v, want run %q", replaced, snapshot.RunID) + } +} + func readSnapshot( t *testing.T, environment *agentIntegrationEnvironment, @@ -1577,10 +1619,32 @@ type agentIntegrationEnvironment struct { agent *Client } +func registerRPCDefinitionsForTestOnly(flow *Flow) { + flow.rpcDefinitionsForTestOnly = []dex.RPCDef{ + dex.DefineRPC(flow.GetFlowStateForTestOnly, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, + }), + dex.DefineRPC(flow.GetPlanExecutionMessagesForTestOnly, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LoadChannelMaps: []dex.ChannelDef{planExecutionsChannel}, + }), + dex.DefineRPC(flow.GetMessagesAfterForTestOnly, &dex.RPCOptions{ + Timeout: defaultCommandTimeout, + LoadAttributeMaps: []dex.AttributeDef{ + currentMessagesAttribute, + archivedMessagesAttribute, + }, + }), + } +} + func newAgentIntegrationEnvironment(t *testing.T, modelClient ModelClient, tools ToolRegistry) *agentIntegrationEnvironment { t.Helper() + flow := NewFlow(modelClient, tools) + registerRPCDefinitionsForTestOnly(flow) environment := &agentIntegrationEnvironment{ - flow: NewFlow(modelClient, tools), + flow: flow, address: availableLocalAddress(t, t.Context()), serverAddress: os.Getenv("DEX_FLOW_SERVICE_ADDRESS"), } diff --git a/internal/agent/history_test.go b/internal/agent/history_test.go index d8bd655..bea54d1 100644 --- a/internal/agent/history_test.go +++ b/internal/agent/history_test.go @@ -105,10 +105,6 @@ func (client *Client) GetFlowStateForTestOnly( client.flow.GetFlowStateForTestOnly, nil, &result, - dex.InvokeOptions{ - Timeout: client.commandTimeout, - LoadChannels: []dex.ChannelDef{queuedUserMessagesChannel}, - }, ) return result, err } @@ -141,12 +137,6 @@ func (client *Client) GetPlanExecutionMessagesForTestOnly( client.flow.GetPlanExecutionMessagesForTestOnly, revision, &messages, - dex.InvokeOptions{ - Timeout: client.commandTimeout, - LoadChannelMapInstances: []dex.ChannelMapLoad{ - planExecutionsChannel.LoadMessages(planRevisionKey(revision)), - }, - }, ) return messages, err } @@ -231,13 +221,6 @@ func (client *Client) GetMessagesAfterForTestOnly( client.flow.GetMessagesAfterForTestOnly, getMessagesAfterInputForTestOnly{After: after, Limit: limit}, &page, - dex.InvokeOptions{ - Timeout: client.commandTimeout, - LoadAttributeMaps: []dex.AttributeDef{ - currentMessagesAttribute, - archivedMessagesAttribute, - }, - }, ) return page, err } diff --git a/script/check_dex_release.py b/script/check_dex_release.py index 11baa25..3f5cf1d 100644 --- a/script/check_dex_release.py +++ b/script/check_dex_release.py @@ -30,7 +30,7 @@ def main() -> None: lock = json.loads((ROOT / "dex-release.lock.json").read_text(encoding="utf-8")) - if set(lock) - {"sdkManifest"} != { + if set(lock) - {"sdkManifest", "sdkGoRelease"} != { "schemaVersion", "release", "manifest", @@ -42,6 +42,10 @@ def main() -> None: "openFlowsCompatibility", }: raise update_dex_release.UpgradeError("Dex release lock has unexpected fields") + update_dex_release.require( + not ({"sdkManifest", "sdkGoRelease"} <= set(lock)), + "Dex release lock cannot use two SDK sources", + ) content = update_dex_release.download(lock["manifest"]["url"]) manifest = update_dex_release.validate_manifest( lock["manifest"]["url"], lock["manifest"]["sha256"], content @@ -52,6 +56,31 @@ def main() -> None: sdk_manifest = update_dex_release.validate_manifest( source["url"], source["sha256"], update_dex_release.download(source["url"]) ) + sdk_release = lock.get("sdkGoRelease") + if sdk_release is not None: + update_dex_release.require( + set(sdk_release) == {"tag", "sourceCommit", "moduleChecksum", "goModChecksum"}, + "Dex Go SDK release lock has unexpected fields", + ) + version = lock["sdkGoVersion"] + update_dex_release.require( + sdk_release["tag"] == f"sdk-go/v{version}", + "Dex Go SDK tag mismatch", + ) + update_dex_release.require( + re.fullmatch(r"[0-9a-f]{40}", sdk_release["sourceCommit"]) is not None, + "Dex Go SDK source commit is invalid", + ) + update_dex_release.require( + re.fullmatch(r"h1:[A-Za-z0-9+/]+={0,2}", sdk_release["moduleChecksum"]) + is not None, + "Dex Go SDK module checksum is invalid", + ) + update_dex_release.require( + re.fullmatch(r"h1:[A-Za-z0-9+/]+={0,2}", sdk_release["goModChecksum"]) + is not None, + "Dex Go SDK go.mod checksum is invalid", + ) requirements = dict( re.findall(r"(?m)^\s*([^\s()]+)\s+(v[^\s]+)(?:\s+//.*)?$", (ROOT / "go.mod").read_text(encoding="utf-8")) ) @@ -60,18 +89,31 @@ def main() -> None: update_dex_release.require( lock["sourceCommit"] == manifest["sourceCommit"], "Dex source commit mismatch" ) - update_dex_release.require( - lock["sdkGoVersion"] == sdk_manifest["components"]["sdkGo"]["version"], - "Dex Go SDK version mismatch", - ) + if sdk_release is None: + update_dex_release.require( + lock["sdkGoVersion"] == sdk_manifest["components"]["sdkGo"]["version"], + "Dex Go SDK version mismatch", + ) update_dex_release.require( requirements.get("github.com/superdurable/dex/sdk-go") == f'v{lock["sdkGoVersion"]}', "SuperAgent must directly require the locked Dex Go SDK", ) - update_dex_release.require( - lock["protocol"] == sdk_manifest["protocol"]["clients"]["sdkGo"], - "Dex protocol mismatch", - ) + if sdk_release is None: + update_dex_release.require( + lock["protocol"] == sdk_manifest["protocol"]["clients"]["sdkGo"], + "Dex protocol mismatch", + ) + else: + sums = (ROOT / "go.sum").read_text(encoding="utf-8").splitlines() + module = f'github.com/superdurable/dex/sdk-go v{lock["sdkGoVersion"]}' + update_dex_release.require( + f'{module} {sdk_release["moduleChecksum"]}' in sums, + "Dex Go SDK module checksum mismatch", + ) + update_dex_release.require( + f'{module}/go.mod {sdk_release["goModChecksum"]}' in sums, + "Dex Go SDK go.mod checksum mismatch", + ) server_protocol = manifest["protocol"]["server"] update_dex_release.require( max(lock["protocol"]["minimum"], server_protocol["minimum"]) diff --git a/script/install-dexcli.sh b/script/install-dexcli.sh index 4aef5d3..cfb2c0a 100755 --- a/script/install-dexcli.sh +++ b/script/install-dexcli.sh @@ -22,10 +22,10 @@ esac archive_name="dexcli_${version}_${operating_system}_${architecture}.tar.gz" case "$archive_name" in - dexcli_v0.9.0_darwin_amd64.tar.gz) checksum=071f530422e869554b2e2a2dc10ce5d917e1093a38a5af4e1438192e9c532408 ;; - dexcli_v0.9.0_darwin_arm64.tar.gz) checksum=4ee2df39d0218169b5fe0fc581e9cac2c1f40e24a011ac5c5ba441eccdfd1f51 ;; - dexcli_v0.9.0_linux_amd64.tar.gz) checksum=0df459cdde367191e7c962b819a1491073b614da93f5459129f38f90970a7016 ;; - dexcli_v0.9.0_linux_arm64.tar.gz) checksum=68f5771cde6ae4a1cfb8c78efb35881765273d4727d6353de41d6b4252476d67 ;; + dexcli_v0.10.0_darwin_amd64.tar.gz) checksum=927d48d360da5183b4956823e827f890fde6da8756a954e5f647098e0e6c348a ;; + dexcli_v0.10.0_darwin_arm64.tar.gz) checksum=1f9c12be1a8b4c7f65af57b93db2a125ff70be63daaf98e902a2b792b095bf97 ;; + dexcli_v0.10.0_linux_amd64.tar.gz) checksum=0cee3b0795147b581c45d2258b0c2d581cd35ce75c515027e2d9b294ce364e2d ;; + dexcli_v0.10.0_linux_arm64.tar.gz) checksum=6ce2d4cdc8a2d91b6fba0c549bdf238ef8d210de69cd140bf60deef59c1412f4 ;; *) echo "no checksum is pinned for $archive_name" >&2; exit 1 ;; esac diff --git a/script/update_dex_release.py b/script/update_dex_release.py index f2126f1..12dc1a9 100644 --- a/script/update_dex_release.py +++ b/script/update_dex_release.py @@ -148,7 +148,10 @@ def update_repository( if previous is not None: lock["sdkGoVersion"] = previous["sdkGoVersion"] lock["protocol"] = previous["protocol"] - lock["sdkManifest"] = previous.get("sdkManifest", previous["manifest"]) + if "sdkGoRelease" in previous: + lock["sdkGoRelease"] = previous["sdkGoRelease"] + else: + lock["sdkManifest"] = previous.get("sdkManifest", previous["manifest"]) (root / "dex-release.lock.json").write_text( json.dumps(lock, indent=2) + "\n", encoding="utf-8", diff --git a/script/update_dex_release_test.py b/script/update_dex_release_test.py index 3b23d8b..2971e98 100644 --- a/script/update_dex_release_test.py +++ b/script/update_dex_release_test.py @@ -136,6 +136,48 @@ def test_rejects_tampering_and_incompatible_protocol(self) -> None: incompatible_content, ) + def test_server_only_upgrade_preserves_checksum_locked_sdk_release(self) -> None: + value = manifest() + content = (json.dumps(value) + "\n").encode() + digest = hashlib.sha256(content).hexdigest() + url = ( + "https://github.com/superdurable/dex/releases/download/server/v1.2.3/" + "dex-compatibility-v1.2.3.json" + ) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "script").mkdir() + (root / "go.mod").write_text( + "require github.com/superdurable/dex/sdk-go v0.9.1\n", encoding="utf-8" + ) + (root / "Makefile").write_text("DEXCLI_VERSION := v1.2.2\n", encoding="utf-8") + (root / "script/install-dexcli.sh").write_text( + "case x in\n" + + "\n".join(f" dexcli_v1.2.2_{name}.tar.gz) checksum=old ;;" for name in ( + "darwin_amd64", "darwin_arm64", "linux_amd64", "linux_arm64" + )) + + "\nesac\n", + encoding="utf-8", + ) + sdk_release = { + "tag": "sdk-go/v0.9.1", + "sourceCommit": "b" * 40, + "moduleChecksum": "h1:module", + "goModChecksum": "h1:gomod", + } + (root / "dex-release.lock.json").write_text(json.dumps({ + "sdkGoVersion": "0.9.1", + "protocol": {"minimum": 2, "maximum": 3}, + "sdkGoRelease": sdk_release, + }), encoding="utf-8") + + MODULE.update_repository(root, url, digest, value, server_only=True) + + lock = json.loads((root / "dex-release.lock.json").read_text(encoding="utf-8")) + self.assertEqual(lock["sdkGoVersion"], "0.9.1") + self.assertEqual(lock["sdkGoRelease"], sdk_release) + self.assertNotIn("sdkManifest", lock) + def test_upgrade_workflow_opens_a_draft_before_product_ci(self) -> None: workflow = (MODULE.ROOT / ".github/workflows/dex-release-upgrade.yml").read_text( encoding="utf-8" From c997f126fdd64f0dee6b3f1fda9656cf946b0481 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 22:16:10 -0700 Subject: [PATCH 5/8] fix: keep agent snapshot application-only --- ARCHITECTURE.md | 16 +- agent/agent.go | 18 -- api/openapi.yaml | 34 +-- ...rk-and-input-consumption-reconciliation.md | 11 +- docs/adr/0015-application-only-snapshot.md | 39 +++ docs/dex-v0.10-upgrade.md | 9 +- docs/flow-model.md | 12 +- internal/agent/client.go | 173 +---------- internal/agent/client_test.go | 23 -- internal/agent/flow.go | 10 +- internal/agent/flow_integration_test.go | 117 +++----- internal/agent/types.go | 77 +---- internal/agent/types_test.go | 8 - internal/api/generated/oas_json_gen.go | 249 +--------------- internal/api/generated/oas_schemas_gen.go | 277 +----------------- internal/api/generated/oas_validators_gen.go | 78 +---- internal/api/handler.go | 95 +----- internal/api/handler_test.go | 48 +-- internal/api/server_integration_test.go | 19 +- internal/api/server_test.go | 5 +- web/src/App.test.tsx | 26 -- web/src/Conversation.tsx | 41 +-- web/src/ConversationView.tsx | 2 - web/src/api/generated/index.ts | 2 +- web/src/api/generated/types.gen.ts | 27 +- web/src/conversation-state.test.ts | 46 +-- web/src/conversation-state.ts | 102 +------ web/src/snapshot-coordinator.test.ts | 23 +- web/tests/full-stack.spec.ts | 19 +- web/tests/portal.spec.ts | 3 - 30 files changed, 212 insertions(+), 1397 deletions(-) create mode 100644 docs/adr/0015-application-only-snapshot.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e78ab8b..8c72ca4 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -140,11 +140,11 @@ replacement. They also receive Dex attempt metadata. The browser performs one generated `GET /products/ai-agent/snapshot` on load and atomically replaces history, description, queued messages, steered messages, -and Run identity through one reducer action. Before invoking the Snapshot RPC, -the backend checks the indexed Flow lifecycle so a terminated Flow cannot return -its last running projection. It then lists the configured recent tail of each -Stream, applies those events chronologically, and long-polls from the newest -returned resume token. The browser orders every +and Run identity through one reducer action. Snapshot is an application-state +query, not a Dex lifecycle projection. It remains readable from retained closed +Flow history and returns the last durable application view. The browser then +lists the configured recent tail of each Stream, applies those events +chronologically, and long-polls from the newest returned resume token. The browser orders every observed activity event, reasoning summary, live assistant response, and durable message in one timeline by creation time. Reasoning entries are keyed by the producing model invocation source. Completion activity marks later text from @@ -159,9 +159,7 @@ pending question increments it only when `AnsweredUserInputs` is empty, then waits exclusively for an answer. The browser takes the first Snapshot round as a watermark, then long-polls for `round > watermark`. Each response returns the actual matched round, which -becomes the next watermark before requesting Snapshot. The ongoing round wait -also discovers Flow closure; Snapshot's lifecycle guard remains the terminal -recovery path. +becomes the next watermark before requesting Snapshot. Every consumed queued message, steered message, or Plan execution request emits one `input_consumed` Activity event with exact application IDs or revision and @@ -190,8 +188,6 @@ reconcile also close this gate. Ordinary Stream events other than explicit Snapshot controls do not request Snapshot. A visible-page configurable single-shot freshness timer defaults to 60 seconds and starts only after the prior Snapshot finishes, so an intervening read resets the complete delay. -Terminal reconciliation stops Streams, Attribute waits, Snapshot work, and the -timer. Resume tokens belong to the live subscription and are not durable UI state. Activity events are independent timeline rows keyed by resume token. A page diff --git a/agent/agent.go b/agent/agent.go index 7a84638..99922e3 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -79,22 +79,6 @@ const ( AgentStatusApplyingSteering = agentinternal.AgentStatusApplyingSteering ) -const ( - FlowStatusRunning = agentinternal.FlowStatusRunning - FlowStatusCompleted = agentinternal.FlowStatusCompleted - FlowStatusFailed = agentinternal.FlowStatusFailed - FlowStatusTerminated = agentinternal.FlowStatusTerminated - FlowStatusCanceled = agentinternal.FlowStatusCanceled - FlowStatusContinuedAsNew = agentinternal.FlowStatusContinuedAsNew - - FlowErrorTypeStepDecision = agentinternal.FlowErrorTypeStepDecision - FlowErrorTypeClientAPI = agentinternal.FlowErrorTypeClientAPI - FlowErrorTypeWorkerMethod = agentinternal.FlowErrorTypeWorkerMethod - FlowErrorTypeInvalidUserCode = agentinternal.FlowErrorTypeInvalidUserCode - FlowErrorTypeInternal = agentinternal.FlowErrorTypeInternal - FlowErrorTypeTimeout = agentinternal.FlowErrorTypeTimeout -) - const ( InteractionModeChat = agentinternal.InteractionModeChat InteractionModePlanning = agentinternal.InteractionModePlanning @@ -188,8 +172,6 @@ type ( AgentStatus = agentinternal.AgentStatus WaitingInputRound = agentinternal.WaitingInputRound - FlowStatus = agentinternal.FlowStatus - FlowErrorType = agentinternal.FlowErrorType InteractionMode = agentinternal.InteractionMode PlanStatus = agentinternal.PlanStatus TaskStatus = agentinternal.TaskStatus diff --git a/api/openapi.yaml b/api/openapi.yaml index acc5e54..88d56a4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -886,9 +886,6 @@ components: additionalProperties: false required: - runId - - flowStatus - - errorType - - errorMessage - history - description - queued @@ -896,21 +893,10 @@ components: properties: runId: $ref: "#/components/schemas/RunId" - flowStatus: - $ref: "#/components/schemas/FlowStatus" - errorType: - allOf: - - $ref: "#/components/schemas/FlowErrorType" - nullable: true - errorMessage: - type: string - nullable: true history: $ref: "#/components/schemas/HistoryPage" description: - allOf: - - $ref: "#/components/schemas/AgentDescription" - nullable: true + $ref: "#/components/schemas/AgentDescription" queued: type: array items: @@ -935,24 +921,6 @@ components: allOf: - $ref: "#/components/schemas/Sequence" nullable: true - FlowStatus: - type: string - enum: - - running - - completed - - failed - - terminated - - canceled - - continued_as_new - FlowErrorType: - type: string - enum: - - step_decision - - client_api - - worker_method - - invalid_user_code - - internal - - timeout SequencedMessage: type: object additionalProperties: false diff --git a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md index 0723a1c..f59f9d5 100644 --- a/docs/adr/0012-watermark-and-input-consumption-reconciliation.md +++ b/docs/adr/0012-watermark-and-input-consumption-reconciliation.md @@ -2,8 +2,9 @@ ## Status -Accepted on 2026-09-14. Supersedes the synchronization policy in ADRs 0004, -0006, and 0007. Supersedes ADR 0010's placement of credential maintenance. +Accepted on 2026-09-14 and amended by ADR 0015. Supersedes the synchronization +policy in ADRs 0004, 0006, and 0007. Supersedes ADR 0010's placement of +credential maintenance. ## Context @@ -36,9 +37,9 @@ completion. Other waits never change the round. The browser reads the initial round from Snapshot, waits for a strictly greater value, adopts the actual matched value as its next watermark, and requests a new Snapshot. It does not create an equality lifecycle probe after Snapshot. -The Snapshot backend checks indexed Flow lifecycle before invoking its durable -read RPC so a terminal Flow cannot return the last running projection. The -visible-page fallback is configurable at runtime and defaults to 60 seconds. +Snapshot reads only durable application state; ADR 0015 removes the indexed +Flow lifecycle probe and terminal browser projection. The visible-page fallback +is configurable at runtime and defaults to 60 seconds. Send payloads receive one stable application message ID before RPC retry. Steering preserves it. Snapshot exposes the application ID while Dex diff --git a/docs/adr/0015-application-only-snapshot.md b/docs/adr/0015-application-only-snapshot.md new file mode 100644 index 0000000..3861fab --- /dev/null +++ b/docs/adr/0015-application-only-snapshot.md @@ -0,0 +1,39 @@ +# ADR 0015: Keep Snapshot application-only + +## Status + +Accepted on 2026-09-17. + +## Context + +Snapshot is the durable current-interaction read model. It previously queried +Dex visibility before its Flow RPC, synthesized a separate terminal response +through `WaitForFlow`, and exposed lifecycle and failure metadata to the browser. +That split the read across independent consistency boundaries and put an +eventually consistent visibility query on every Snapshot request. + +Dex Server `v0.10.0` executes an RPC without locks or transactional mode through +Temporal Query. A real Temporal integration test confirms that this path remains +readable after Flow closure. The terminal rejection test in Dex forces all RPCs +through synchronous Update and does not describe the Query path. + +## Decision + +`GetSnapshot` invokes its registered read-only RPC exactly once. It does not call +`SearchFlows`, `WaitForFlow`, or retry `FlowNotActiveError` or +`LongPollTimeoutError`. Snapshot contains Run ID and durable Agent application +state. It does not contain Dex lifecycle status, terminal failure metadata, or a +nullable terminal description. + +Run ID remains the execution-generation key. The browser uses a run change to +discard transient reconciliation state and reset Stream resume tokens across +continue-as-new. The browser has no terminal Snapshot state or terminal result +screen. + +## Consequences + +Snapshot has one coherent application-state boundary and no visibility-index +dependency. A retained closed Flow returns its last durable Agent view. Missing, +expired, deleted, or globally forced-Update executions can still return their +typed Dex error. Lifecycle inspection is an operational concern outside the +Snapshot HTTP contract. diff --git a/docs/dex-v0.10-upgrade.md b/docs/dex-v0.10-upgrade.md index 35c94d4..fce726d 100644 --- a/docs/dex-v0.10-upgrade.md +++ b/docs/dex-v0.10-upgrade.md @@ -28,10 +28,11 @@ locked. Unit compilation verifies the explicit RPC registration API. Real Server integration, visualization, complete checks, and browser E2E must pass before release. -Earlier browser E2E exposed Snapshot long-poll expiry and continue-as-new -visibility races. Snapshot retries typed read-only expiry errors and does not -treat `ContinuedAsNew` as terminal. The regression scenarios remain release -gates. +Server implementation and real Temporal integration verify that a non-locking, +non-transactional read-only RPC uses Query and remains readable after Flow +closure. Snapshot therefore performs one RPC without visibility lookup, +completion wait, or lifecycle-error retry. Continue-as-new remains a direct RPC +regression gate. ## Documentation diff --git a/docs/flow-model.md b/docs/flow-model.md index 17288b8..246dcdb 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -33,10 +33,11 @@ Attribute index synchronization or Worker binding. Deploy the Server before the Worker. Startup fails when `GetServerInfo` is missing, either interval is invalid, or the intervals do not overlap. -Snapshot reads retry inactive-run and server long-poll expiry errors within a -three-attempt budget. Snapshot is read-only, so these retries cannot duplicate -commands or external effects. Caller cancellation and other errors still return -immediately. +Snapshot performs one read-only RPC. It does not query Dex visibility, wait for +Flow completion, or retry lifecycle errors. Temporal Query serves the retained +application state after Flow closure. Run ID identifies the execution generation +so the browser can reset transient Stream and reconciliation state after +continue-as-new. Renewable sandbox credentials are not an Agent Flow resource. A future, separately designed `SandboxLifecycleFlow` will own that lifecycle. @@ -225,7 +226,8 @@ retained messages. Snapshot is one read-only Flow RPC that loads current history, the interaction description, and pending Channels. It returns `WaitingInputRound` and stable -application message IDs. Archive paging returns exactly one immutable chunk and +application message IDs. Snapshot contains application state only; it does not +project Dex lifecycle or terminal failure metadata. Archive paging returns exactly one immutable chunk and the bounded sequence metadata needed for continuation. Its registered `v0.9.1` RPC options load the retained archive map because the requested chunk key is an RPC input and invocation-specific selective loads no longer exist. diff --git a/internal/agent/client.go b/internal/agent/client.go index 0c25fe5..82b15b8 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -28,11 +28,9 @@ import ( ) const ( - defaultCommandTimeout = 20 * time.Second - defaultEventPoll = 20 * time.Second - // Snapshot reads use a shorter budget so clients can retry across continue-as-new. - defaultSnapshotTimeout = 5 * time.Second - maximumSnapshotAttempts = 3 + defaultCommandTimeout = 20 * time.Second + defaultEventPoll = 20 * time.Second + defaultSnapshotTimeout = 5 * time.Second // MaximumRecentEventLimit matches Dex's default maximum Stream list page size. MaximumRecentEventLimit = 1_000 ) @@ -206,48 +204,8 @@ func (client *Client) GetSnapshot( if err := validateFlowID(flowID); err != nil { return AgentSnapshot{}, err } - current, statusErr := client.latestAgentRun(ctx, flowID) - if statusErr == nil && current != nil && isTerminalFlowStatus(current.Status) { - return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) - } - var retryErr error - for range maximumSnapshotAttempts { - snapshot, err := client.invokeSnapshotRPC(ctx, flowID) - if err == nil { - return snapshot, nil - } - var inactive *dex.FlowNotActiveError - var pollTimeout *dex.LongPollTimeoutError - if !errors.As(err, &inactive) && !errors.As(err, &pollTimeout) { - return AgentSnapshot{}, err - } - // Snapshot is read-only, so server long-poll expiry can safely retry within this bounded budget. - retryErr = err - current, statusErr = client.latestAgentRun(ctx, flowID) - if statusErr != nil { - return AgentSnapshot{}, errors.Join(err, statusErr) - } - if current == nil || !isTerminalFlowStatus(current.Status) { - continue - } - return client.terminalSnapshot(ctx, flowID, RunID(current.RunID)) - } - return AgentSnapshot{}, retryErr -} - -func isTerminalFlowStatus(status dex.FlowStatus) bool { - return (dex.FlowResult{Status: status}).IsTerminal() -} - -func (client *Client) invokeSnapshotRPC(ctx context.Context, flowID FlowID) (AgentSnapshot, error) { - timeout := client.commandTimeout - if timeout <= 0 || timeout > defaultSnapshotTimeout { - timeout = defaultSnapshotTimeout - } - rpcContext, cancel := context.WithTimeout(ctx, timeout) - defer cancel() var snapshot AgentSnapshot - err := client.sdk.InvokeRPC(rpcContext, string(flowID), client.flow.GetSnapshot, nil, &snapshot) + err := client.sdk.InvokeRPC(ctx, string(flowID), client.flow.GetSnapshot, nil, &snapshot) return snapshot, err } @@ -294,129 +252,6 @@ func (client *Client) WaitForWaitingInputRound( return matched, err } -func (client *Client) terminalSnapshot( - ctx context.Context, - flowID FlowID, - runID RunID, -) (AgentSnapshot, error) { - result, err := client.sdk.WaitForFlow(ctx, string(flowID), dex.WaitForFlowOptions{}) - if err != nil { - return AgentSnapshot{}, fmt.Errorf("read terminal Flow result: %w", err) - } - status, err := flowStatusFromDex(result.Status) - if err != nil { - return AgentSnapshot{}, err - } - if status == FlowStatusRunning { - return AgentSnapshot{}, errors.New("inactive Agent resolved to a non-terminal Flow") - } - if runID == "" { - runID, err = client.currentRunID(ctx, flowID) - if err != nil { - return AgentSnapshot{}, err - } - } - errorType, err := flowErrorTypeFromDex(result.ErrorType) - if err != nil { - return AgentSnapshot{}, err - } - var errorMessage *string - if result.ErrorMessage != "" { - message := result.ErrorMessage - errorMessage = &message - } - return AgentSnapshot{ - RunID: runID, - FlowStatus: status, - ErrorType: errorType, - ErrorMessage: errorMessage, - History: HistoryPage{Messages: []SequencedMessage{}}, - Queued: []PendingUserMessage{}, - Steered: []PendingUserMessage{}, - }, nil -} - -func (client *Client) currentRunID(ctx context.Context, flowID FlowID) (RunID, error) { - current, err := client.latestAgentRun(ctx, flowID) - if err != nil { - return "", err - } - if current == nil { - return "", fmt.Errorf("agent Flow %q has no searchable run", flowID) - } - return RunID(current.RunID), nil -} - -func (client *Client) latestAgentRun(ctx context.Context, flowID FlowID) (*dex.SearchFlowEntry, error) { - query := "WorkflowId=" + visibilityString(string(flowID)) - page, err := client.sdk.SearchFlows(ctx, query, 100, "") - if err != nil { - return nil, fmt.Errorf("find Agent Flow run: %w", err) - } - var current *dex.SearchFlowEntry - for index := range page.Flows { - candidate := &page.Flows[index] - if candidate.FlowID != string(flowID) || candidate.FlowType != flowTypeAIAgent { - continue - } - if current == nil || candidate.StartedAt.After(current.StartedAt) { - current = candidate - } - } - if current == nil { - return nil, nil - } - return current, nil -} - -func visibilityString(value string) string { - return "'" + strings.ReplaceAll(value, "'", "''") + "'" -} - -func flowStatusFromDex(status dex.FlowStatus) (FlowStatus, error) { - switch status { - case dex.FlowRunning: - return FlowStatusRunning, nil - case dex.FlowCompleted: - return FlowStatusCompleted, nil - case dex.FlowFailed: - return FlowStatusFailed, nil - case dex.FlowTerminated: - return FlowStatusTerminated, nil - case dex.FlowCanceled: - return FlowStatusCanceled, nil - case dex.FlowContinuedAsNew: - return FlowStatusContinuedAsNew, nil - case dex.FlowServerSideTimeoutInternalOnly: - return "", errors.New("dex returned its internal-only Flow timeout status") - default: - return "", fmt.Errorf("unknown Dex Flow status %d", status) - } -} - -func flowErrorTypeFromDex(errorType dex.FlowErrorType) (*FlowErrorType, error) { - var mapped FlowErrorType - switch errorType { - case 0: - return nil, nil - case dex.FlowErrorStepDecision: - mapped = FlowErrorTypeStepDecision - case dex.FlowErrorClientAPI: - mapped = FlowErrorTypeClientAPI - case dex.FlowErrorWorkerMethod: - mapped = FlowErrorTypeWorkerMethod - case dex.FlowErrorInvalidUserCode: - mapped = FlowErrorTypeInvalidUserCode - case dex.FlowErrorInternal: - mapped = FlowErrorTypeInternal - case dex.FlowErrorTimeout: - mapped = FlowErrorTypeTimeout - default: - return nil, fmt.Errorf("unknown Dex Flow error type %d", errorType) - } - return &mapped, nil -} - // DeleteQueuedMessage removes one exact pending user message. func (client *Client) DeleteQueuedMessage(ctx context.Context, flowID FlowID, messageID MessageID) error { if err := validateFlowID(flowID); err != nil { diff --git a/internal/agent/client_test.go b/internal/agent/client_test.go index 6b76b67..d19abd1 100644 --- a/internal/agent/client_test.go +++ b/internal/agent/client_test.go @@ -43,26 +43,3 @@ func TestListRecentEventsRejectsInvalidLimits(t *testing.T) { } } } - -func TestIsTerminalFlowStatusTreatsContinueAsNewAsActive(t *testing.T) { - t.Parallel() - for _, test := range []struct { - name string - status dex.FlowStatus - terminal bool - }{ - {name: "running", status: dex.FlowRunning}, - {name: "continued as new", status: dex.FlowContinuedAsNew}, - {name: "completed", status: dex.FlowCompleted, terminal: true}, - {name: "failed", status: dex.FlowFailed, terminal: true}, - {name: "canceled", status: dex.FlowCanceled, terminal: true}, - {name: "terminated", status: dex.FlowTerminated, terminal: true}, - } { - t.Run(test.name, func(t *testing.T) { - t.Parallel() - if got := isTerminalFlowStatus(test.status); got != test.terminal { - t.Fatalf("isTerminalFlowStatus(%v) = %t, want %t", test.status, got, test.terminal) - } - }) - } -} diff --git a/internal/agent/flow.go b/internal/agent/flow.go index f051e9f..a202d6e 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -318,9 +318,8 @@ func (flow *Flow) GetSnapshot(ctx dex.Context, _ dex.None) (*dex.RPCResult[Agent } return &dex.RPCResult[AgentSnapshot]{Output: AgentSnapshot{ RunID: RunID(ctx.RunID()), - FlowStatus: FlowStatusRunning, History: history, - Description: &description, + Description: description, Queued: pendingUserMessages(queued), Steered: pendingUserMessages(steered), }}, nil @@ -540,10 +539,9 @@ func (flow *Flow) initializingSnapshot( steered []dex.ChannelMessage[PendingUserMessage], ) AgentSnapshot { return AgentSnapshot{ - RunID: RunID(ctx.RunID()), - FlowStatus: FlowStatusRunning, - History: HistoryPage{Messages: []SequencedMessage{}}, - Description: &AgentDescription{ + RunID: RunID(ctx.RunID()), + History: HistoryPage{Messages: []SequencedMessage{}}, + Description: AgentDescription{ Status: AgentStatusInitializing, WaitingInputRound: 0, FirstRetainedSequence: 1, diff --git a/internal/agent/flow_integration_test.go b/internal/agent/flow_integration_test.go index 7c26f91..e25b26d 100644 --- a/internal/agent/flow_integration_test.go +++ b/internal/agent/flow_integration_test.go @@ -130,8 +130,7 @@ func TestAgentToolRetryIntegration(t *testing.T) { t.Fatal(err) } snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && - snapshot.Description.Status == AgentStatusWaitingForToolRecovery && + return snapshot.Description.Status == AgentStatusWaitingForToolRecovery && snapshot.Description.PendingToolRecovery != nil }) pending := snapshot.Description.PendingToolRecovery @@ -236,8 +235,7 @@ func TestAgentToolRetryIntegration(t *testing.T) { t.Fatal(err) } snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && - snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.PendingToolRecovery == nil && historyHasMessage( snapshot.History.Messages, @@ -294,8 +292,7 @@ func TestAgentToolRetryIntegration(t *testing.T) { t.Fatalf("accepted concurrent recovery commands = %d, want 1", accepted) } waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && - snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.PendingToolRecovery == nil }) }) @@ -389,8 +386,7 @@ func TestAgentRejectsInvalidWriteTodosIntegration(t *testing.T) { t.Fatal(err) } snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && - snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && historyContainsText(snapshot.History.Messages, string(toolErrorInvalidPlan)) && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration tool result acknowledged") }) @@ -431,8 +427,7 @@ func TestAgentFlowDurabilityIntegration(t *testing.T) { if initialSnapshot.RunID != runID { t.Fatalf("Snapshot run ID = %q, want %q", initialSnapshot.RunID, runID) } - if initialSnapshot.Description == nil || - initialSnapshot.Description.Status != AgentStatusWaitingForMessage || + if initialSnapshot.Description.Status != AgentStatusWaitingForMessage || len(initialSnapshot.History.Messages) != 0 || len(initialSnapshot.Queued) != 0 || len(initialSnapshot.Steered) != 0 { @@ -479,7 +474,7 @@ func TestAgentFlowDurabilityIntegration(t *testing.T) { return event.Kind == EventKindSnapshotRequired }) approvalSnapshot := readSnapshot(t, environment, flowID) - if approvalSnapshot.Description == nil || approvalSnapshot.Description.PendingApproval == nil { + if approvalSnapshot.Description.PendingApproval == nil { t.Fatalf("Snapshot after approval control = %#v", approvalSnapshot) } approval := *approvalSnapshot.Description.PendingApproval @@ -652,9 +647,7 @@ func TestAgentFlowDurabilityIntegration(t *testing.T) { if !errors.As(archiveErr, ¬Found) { t.Fatalf("trimmed archive error = %T %v", archiveErr, archiveErr) } - if snapshot := readSnapshot(t, environment, flowID); snapshot.Description == nil { - t.Fatalf("Snapshot after trimmed archive read = %#v", snapshot) - } + _ = readSnapshot(t, environment, flowID) } } @@ -674,7 +667,7 @@ func TestAgentTimerSnapshotNotificationIntegration(t *testing.T) { return event.Kind == EventKindSnapshotRequired }) snapshot := readSnapshot(t, environment, flowID) - if snapshot.Description == nil || snapshot.Description.PendingTimer == nil { + if snapshot.Description.PendingTimer == nil { t.Fatalf("Snapshot after timer control = %#v", snapshot) } } @@ -686,7 +679,7 @@ func TestAgentWaitingInputRoundIntegration(t *testing.T) { t.Fatal(err) } initial := readSnapshot(t, environment, flowID) - if initial.Description == nil || initial.Description.WaitingInputRound != 1 { + if initial.Description.WaitingInputRound != 1 { t.Fatalf("initial waiting input round = %#v, want 1", initial.Description) } @@ -707,7 +700,7 @@ func TestAgentWaitingInputRoundIntegration(t *testing.T) { t.Fatalf("first round result = %#v", first) } snapshot := readSnapshot(t, environment, flowID) - if snapshot.Description == nil || snapshot.Description.WaitingInputRound != first.round || + if snapshot.Description.WaitingInputRound != first.round || !historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: round cycle") { t.Fatalf("reconciled Snapshot = %#v", snapshot) } @@ -886,7 +879,7 @@ func TestAgentUserInputIntegration(t *testing.T) { t.Fatal(err) } snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput != nil + return snapshot.Description.PendingUserInput != nil }) if len(snapshot.Description.PendingUserInput.Questions) != 1 || snapshot.Description.PendingUserInput.Questions[0].Question != "What date should I use?" { @@ -925,11 +918,11 @@ func TestAgentUserInputIntegration(t *testing.T) { t.Fatal(err) } closed := readSnapshot(t, environment, flowID) - if closed.Description == nil || closed.Description.PendingUserInput != nil { + if closed.Description.PendingUserInput != nil { t.Fatalf("question remained after accepted answer: %#v", closed.Description) } waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput == nil && + return snapshot.Description.PendingUserInput == nil && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: **Details**: September 12") }) staleAnswer := environment.agent.AnswerQuestions( @@ -948,7 +941,7 @@ func TestAgentUserInputIntegration(t *testing.T) { t.Fatal(err) } snapshot = waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput != nil && + return snapshot.Description.PendingUserInput != nil && len(snapshot.Description.PendingUserInput.Questions) == 1 && len(snapshot.Description.PendingUserInput.Questions[0].Options) == 2 }) @@ -962,7 +955,7 @@ func TestAgentUserInputIntegration(t *testing.T) { t.Fatal(err) } waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput == nil && + return snapshot.Description.PendingUserInput == nil && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: **Details**: Production") }) @@ -984,8 +977,7 @@ func TestAgentUserInputIntegration(t *testing.T) { {QuestionID: "unknown", Answer: "Detailed"}, }} assertAnswerRejected(t, environment.agent.AnswerQuestions(t.Context(), flowID, unknown)) - if current := readSnapshot(t, environment, flowID); current.Description == nil || - current.Description.PendingUserInput == nil || current.Description.PendingUserInput.CallID != multi.CallID { + if current := readSnapshot(t, environment, flowID); current.Description.PendingUserInput == nil || current.Description.PendingUserInput.CallID != multi.CallID { t.Fatalf("invalid answer changed pending batch: %#v", current.Description) } @@ -1021,7 +1013,7 @@ func TestAgentUserInputIntegration(t *testing.T) { } const combinedAnswer = "**Region**: West\n\n**Pace**: Careful\n\n**Format**: Detailed" waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput == nil && + return snapshot.Description.PendingUserInput == nil && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: "+combinedAnswer) }) @@ -1070,7 +1062,7 @@ func TestAgentQuestionAnswerPriorityIntegration(t *testing.T) { t.Fatal(err) } initial := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage + return snapshot.Description.Status == AgentStatusWaitingForMessage }) if err := environment.agent.SendMessage(t.Context(), flowID, UserMessage{Content: "/wait"}); err != nil { t.Fatal(err) @@ -1092,7 +1084,7 @@ func TestAgentQuestionAnswerPriorityIntegration(t *testing.T) { t.Fatal(err) } pendingSnapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput != nil && + return snapshot.Description.PendingUserInput != nil && snapshot.Description.WaitingInputRound > initial.Description.WaitingInputRound }) if len(pendingSnapshot.Queued) != 2 { @@ -1111,7 +1103,7 @@ func TestAgentQuestionAnswerPriorityIntegration(t *testing.T) { t.Fatal(err) } pendingSnapshot = waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.PendingUserInput != nil && + return snapshot.Description.PendingUserInput != nil && len(snapshot.Steered) == 1 && len(snapshot.Queued) == 1 }) environment.replaceWorker(t, flowID) @@ -1123,7 +1115,7 @@ func TestAgentQuestionAnswerPriorityIntegration(t *testing.T) { t.Fatal(err) } completed := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && len(snapshot.Queued) == 0 && len(snapshot.Steered) == 0 && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: **Details**: priority answer") && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: steered after question") && @@ -1193,7 +1185,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } revised := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Plan != nil && + return snapshot.Description.Plan != nil && snapshot.Description.Plan.Revision > first.Revision }) if revised.Description.Plan.Tasks[0].Content != "Plan the revised objective" { @@ -1208,7 +1200,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.Plan == nil }) @@ -1218,7 +1210,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } draftSnapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.Plan != nil && snapshot.Description.Plan.Status == PlanStatusDraft }) if err := environment.agent.ExecutePlan(t.Context(), flowID, PlanExecutionRequest{ @@ -1227,7 +1219,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } active := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.Plan != nil && snapshot.Description.Plan.Status == PlanStatusActive }) state := waitForAgentState(t, environment, flowID, func(state AgentState) bool { @@ -1261,7 +1253,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } continued := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.Plan != nil && snapshot.Description.Plan.Status == PlanStatusActive && countHistoryMessages( snapshot.History.Messages, @@ -1280,7 +1272,7 @@ func TestAgentPlanGuardrailsIntegration(t *testing.T) { t.Fatal(err) } afterBlockedTool := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && historyContainsText(snapshot.History.Messages, string(toolErrorUnknownOrDisabled)) }) if afterBlockedTool.Description.Plan == nil || afterBlockedTool.Description.Plan.Status != PlanStatusActive || @@ -1309,7 +1301,7 @@ func TestAgentRejectsPlanExecutionWhileBusyWithoutPublishing(t *testing.T) { t.Fatal(err) } draftSnapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && snapshot.Description.Plan != nil && snapshot.Description.Plan.Status == PlanStatusDraft }) draft := draftSnapshot.Description.Plan @@ -1428,8 +1420,7 @@ func TestAgentBatchSteeringIntegration(t *testing.T) { } queued := waitForQueuedMessages(t, environment, flowID, 2) queuedSnapshot := readSnapshot(t, environment, flowID) - if initial.Description == nil || queuedSnapshot.Description == nil || - queuedSnapshot.Description.WaitingInputRound != initial.Description.WaitingInputRound { + if queuedSnapshot.Description.WaitingInputRound != initial.Description.WaitingInputRound { t.Fatalf("queued input created a false waiting round: initial=%#v queued=%#v", initial.Description, queuedSnapshot.Description) } for _, message := range queued { @@ -1438,7 +1429,7 @@ func TestAgentBatchSteeringIntegration(t *testing.T) { } } snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && snapshot.Description.Status == AgentStatusWaitingForMessage && + return snapshot.Description.Status == AgentStatusWaitingForMessage && len(snapshot.Queued) == 0 && len(snapshot.Steered) == 0 && historyHasMessage(snapshot.History.Messages, MessageRoleAssistant, "integration response: final replacement objective") }) @@ -1460,9 +1451,9 @@ func TestAgentBatchSteeringIntegration(t *testing.T) { ) } -func TestAgentTerminalSnapshotIntegration(t *testing.T) { +func TestAgentSnapshotRemainsReadableAfterTerminationIntegration(t *testing.T) { environment := newAgentIntegrationEnvironment(t, integrationModel{}, newIntegrationToolRegistry()) - flowID := FlowID("agent-terminal-" + randomLocalID(t)) + flowID := FlowID("agent-snapshot-after-termination-" + randomLocalID(t)) runID, err := environment.agent.Start(t.Context(), flowID, StartRequest{Config: NewAgentConfig()}) if err != nil { t.Fatal(err) @@ -1472,7 +1463,7 @@ func TestAgentTerminalSnapshotIntegration(t *testing.T) { }) if err := environment.sdk.StopFlow(t.Context(), string(flowID), dex.StopOptions{ Type: dex.TerminateFlow, - Reason: "terminal Snapshot integration", + Reason: "Snapshot query after termination integration", }); err != nil { t.Fatal(err) } @@ -1486,16 +1477,12 @@ func TestAgentTerminalSnapshotIntegration(t *testing.T) { if _, err := environment.agent.WaitForWaitingInputRound(t.Context(), flowID, 1); err == nil { t.Fatal("waiting input round remained active after termination") } - - snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.FlowStatus == FlowStatusTerminated - }) - if snapshot.RunID != runID || snapshot.FlowStatus != FlowStatusTerminated { - t.Fatalf("terminal Snapshot identity = %#v", snapshot) + snapshot := readSnapshot(t, environment, flowID) + if snapshot.RunID != runID || snapshot.Description.Status != AgentStatusWaitingForMessage { + t.Fatalf("Snapshot after termination = %#v", snapshot) } - if snapshot.Description != nil || snapshot.ErrorType != nil || - len(snapshot.History.Messages) != 0 || len(snapshot.Queued) != 0 || len(snapshot.Steered) != 0 { - t.Fatalf("terminal Snapshot durable view = %#v", snapshot) + if len(snapshot.History.Messages) != 0 || len(snapshot.Queued) != 0 || len(snapshot.Steered) != 0 { + t.Fatalf("Snapshot durable view after termination = %#v", snapshot) } } @@ -1512,31 +1499,18 @@ func TestAgentSnapshotAfterContinueAsNewIntegration(t *testing.T) { if err := environment.sdk.TriggerContinueAsNew(t.Context(), string(flowID)); err != nil { t.Fatal(err) } + var snapshot AgentSnapshot waitUntil(t, environment, "continued Agent run", func() (bool, error) { - page, searchErr := environment.sdk.SearchFlows( - t.Context(), - "WorkflowId="+visibilityString(string(flowID)), - 100, - "", - ) - if searchErr != nil { - return false, searchErr - } - for _, candidate := range page.Flows { - if candidate.RunID == string(firstRunID) && candidate.Status == dex.FlowContinuedAsNew { - return true, nil - } - } - return false, nil + var snapshotErr error + snapshot, snapshotErr = environment.agent.GetSnapshot(t.Context(), flowID) + return snapshotErr == nil && snapshot.RunID != firstRunID, snapshotErr }) - - snapshot := readSnapshot(t, environment, flowID) - if snapshot.RunID == firstRunID || snapshot.FlowStatus != FlowStatusRunning || snapshot.Description == nil { + if snapshot.RunID == firstRunID { t.Fatalf("Snapshot after continue-as-new = %#v", snapshot) } environment.replaceWorker(t, flowID) replaced := readSnapshot(t, environment, flowID) - if replaced.RunID != snapshot.RunID || replaced.FlowStatus != FlowStatusRunning { + if replaced.RunID != snapshot.RunID { t.Fatalf("Snapshot after Worker replacement = %#v, want run %q", replaced, snapshot.RunID) } } @@ -2801,8 +2775,7 @@ func waitForPendingToolRecoveryForTestOnly( ) PendingToolRecovery { t.Helper() snapshot := waitForSnapshot(t, environment, flowID, func(snapshot AgentSnapshot) bool { - return snapshot.Description != nil && - snapshot.Description.Status == AgentStatusWaitingForToolRecovery && + return snapshot.Description.Status == AgentStatusWaitingForToolRecovery && snapshot.Description.PendingToolRecovery != nil && snapshot.Description.PendingToolRecovery.RecoveryID != previous }) diff --git a/internal/agent/types.go b/internal/agent/types.go index 1e07fe8..913f70a 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -174,70 +174,6 @@ func (status *AgentStatus) UnmarshalJSON(data []byte) error { // WaitingInputRound is the monotonic browser reconciliation watermark. type WaitingInputRound int64 -// FlowStatus describes the Dex lifecycle state exposed with an Agent Snapshot. -type FlowStatus string - -const ( - FlowStatusRunning FlowStatus = "running" - FlowStatusCompleted FlowStatus = "completed" - FlowStatusFailed FlowStatus = "failed" - FlowStatusTerminated FlowStatus = "terminated" - FlowStatusCanceled FlowStatus = "canceled" - FlowStatusContinuedAsNew FlowStatus = "continued_as_new" -) - -// Validate rejects unknown Flow lifecycle states. -func (status FlowStatus) Validate() error { - switch status { - case FlowStatusRunning, - FlowStatusCompleted, - FlowStatusFailed, - FlowStatusTerminated, - FlowStatusCanceled, - FlowStatusContinuedAsNew: - return nil - default: - return newEnumValidationError("FlowStatus", string(status)) - } -} - -// UnmarshalJSON decodes and validates a Flow lifecycle state. -func (status *FlowStatus) UnmarshalJSON(data []byte) error { - return decodeEnum(data, status, FlowStatus.Validate) -} - -// FlowErrorType classifies a terminal Dex Flow failure. -type FlowErrorType string - -const ( - FlowErrorTypeStepDecision FlowErrorType = "step_decision" - FlowErrorTypeClientAPI FlowErrorType = "client_api" - FlowErrorTypeWorkerMethod FlowErrorType = "worker_method" - FlowErrorTypeInvalidUserCode FlowErrorType = "invalid_user_code" - FlowErrorTypeInternal FlowErrorType = "internal" - FlowErrorTypeTimeout FlowErrorType = "timeout" -) - -// Validate rejects unknown Flow failure categories. -func (errorType FlowErrorType) Validate() error { - switch errorType { - case FlowErrorTypeStepDecision, - FlowErrorTypeClientAPI, - FlowErrorTypeWorkerMethod, - FlowErrorTypeInvalidUserCode, - FlowErrorTypeInternal, - FlowErrorTypeTimeout: - return nil - default: - return newEnumValidationError("FlowErrorType", string(errorType)) - } -} - -// UnmarshalJSON decodes and validates a Flow failure category. -func (errorType *FlowErrorType) UnmarshalJSON(data []byte) error { - return decodeEnum(data, errorType, FlowErrorType.Validate) -} - // InteractionMode controls which tools one model turn may use. type InteractionMode string @@ -807,14 +743,11 @@ type AgentDescription struct { // AgentSnapshot is one atomic durable application view. type AgentSnapshot struct { - RunID RunID `json:"run_id"` - FlowStatus FlowStatus `json:"flow_status"` - ErrorType *FlowErrorType `json:"error_type,omitempty"` - ErrorMessage *string `json:"error_message,omitempty"` - History HistoryPage `json:"history"` - Description *AgentDescription `json:"description,omitempty"` - Queued []PendingUserMessage `json:"queued"` - Steered []PendingUserMessage `json:"steered"` + RunID RunID `json:"run_id"` + History HistoryPage `json:"history"` + Description AgentDescription `json:"description"` + Queued []PendingUserMessage `json:"queued"` + Steered []PendingUserMessage `json:"steered"` } // NewAgentState returns the initial durable state. diff --git a/internal/agent/types_test.go b/internal/agent/types_test.go index 90ae31f..e86fdad 100644 --- a/internal/agent/types_test.go +++ b/internal/agent/types_test.go @@ -97,8 +97,6 @@ func TestEnumsRejectUnknownJSONWithTypedError(t *testing.T) { target json.Unmarshaler }{ {name: "Agent status", typeName: "AgentStatus", target: new(AgentStatus)}, - {name: "Flow status", typeName: "FlowStatus", target: new(FlowStatus)}, - {name: "Flow error type", typeName: "FlowErrorType", target: new(FlowErrorType)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -114,12 +112,6 @@ func TestEnumsRejectUnknownJSONWithTypedError(t *testing.T) { } } -func TestVisibilityStringQuotesApostrophes(t *testing.T) { - if got := visibilityString("customer's-flow"); got != `'customer''s-flow'` { - t.Fatalf("visibilityString() = %q", got) - } -} - func TestModelRequiresSupportedProviderPrefix(t *testing.T) { tests := []struct { model Model diff --git a/internal/api/generated/oas_json_gen.go b/internal/api/generated/oas_json_gen.go index a2d5bd0..4e8d4b7 100644 --- a/internal/api/generated/oas_json_gen.go +++ b/internal/api/generated/oas_json_gen.go @@ -1288,18 +1288,6 @@ func (s *AgentSnapshot) encodeFields(e *jx.Encoder) { e.FieldStart("runId") s.RunId.Encode(e) } - { - e.FieldStart("flowStatus") - s.FlowStatus.Encode(e) - } - { - e.FieldStart("errorType") - s.ErrorType.Encode(e) - } - { - e.FieldStart("errorMessage") - s.ErrorMessage.Encode(e) - } { e.FieldStart("history") s.History.Encode(e) @@ -1326,15 +1314,12 @@ func (s *AgentSnapshot) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfAgentSnapshot = [8]string{ +var jsonFieldsNameOfAgentSnapshot = [5]string{ 0: "runId", - 1: "flowStatus", - 2: "errorType", - 3: "errorMessage", - 4: "history", - 5: "description", - 6: "queued", - 7: "steered", + 1: "history", + 2: "description", + 3: "queued", + 4: "steered", } // Decode decodes AgentSnapshot from json. @@ -1356,38 +1341,8 @@ func (s *AgentSnapshot) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"runId\"") } - case "flowStatus": - requiredBitSet[0] |= 1 << 1 - if err := func() error { - if err := s.FlowStatus.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"flowStatus\"") - } - case "errorType": - requiredBitSet[0] |= 1 << 2 - if err := func() error { - if err := s.ErrorType.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"errorType\"") - } - case "errorMessage": - requiredBitSet[0] |= 1 << 3 - if err := func() error { - if err := s.ErrorMessage.Decode(d); err != nil { - return err - } - return nil - }(); err != nil { - return errors.Wrap(err, "decode field \"errorMessage\"") - } case "history": - requiredBitSet[0] |= 1 << 4 + requiredBitSet[0] |= 1 << 1 if err := func() error { if err := s.History.Decode(d); err != nil { return err @@ -1397,7 +1352,7 @@ func (s *AgentSnapshot) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"history\"") } case "description": - requiredBitSet[0] |= 1 << 5 + requiredBitSet[0] |= 1 << 2 if err := func() error { if err := s.Description.Decode(d); err != nil { return err @@ -1407,7 +1362,7 @@ func (s *AgentSnapshot) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"description\"") } case "queued": - requiredBitSet[0] |= 1 << 6 + requiredBitSet[0] |= 1 << 3 if err := func() error { s.Queued = make([]PendingUserMessage, 0) if err := d.Arr(func(d *jx.Decoder) error { @@ -1425,7 +1380,7 @@ func (s *AgentSnapshot) Decode(d *jx.Decoder) error { return errors.Wrap(err, "decode field \"queued\"") } case "steered": - requiredBitSet[0] |= 1 << 7 + requiredBitSet[0] |= 1 << 4 if err := func() error { s.Steered = make([]PendingUserMessage, 0) if err := d.Arr(func(d *jx.Decoder) error { @@ -1452,7 +1407,7 @@ func (s *AgentSnapshot) Decode(d *jx.Decoder) error { // Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{ - 0b11111111, + 0b00011111, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -2721,54 +2676,6 @@ func (s *ExecutePlanServiceUnavailable) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes FlowErrorType as json. -func (s FlowErrorType) Encode(e *jx.Encoder) { - e.Str(string(s)) -} - -// Decode decodes FlowErrorType from json. -func (s *FlowErrorType) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode FlowErrorType to nil") - } - v, err := d.StrBytes() - if err != nil { - return err - } - // Try to use constant string. - switch FlowErrorType(v) { - case FlowErrorTypeStepDecision: - *s = FlowErrorTypeStepDecision - case FlowErrorTypeClientAPI: - *s = FlowErrorTypeClientAPI - case FlowErrorTypeWorkerMethod: - *s = FlowErrorTypeWorkerMethod - case FlowErrorTypeInvalidUserCode: - *s = FlowErrorTypeInvalidUserCode - case FlowErrorTypeInternal: - *s = FlowErrorTypeInternal - case FlowErrorTypeTimeout: - *s = FlowErrorTypeTimeout - default: - *s = FlowErrorType(v) - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s FlowErrorType) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *FlowErrorType) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes FlowID as json. func (s FlowID) Encode(e *jx.Encoder) { unwrapped := string(s) @@ -2809,54 +2716,6 @@ func (s *FlowID) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes FlowStatus as json. -func (s FlowStatus) Encode(e *jx.Encoder) { - e.Str(string(s)) -} - -// Decode decodes FlowStatus from json. -func (s *FlowStatus) Decode(d *jx.Decoder) error { - if s == nil { - return errors.New("invalid: unable to decode FlowStatus to nil") - } - v, err := d.StrBytes() - if err != nil { - return err - } - // Try to use constant string. - switch FlowStatus(v) { - case FlowStatusRunning: - *s = FlowStatusRunning - case FlowStatusCompleted: - *s = FlowStatusCompleted - case FlowStatusFailed: - *s = FlowStatusFailed - case FlowStatusTerminated: - *s = FlowStatusTerminated - case FlowStatusCanceled: - *s = FlowStatusCanceled - case FlowStatusContinuedAsNew: - *s = FlowStatusContinuedAsNew - default: - *s = FlowStatus(v) - } - - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s FlowStatus) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *FlowStatus) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes GetAgentSnapshotBadRequest as json. func (s *GetAgentSnapshotBadRequest) Encode(e *jx.Encoder) { unwrapped := (*Problem)(s) @@ -3684,50 +3543,6 @@ func (s *MessageRole) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes AgentDescription as json. -func (o NilAgentDescription) Encode(e *jx.Encoder) { - if o.Null { - e.Null() - return - } - o.Value.Encode(e) -} - -// Decode decodes AgentDescription from json. -func (o *NilAgentDescription) Decode(d *jx.Decoder) error { - if o == nil { - return errors.New("invalid: unable to decode NilAgentDescription to nil") - } - if d.Next() == jx.Null { - if err := d.Null(); err != nil { - return err - } - - var v AgentDescription - o.Value = v - o.Null = true - return nil - } - o.Null = false - if err := o.Value.Decode(d); err != nil { - return err - } - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s NilAgentDescription) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *NilAgentDescription) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes AgentPlan as json. func (o NilAgentPlan) Encode(e *jx.Encoder) { if o.Null { @@ -3816,50 +3631,6 @@ func (s *NilCallID) UnmarshalJSON(data []byte) error { return s.Decode(d) } -// Encode encodes FlowErrorType as json. -func (o NilFlowErrorType) Encode(e *jx.Encoder) { - if o.Null { - e.Null() - return - } - e.Str(string(o.Value)) -} - -// Decode decodes FlowErrorType from json. -func (o *NilFlowErrorType) Decode(d *jx.Decoder) error { - if o == nil { - return errors.New("invalid: unable to decode NilFlowErrorType to nil") - } - if d.Next() == jx.Null { - if err := d.Null(); err != nil { - return err - } - - var v FlowErrorType - o.Value = v - o.Null = true - return nil - } - o.Null = false - if err := o.Value.Decode(d); err != nil { - return err - } - return nil -} - -// MarshalJSON implements stdjson.Marshaler. -func (s NilFlowErrorType) MarshalJSON() ([]byte, error) { - e := jx.Encoder{} - s.Encode(&e) - return e.Bytes(), nil -} - -// UnmarshalJSON implements stdjson.Unmarshaler. -func (s *NilFlowErrorType) UnmarshalJSON(data []byte) error { - d := jx.DecodeBytes(data) - return s.Decode(d) -} - // Encode encodes InputConsumption as json. func (o NilInputConsumption) Encode(e *jx.Encoder) { if o.Null { diff --git a/internal/api/generated/oas_schemas_gen.go b/internal/api/generated/oas_schemas_gen.go index 8112585..562a115 100644 --- a/internal/api/generated/oas_schemas_gen.go +++ b/internal/api/generated/oas_schemas_gen.go @@ -556,14 +556,11 @@ func (s *AgentPlan) SetTasks(val []PlanTask) { // Ref: #/components/schemas/AgentSnapshot type AgentSnapshot struct { - RunId RunID `json:"runId"` - FlowStatus FlowStatus `json:"flowStatus"` - ErrorType NilFlowErrorType `json:"errorType"` - ErrorMessage NilString `json:"errorMessage"` - History HistoryPage `json:"history"` - Description NilAgentDescription `json:"description"` - Queued []PendingUserMessage `json:"queued"` - Steered []PendingUserMessage `json:"steered"` + RunId RunID `json:"runId"` + History HistoryPage `json:"history"` + Description AgentDescription `json:"description"` + Queued []PendingUserMessage `json:"queued"` + Steered []PendingUserMessage `json:"steered"` } // GetRunId returns the value of RunId. @@ -571,28 +568,13 @@ func (s *AgentSnapshot) GetRunId() RunID { return s.RunId } -// GetFlowStatus returns the value of FlowStatus. -func (s *AgentSnapshot) GetFlowStatus() FlowStatus { - return s.FlowStatus -} - -// GetErrorType returns the value of ErrorType. -func (s *AgentSnapshot) GetErrorType() NilFlowErrorType { - return s.ErrorType -} - -// GetErrorMessage returns the value of ErrorMessage. -func (s *AgentSnapshot) GetErrorMessage() NilString { - return s.ErrorMessage -} - // GetHistory returns the value of History. func (s *AgentSnapshot) GetHistory() HistoryPage { return s.History } // GetDescription returns the value of Description. -func (s *AgentSnapshot) GetDescription() NilAgentDescription { +func (s *AgentSnapshot) GetDescription() AgentDescription { return s.Description } @@ -611,28 +593,13 @@ func (s *AgentSnapshot) SetRunId(val RunID) { s.RunId = val } -// SetFlowStatus sets the value of FlowStatus. -func (s *AgentSnapshot) SetFlowStatus(val FlowStatus) { - s.FlowStatus = val -} - -// SetErrorType sets the value of ErrorType. -func (s *AgentSnapshot) SetErrorType(val NilFlowErrorType) { - s.ErrorType = val -} - -// SetErrorMessage sets the value of ErrorMessage. -func (s *AgentSnapshot) SetErrorMessage(val NilString) { - s.ErrorMessage = val -} - // SetHistory sets the value of History. func (s *AgentSnapshot) SetHistory(val HistoryPage) { s.History = val } // SetDescription sets the value of Description. -func (s *AgentSnapshot) SetDescription(val NilAgentDescription) { +func (s *AgentSnapshot) SetDescription(val AgentDescription) { s.Description = val } @@ -1205,148 +1172,8 @@ type ExecutePlanServiceUnavailable Problem func (*ExecutePlanServiceUnavailable) executePlanRes() {} -// Ref: #/components/schemas/FlowErrorType -type FlowErrorType string - -const ( - FlowErrorTypeStepDecision FlowErrorType = "step_decision" - FlowErrorTypeClientAPI FlowErrorType = "client_api" - FlowErrorTypeWorkerMethod FlowErrorType = "worker_method" - FlowErrorTypeInvalidUserCode FlowErrorType = "invalid_user_code" - FlowErrorTypeInternal FlowErrorType = "internal" - FlowErrorTypeTimeout FlowErrorType = "timeout" -) - -// AllValues returns all FlowErrorType values. -func (FlowErrorType) AllValues() []FlowErrorType { - return []FlowErrorType{ - FlowErrorTypeStepDecision, - FlowErrorTypeClientAPI, - FlowErrorTypeWorkerMethod, - FlowErrorTypeInvalidUserCode, - FlowErrorTypeInternal, - FlowErrorTypeTimeout, - } -} - -// MarshalText implements encoding.TextMarshaler. -func (s FlowErrorType) MarshalText() ([]byte, error) { - switch s { - case FlowErrorTypeStepDecision: - return []byte(s), nil - case FlowErrorTypeClientAPI: - return []byte(s), nil - case FlowErrorTypeWorkerMethod: - return []byte(s), nil - case FlowErrorTypeInvalidUserCode: - return []byte(s), nil - case FlowErrorTypeInternal: - return []byte(s), nil - case FlowErrorTypeTimeout: - return []byte(s), nil - default: - return nil, errors.Errorf("invalid value: %q", s) - } -} - -// UnmarshalText implements encoding.TextUnmarshaler. -func (s *FlowErrorType) UnmarshalText(data []byte) error { - switch FlowErrorType(data) { - case FlowErrorTypeStepDecision: - *s = FlowErrorTypeStepDecision - return nil - case FlowErrorTypeClientAPI: - *s = FlowErrorTypeClientAPI - return nil - case FlowErrorTypeWorkerMethod: - *s = FlowErrorTypeWorkerMethod - return nil - case FlowErrorTypeInvalidUserCode: - *s = FlowErrorTypeInvalidUserCode - return nil - case FlowErrorTypeInternal: - *s = FlowErrorTypeInternal - return nil - case FlowErrorTypeTimeout: - *s = FlowErrorTypeTimeout - return nil - default: - return errors.Errorf("invalid value: %q", data) - } -} - type FlowID string -// Ref: #/components/schemas/FlowStatus -type FlowStatus string - -const ( - FlowStatusRunning FlowStatus = "running" - FlowStatusCompleted FlowStatus = "completed" - FlowStatusFailed FlowStatus = "failed" - FlowStatusTerminated FlowStatus = "terminated" - FlowStatusCanceled FlowStatus = "canceled" - FlowStatusContinuedAsNew FlowStatus = "continued_as_new" -) - -// AllValues returns all FlowStatus values. -func (FlowStatus) AllValues() []FlowStatus { - return []FlowStatus{ - FlowStatusRunning, - FlowStatusCompleted, - FlowStatusFailed, - FlowStatusTerminated, - FlowStatusCanceled, - FlowStatusContinuedAsNew, - } -} - -// MarshalText implements encoding.TextMarshaler. -func (s FlowStatus) MarshalText() ([]byte, error) { - switch s { - case FlowStatusRunning: - return []byte(s), nil - case FlowStatusCompleted: - return []byte(s), nil - case FlowStatusFailed: - return []byte(s), nil - case FlowStatusTerminated: - return []byte(s), nil - case FlowStatusCanceled: - return []byte(s), nil - case FlowStatusContinuedAsNew: - return []byte(s), nil - default: - return nil, errors.Errorf("invalid value: %q", s) - } -} - -// UnmarshalText implements encoding.TextUnmarshaler. -func (s *FlowStatus) UnmarshalText(data []byte) error { - switch FlowStatus(data) { - case FlowStatusRunning: - *s = FlowStatusRunning - return nil - case FlowStatusCompleted: - *s = FlowStatusCompleted - return nil - case FlowStatusFailed: - *s = FlowStatusFailed - return nil - case FlowStatusTerminated: - *s = FlowStatusTerminated - return nil - case FlowStatusCanceled: - *s = FlowStatusCanceled - return nil - case FlowStatusContinuedAsNew: - *s = FlowStatusContinuedAsNew - return nil - default: - return errors.Errorf("invalid value: %q", data) - } -} - type GetAgentSnapshotBadRequest Problem func (*GetAgentSnapshotBadRequest) getAgentSnapshotRes() {} @@ -1652,51 +1479,6 @@ func (s *MessageRole) UnmarshalText(data []byte) error { } } -// NewNilAgentDescription returns new NilAgentDescription with value set to v. -func NewNilAgentDescription(v AgentDescription) NilAgentDescription { - return NilAgentDescription{ - Value: v, - } -} - -// NilAgentDescription is nullable AgentDescription. -type NilAgentDescription struct { - Value AgentDescription - Null bool -} - -// SetTo sets value to v. -func (o *NilAgentDescription) SetTo(v AgentDescription) { - o.Null = false - o.Value = v -} - -// IsNull returns true if value is Null. -func (o NilAgentDescription) IsNull() bool { return o.Null } - -// SetToNull sets value to null. -func (o *NilAgentDescription) SetToNull() { - o.Null = true - var v AgentDescription - o.Value = v -} - -// Get returns value and boolean that denotes whether value was set. -func (o NilAgentDescription) Get() (v AgentDescription, ok bool) { - if o.Null { - return v, false - } - return o.Value, true -} - -// Or returns value if set, or given parameter if does not. -func (o NilAgentDescription) Or(d AgentDescription) AgentDescription { - if v, ok := o.Get(); ok { - return v - } - return d -} - // NewNilAgentPlan returns new NilAgentPlan with value set to v. func NewNilAgentPlan(v AgentPlan) NilAgentPlan { return NilAgentPlan{ @@ -1787,51 +1569,6 @@ func (o NilCallID) Or(d CallID) CallID { return d } -// NewNilFlowErrorType returns new NilFlowErrorType with value set to v. -func NewNilFlowErrorType(v FlowErrorType) NilFlowErrorType { - return NilFlowErrorType{ - Value: v, - } -} - -// NilFlowErrorType is nullable FlowErrorType. -type NilFlowErrorType struct { - Value FlowErrorType - Null bool -} - -// SetTo sets value to v. -func (o *NilFlowErrorType) SetTo(v FlowErrorType) { - o.Null = false - o.Value = v -} - -// IsNull returns true if value is Null. -func (o NilFlowErrorType) IsNull() bool { return o.Null } - -// SetToNull sets value to null. -func (o *NilFlowErrorType) SetToNull() { - o.Null = true - var v FlowErrorType - o.Value = v -} - -// Get returns value and boolean that denotes whether value was set. -func (o NilFlowErrorType) Get() (v FlowErrorType, ok bool) { - if o.Null { - return v, false - } - return o.Value, true -} - -// Or returns value if set, or given parameter if does not. -func (o NilFlowErrorType) Or(d FlowErrorType) FlowErrorType { - if v, ok := o.Get(); ok { - return v - } - return d -} - // NewNilInputConsumption returns new NilInputConsumption with value set to v. func NewNilInputConsumption(v InputConsumption) NilInputConsumption { return NilInputConsumption{ diff --git a/internal/api/generated/oas_validators_gen.go b/internal/api/generated/oas_validators_gen.go index 7294e2e..c754e48 100644 --- a/internal/api/generated/oas_validators_gen.go +++ b/internal/api/generated/oas_validators_gen.go @@ -788,35 +788,6 @@ func (s *AgentSnapshot) Validate() error { Error: err, }) } - if err := func() error { - if err := s.FlowStatus.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "flowStatus", - Error: err, - }) - } - if err := func() error { - if value, ok := s.ErrorType.Get(); ok { - if err := func() error { - if err := value.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return err - } - } - return nil - }(); err != nil { - failures = append(failures, validate.FieldError{ - Name: "errorType", - Error: err, - }) - } if err := func() error { if err := s.History.Validate(); err != nil { return err @@ -829,15 +800,8 @@ func (s *AgentSnapshot) Validate() error { }) } if err := func() error { - if value, ok := s.Description.Get(); ok { - if err := func() error { - if err := value.Validate(); err != nil { - return err - } - return nil - }(); err != nil { - return err - } + if err := s.Description.Validate(); err != nil { + return err } return nil }(); err != nil { @@ -1355,25 +1319,6 @@ func (s *ExecutePlanServiceUnavailable) Validate() error { return nil } -func (s FlowErrorType) Validate() error { - switch s { - case "step_decision": - return nil - case "client_api": - return nil - case "worker_method": - return nil - case "invalid_user_code": - return nil - case "internal": - return nil - case "timeout": - return nil - default: - return errors.Errorf("invalid value: %v", s) - } -} - func (s FlowID) Validate() error { alias := (string)(s) if err := (validate.String{ @@ -1394,25 +1339,6 @@ func (s FlowID) Validate() error { return nil } -func (s FlowStatus) Validate() error { - switch s { - case "running": - return nil - case "completed": - return nil - case "failed": - return nil - case "terminated": - return nil - case "canceled": - return nil - case "continued_as_new": - return nil - default: - return errors.Errorf("invalid value: %v", s) - } -} - func (s *GetAgentSnapshotBadRequest) Validate() error { alias := (*Problem)(s) if err := alias.Validate(); err != nil { diff --git a/internal/api/handler.go b/internal/api/handler.go index b6a4a9a..a10155c 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -524,64 +524,19 @@ func transportSnapshot(snapshot agent.AgentSnapshot) (transportapi.AgentSnapshot if err != nil { return transportapi.AgentSnapshot{}, err } - flowStatus, err := transportFlowStatus(snapshot.FlowStatus) + description, err := transportAgentDescription(snapshot.Description) if err != nil { return transportapi.AgentSnapshot{}, err } - description, err := transportOptionalAgentDescription(snapshot.Description) - if err != nil { - return transportapi.AgentSnapshot{}, err - } - errorType, err := transportOptionalFlowErrorType(snapshot.ErrorType) - if err != nil { - return transportapi.AgentSnapshot{}, err - } - errorMessage := transportapi.NilString{} - if snapshot.ErrorMessage == nil { - errorMessage.SetToNull() - } else { - errorMessage.SetTo(*snapshot.ErrorMessage) - } return transportapi.AgentSnapshot{ - RunId: transportapi.RunID(snapshot.RunID), - FlowStatus: flowStatus, - ErrorType: errorType, - ErrorMessage: errorMessage, - History: history, - Description: description, - Queued: transportPendingUserMessages(snapshot.Queued), - Steered: transportPendingUserMessages(snapshot.Steered), + RunId: transportapi.RunID(snapshot.RunID), + History: history, + Description: description, + Queued: transportPendingUserMessages(snapshot.Queued), + Steered: transportPendingUserMessages(snapshot.Steered), }, nil } -func transportOptionalAgentDescription(description *agent.AgentDescription) (transportapi.NilAgentDescription, error) { - result := transportapi.NilAgentDescription{} - if description == nil { - result.SetToNull() - return result, nil - } - mapped, err := transportAgentDescription(*description) - if err != nil { - return transportapi.NilAgentDescription{}, err - } - result.SetTo(mapped) - return result, nil -} - -func transportOptionalFlowErrorType(errorType *agent.FlowErrorType) (transportapi.NilFlowErrorType, error) { - result := transportapi.NilFlowErrorType{} - if errorType == nil { - result.SetToNull() - return result, nil - } - mapped, err := transportFlowErrorType(*errorType) - if err != nil { - return transportapi.NilFlowErrorType{}, err - } - result.SetTo(mapped) - return result, nil -} - func transportHistoryPage(page agent.HistoryPage) (transportapi.HistoryPage, error) { messages := make([]transportapi.SequencedMessage, 0, len(page.Messages)) for _, message := range page.Messages { @@ -804,44 +759,6 @@ func transportOptionalAgentPlan(plan *agent.AgentPlan) (transportapi.NilAgentPla return result, nil } -func transportFlowStatus(status agent.FlowStatus) (transportapi.FlowStatus, error) { - switch status { - case agent.FlowStatusRunning: - return transportapi.FlowStatusRunning, nil - case agent.FlowStatusCompleted: - return transportapi.FlowStatusCompleted, nil - case agent.FlowStatusFailed: - return transportapi.FlowStatusFailed, nil - case agent.FlowStatusTerminated: - return transportapi.FlowStatusTerminated, nil - case agent.FlowStatusCanceled: - return transportapi.FlowStatusCanceled, nil - case agent.FlowStatusContinuedAsNew: - return transportapi.FlowStatusContinuedAsNew, nil - default: - return "", &agent.EnumValidationError{Type: "FlowStatus", Value: string(status)} - } -} - -func transportFlowErrorType(errorType agent.FlowErrorType) (transportapi.FlowErrorType, error) { - switch errorType { - case agent.FlowErrorTypeStepDecision: - return transportapi.FlowErrorTypeStepDecision, nil - case agent.FlowErrorTypeClientAPI: - return transportapi.FlowErrorTypeClientAPI, nil - case agent.FlowErrorTypeWorkerMethod: - return transportapi.FlowErrorTypeWorkerMethod, nil - case agent.FlowErrorTypeInvalidUserCode: - return transportapi.FlowErrorTypeInvalidUserCode, nil - case agent.FlowErrorTypeInternal: - return transportapi.FlowErrorTypeInternal, nil - case agent.FlowErrorTypeTimeout: - return transportapi.FlowErrorTypeTimeout, nil - default: - return "", &agent.EnumValidationError{Type: "FlowErrorType", Value: string(errorType)} - } -} - func transportAgentStatus(status agent.AgentStatus) (transportapi.AgentStatus, error) { switch status { case agent.AgentStatusInitializing: diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 6ae940a..02bf1c6 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -283,8 +283,7 @@ func TestGetAgentSnapshotMapsAtomicDomainView(t *testing.T) { callID := agent.CallID("call-1") toolName := agent.ToolName("lookup") service := &fakeAgentService{snapshot: agent.AgentSnapshot{ - RunID: "run-1", - FlowStatus: agent.FlowStatusRunning, + RunID: "run-1", History: agent.HistoryPage{Messages: []agent.SequencedMessage{{ Sequence: 1, Message: agent.AgentMessage{ @@ -296,7 +295,7 @@ func TestGetAgentSnapshotMapsAtomicDomainView(t *testing.T) { CreatedAt: createdAt, }, }}}, - Description: &agent.AgentDescription{ + Description: agent.AgentDescription{ Status: agent.AgentStatusWaitingForToolApproval, WaitingInputRound: 7, Model: "openai/gpt-5-mini", @@ -331,8 +330,7 @@ func TestGetAgentSnapshotMapsAtomicDomainView(t *testing.T) { t.Fatalf("validate response: %v", validationErr) } snapshot := &result.Response - if snapshot.RunId != "run-1" || snapshot.FlowStatus != transportapi.FlowStatusRunning || - len(snapshot.History.Messages) != 1 || len(snapshot.Queued) != 1 { + if snapshot.RunId != "run-1" || len(snapshot.History.Messages) != 1 || len(snapshot.Queued) != 1 { t.Fatalf("Snapshot = %#v", snapshot) } message := snapshot.History.Messages[0].Message @@ -341,48 +339,12 @@ func TestGetAgentSnapshotMapsAtomicDomainView(t *testing.T) { message.ToolCalls[0].ArgumentsJson != `{"path":"README.md"}` { t.Fatalf("Snapshot message = %#v", message) } - description, ok := snapshot.Description.Get() - if !ok || description.PendingApproval.IsNull() || description.Plan.IsNull() { + description := snapshot.Description + if description.PendingApproval.IsNull() || description.Plan.IsNull() { t.Fatalf("Snapshot description = %#v", snapshot.Description) } } -func TestGetAgentSnapshotMapsTerminalFlowResult(t *testing.T) { - t.Parallel() - errorType := agent.FlowErrorTypeWorkerMethod - errorMessage := "worker failed" - handler := newTestHandler(&fakeAgentService{snapshot: agent.AgentSnapshot{ - RunID: "run-terminal", - FlowStatus: agent.FlowStatusFailed, - ErrorType: &errorType, - ErrorMessage: &errorMessage, - History: agent.HistoryPage{Messages: []agent.SequencedMessage{}}, - Queued: []agent.PendingUserMessage{}, - Steered: []agent.PendingUserMessage{}, - }}, fakeCredentials{}) - response, err := handler.GetAgentSnapshot(context.Background(), transportapi.GetAgentSnapshotParams{ - FlowId: "flow-terminal", - }) - if err != nil { - t.Fatal(err) - } - result, ok := response.(*transportapi.AgentSnapshotHeaders) - if !ok { - t.Fatalf("response type = %T", response) - } - snapshot := result.Response - if snapshot.FlowStatus != transportapi.FlowStatusFailed || !snapshot.Description.IsNull() { - t.Fatalf("terminal Snapshot = %#v", snapshot) - } - mappedErrorType, ok := snapshot.ErrorType.Get() - if !ok || mappedErrorType != transportapi.FlowErrorTypeWorkerMethod { - t.Fatalf("terminal error type = %#v", snapshot.ErrorType) - } - if mappedMessage, ok := snapshot.ErrorMessage.Get(); !ok || mappedMessage != errorMessage { - t.Fatalf("terminal error message = %#v", snapshot.ErrorMessage) - } -} - func TestGetAgentSnapshotPreservesDexFlowLifecycleErrors(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/api/server_integration_test.go b/internal/api/server_integration_test.go index 5bcb21a..86c65c6 100644 --- a/internal/api/server_integration_test.go +++ b/internal/api/server_integration_test.go @@ -108,7 +108,7 @@ func TestAgentHTTPServerIntegration(t *testing.T) { var snapshot transportapi.AgentSnapshot snapshotURL := fmt.Sprintf("%s/products/ai-agent/snapshot?flowId=%s", baseURL, flowID) requestJSON(t, http.MethodGet, snapshotURL, nil, http.StatusOK, &snapshot) - if snapshot.Description.IsNull() || len(snapshot.History.Messages) != 2 { + if len(snapshot.History.Messages) != 2 { t.Fatalf("Snapshot = %#v", snapshot) } if snapshot.History.Messages[0].Message.Content != "through HTTP" || @@ -143,8 +143,8 @@ func TestAgentHTTPServerIntegration(t *testing.T) { }, http.StatusAccepted, nil) requestJSON(t, http.MethodGet, waitingInputRoundURL(baseURL, flowID, waiting.WaitingInputRound), nil, http.StatusOK, &waiting) requestJSON(t, http.MethodGet, snapshotURL, nil, http.StatusOK, &snapshot) - description, ok := snapshot.Description.Get() - if !ok || description.Plan.IsNull() { + description := snapshot.Description + if description.Plan.IsNull() { t.Fatalf("Plan Snapshot = %#v", snapshot) } plan, ok := description.Plan.Get() @@ -181,10 +181,7 @@ func TestAgentHTTPServerIntegration(t *testing.T) { busyPlanFlowID, ) requestJSON(t, http.MethodGet, busyPlanSnapshotURL, nil, http.StatusOK, &snapshot) - busyDescription, ok := snapshot.Description.Get() - if !ok { - t.Fatalf("busy Plan Snapshot = %#v", snapshot) - } + busyDescription := snapshot.Description busyPlan, ok := busyDescription.Plan.Get() if !ok { t.Fatalf("busy Plan = %#v", busyDescription.Plan) @@ -212,8 +209,8 @@ func TestAgentHTTPServerIntegration(t *testing.T) { }, http.StatusAccepted, nil) requestJSON(t, http.MethodGet, waitingInputRoundURL(baseURL, questionFlowID, questionWaiting.WaitingInputRound), nil, http.StatusOK, &questionWaiting) requestJSON(t, http.MethodGet, questionSnapshotURL, nil, http.StatusOK, &snapshot) - description, ok = snapshot.Description.Get() - if !ok || description.PendingUserInput.IsNull() { + description = snapshot.Description + if description.PendingUserInput.IsNull() { t.Fatalf("question Snapshot = %#v", snapshot) } pendingInput, ok := description.PendingUserInput.Get() @@ -233,8 +230,8 @@ func TestAgentHTTPServerIntegration(t *testing.T) { } requestJSON(t, http.MethodPost, answerURL, answer, http.StatusAccepted, nil) requestJSON(t, http.MethodGet, questionSnapshotURL, nil, http.StatusOK, &snapshot) - description, ok = snapshot.Description.Get() - if !ok || !description.PendingUserInput.IsNull() { + description = snapshot.Description + if !description.PendingUserInput.IsNull() { t.Fatalf("question remained after accepted answer: %#v", snapshot) } requestJSON(t, http.MethodPost, answerURL, answer, http.StatusConflict, nil) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 00bec62..684bcf5 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -87,12 +87,11 @@ func TestPortalEncodesEmptyMCPArrays(t *testing.T) { func TestSnapshotResponseCannotBeCached(t *testing.T) { t.Parallel() service := &fakeAgentService{snapshot: agent.AgentSnapshot{ - RunID: "run-1", - FlowStatus: agent.FlowStatusRunning, + RunID: "run-1", History: agent.HistoryPage{ Messages: []agent.SequencedMessage{}, }, - Description: &agent.AgentDescription{ + Description: agent.AgentDescription{ Status: agent.AgentStatusInitializing, WaitingInputRound: 0, Model: "mock/reliable", diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e2bcae0..593a8a6 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -20,7 +20,6 @@ import { AgentStatus, EventKind, EventStream, - FlowStatus, MessageRole, PlanStatus, Provider, @@ -107,9 +106,6 @@ const activeDescription: AgentDescription = { const snapshot: AgentSnapshot = { runId: "run-1", - flowStatus: FlowStatus.RUNNING, - errorType: null, - errorMessage: null, history: { messages: [], nextBeforeSequence: null }, description: activeDescription, queued: [], @@ -450,28 +446,6 @@ describe("App", () => { }); }); - it("shows a terminal Flow result without opening live subscriptions", async () => { - vi.mocked(getAgentSnapshot).mockResolvedValueOnce({ - runId: "run-terminal", - flowStatus: FlowStatus.TERMINATED, - errorType: null, - errorMessage: "stopped by operator", - history: { messages: [], nextBeforeSequence: null }, - description: null, - queued: [], - steered: [], - }); - window.history.replaceState({}, "", "/?flowId=flow-existing"); - - render(); - - expect( - await screen.findByRole("heading", { name: "Agent Terminated" }), - ).toBeInTheDocument(); - expect(screen.getByText("stopped by operator")).toBeInTheDocument(); - expect(readEvent).not.toHaveBeenCalled(); - }); - it("shows an optimistic queue item while message submission is pending", async () => { vi.mocked(sendMessage).mockImplementation( () => new Promise(() => undefined), diff --git a/web/src/Conversation.tsx b/web/src/Conversation.tsx index 6291208..d568b22 100644 --- a/web/src/Conversation.tsx +++ b/web/src/Conversation.tsx @@ -97,11 +97,9 @@ export function Conversation({ await waitForNetworkCancellation(); }, []); const nextHistoryRequestID = useRef(1); - const isTerminal = state.kind === "ready" && state.lifecycle === "terminal"; const requestSnapshot = useSnapshotCoordinator( flowId, dispatch, - isTerminal, snapshotRefreshIntervalMilliseconds, ); const runCommand = useCommandRunner( @@ -144,18 +142,12 @@ export function Conversation({ const canOpenLiveReads = state.kind === "ready" && - state.lifecycle === "active" && state.pendingCommand === null && state.reconciliation === "open" && isDocumentVisible; const subscriptionGeneration = - state.kind === "ready" && state.lifecycle === "active" - ? state.subscriptionGeneration - : -1; - const activeRunID = - state.kind === "ready" && state.lifecycle === "active" - ? state.snapshot.runId - : null; + state.kind === "ready" ? state.subscriptionGeneration : -1; + const activeRunID = state.kind === "ready" ? state.snapshot.runId : null; useEffect(() => { resetResumeTokens(resumeTokens.current); }, [flowId, activeRunID]); @@ -242,9 +234,7 @@ export function Conversation({ ]); const waitingInputRound = - state.kind === "ready" && state.lifecycle === "active" - ? state.snapshot.description.waitingInputRound - : 0; + state.kind === "ready" ? state.snapshot.description.waitingInputRound : 0; const waitingInputRoundRef = useRef(waitingInputRound); useEffect(() => { waitingInputRoundRef.current = waitingInputRound; @@ -313,20 +303,6 @@ export function Conversation({ /> ); } - if (state.lifecycle === "terminal") { - return ( - - ); - } - const isBusy = state.pendingCommand !== null; const areMutationsDisabled = isBusy || state.reconciliation !== "open"; const submitMessage = () => { @@ -474,7 +450,6 @@ function ConversationStatus({ function useSnapshotCoordinator( flowId: FlowId, dispatch: Dispatch, - isTerminal: boolean, refreshIntervalMilliseconds: number, ) { const coordinator = useRef(null); @@ -511,9 +486,6 @@ function useSnapshotCoordinator( if (coordinator.current === current) coordinator.current = null; }; }, [dispatch, flowId, refreshIntervalMilliseconds]); - useEffect(() => { - if (isTerminal) coordinator.current?.stop(); - }, [isTerminal]); return useCallback((trigger: SnapshotTrigger) => { coordinator.current?.request(trigger); }, []); @@ -664,13 +636,6 @@ function errorMessage(reason: unknown): string { return "The request could not be completed."; } -function statusLabel(value: string): string { - return value - .split("_") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - async function waitBeforeNextPoll(signal: AbortSignal): Promise { await new Promise((resolve) => { if (signal.aborted) { diff --git a/web/src/ConversationView.tsx b/web/src/ConversationView.tsx index 259bf8d..41bc556 100644 --- a/web/src/ConversationView.tsx +++ b/web/src/ConversationView.tsx @@ -931,8 +931,6 @@ function connectionLabel(connection: ConnectionState): string { return "Reconnecting"; case "stale": return "Stale"; - case "terminal": - return "Terminal"; } } diff --git a/web/src/api/generated/index.ts b/web/src/api/generated/index.ts index d085808..4faa3d5 100644 --- a/web/src/api/generated/index.ts +++ b/web/src/api/generated/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { answerQuestions, approveTool, deleteQueuedMessage, executePlan, getAgentSnapshot, getArchivedMessages, getHealth, getPortal, getReadiness, listRecentEvents, type Options, readEvent, resolveToolRecovery, sendMessage, startAgent, steerQueuedMessage, waitForWaitingInputRound } from './sdk.gen'; -export { type Accepted, type ActivityStreamEvent, type AgentDescription, type AgentEvent, type AgentMessage, type AgentPlan, type AgentSnapshot, AgentStatus, type AnswerQuestionsData, type AnswerQuestionsError, type AnswerQuestionsErrors, type AnswerQuestionsRequest, type AnswerQuestionsResponse, type AnswerQuestionsResponses, type ApproveToolData, type ApproveToolError, type ApproveToolErrors, type ApproveToolResponse, type ApproveToolResponses, type AssistantStreamEvent, type CallId, type ClientOptions, type DeleteQueuedMessageData, type DeleteQueuedMessageError, type DeleteQueuedMessageErrors, type DeleteQueuedMessageResponse, type DeleteQueuedMessageResponses, EventKind, EventStream, type ExecutePlanData, type ExecutePlanError, type ExecutePlanErrors, type ExecutePlanRequest, type ExecutePlanResponse, type ExecutePlanResponses, FlowErrorType, type FlowId, FlowStatus, type GetAgentSnapshotData, type GetAgentSnapshotError, type GetAgentSnapshotErrors, type GetAgentSnapshotResponse, type GetAgentSnapshotResponses, type GetArchivedMessagesData, type GetArchivedMessagesError, type GetArchivedMessagesErrors, type GetArchivedMessagesResponse, type GetArchivedMessagesResponses, type GetHealthData, type GetHealthResponse, type GetHealthResponses, type GetPortalData, type GetPortalError, type GetPortalErrors, type GetPortalResponse, type GetPortalResponses, type GetReadinessData, type GetReadinessError, type GetReadinessErrors, type GetReadinessResponse, type GetReadinessResponses, type Health, HealthStatus, type HistoryPage, type InputConsumption, type ListRecentEventsData, type ListRecentEventsError, type ListRecentEventsErrors, type ListRecentEventsResponse, type ListRecentEventsResponses, type MessageId, MessageRole, type PendingApproval, type PendingTimer, type PendingToolRecovery, type PendingToolRecoveryCall, type PendingUserInput, type PendingUserMessage, PlanStatus, type PlanTask, type PollTimeout, PollTimeoutReason, type Portal, type PortalProvider, type PortalTool, type Problem, Provider, QueueAction, type QueueMutationRequest, type QueueMutationResponse, type ReadEventData, type ReadEventError, type ReadEventErrors, type ReadEventResponse, type ReadEventResponses, type ReasoningStreamEvent, type RecentEvents, type ResolveToolRecoveryData, type ResolveToolRecoveryError, type ResolveToolRecoveryErrors, type ResolveToolRecoveryRequest, type ResolveToolRecoveryResponse, type ResolveToolRecoveryResponses, type ResumeToken, type RunId, type SendMessageData, type SendMessageError, type SendMessageErrors, type SendMessageRequest, type SendMessageResponse, type SendMessageResponses, type Sequence, type SequencedMessage, type StartAgentData, type StartAgentError, type StartAgentErrors, type StartAgentRequest, type StartAgentResponse, type StartAgentResponse2, type StartAgentResponses, type SteerQueuedMessageData, type SteerQueuedMessageError, type SteerQueuedMessageErrors, type SteerQueuedMessageResponse, type SteerQueuedMessageResponses, type StreamEvent, TaskStatus, type TextStreamEvent, type ToolApprovalRequest, type ToolCall, type ToolName, ToolRecoveryAction, type ToolRecoveryDecision, ToolRecoveryResolution, type UserInputAnswer, type UserInputOption, type UserInputQuestion, type UserMessage, type WaitForWaitingInputRoundData, type WaitForWaitingInputRoundError, type WaitForWaitingInputRoundErrors, type WaitForWaitingInputRoundResponse, type WaitForWaitingInputRoundResponses, type WaitingInputRound, type WaitingInputRoundState } from './types.gen'; +export { type Accepted, type ActivityStreamEvent, type AgentDescription, type AgentEvent, type AgentMessage, type AgentPlan, type AgentSnapshot, AgentStatus, type AnswerQuestionsData, type AnswerQuestionsError, type AnswerQuestionsErrors, type AnswerQuestionsRequest, type AnswerQuestionsResponse, type AnswerQuestionsResponses, type ApproveToolData, type ApproveToolError, type ApproveToolErrors, type ApproveToolResponse, type ApproveToolResponses, type AssistantStreamEvent, type CallId, type ClientOptions, type DeleteQueuedMessageData, type DeleteQueuedMessageError, type DeleteQueuedMessageErrors, type DeleteQueuedMessageResponse, type DeleteQueuedMessageResponses, EventKind, EventStream, type ExecutePlanData, type ExecutePlanError, type ExecutePlanErrors, type ExecutePlanRequest, type ExecutePlanResponse, type ExecutePlanResponses, type FlowId, type GetAgentSnapshotData, type GetAgentSnapshotError, type GetAgentSnapshotErrors, type GetAgentSnapshotResponse, type GetAgentSnapshotResponses, type GetArchivedMessagesData, type GetArchivedMessagesError, type GetArchivedMessagesErrors, type GetArchivedMessagesResponse, type GetArchivedMessagesResponses, type GetHealthData, type GetHealthResponse, type GetHealthResponses, type GetPortalData, type GetPortalError, type GetPortalErrors, type GetPortalResponse, type GetPortalResponses, type GetReadinessData, type GetReadinessError, type GetReadinessErrors, type GetReadinessResponse, type GetReadinessResponses, type Health, HealthStatus, type HistoryPage, type InputConsumption, type ListRecentEventsData, type ListRecentEventsError, type ListRecentEventsErrors, type ListRecentEventsResponse, type ListRecentEventsResponses, type MessageId, MessageRole, type PendingApproval, type PendingTimer, type PendingToolRecovery, type PendingToolRecoveryCall, type PendingUserInput, type PendingUserMessage, PlanStatus, type PlanTask, type PollTimeout, PollTimeoutReason, type Portal, type PortalProvider, type PortalTool, type Problem, Provider, QueueAction, type QueueMutationRequest, type QueueMutationResponse, type ReadEventData, type ReadEventError, type ReadEventErrors, type ReadEventResponse, type ReadEventResponses, type ReasoningStreamEvent, type RecentEvents, type ResolveToolRecoveryData, type ResolveToolRecoveryError, type ResolveToolRecoveryErrors, type ResolveToolRecoveryRequest, type ResolveToolRecoveryResponse, type ResolveToolRecoveryResponses, type ResumeToken, type RunId, type SendMessageData, type SendMessageError, type SendMessageErrors, type SendMessageRequest, type SendMessageResponse, type SendMessageResponses, type Sequence, type SequencedMessage, type StartAgentData, type StartAgentError, type StartAgentErrors, type StartAgentRequest, type StartAgentResponse, type StartAgentResponse2, type StartAgentResponses, type SteerQueuedMessageData, type SteerQueuedMessageError, type SteerQueuedMessageErrors, type SteerQueuedMessageResponse, type SteerQueuedMessageResponses, type StreamEvent, TaskStatus, type TextStreamEvent, type ToolApprovalRequest, type ToolCall, type ToolName, ToolRecoveryAction, type ToolRecoveryDecision, ToolRecoveryResolution, type UserInputAnswer, type UserInputOption, type UserInputQuestion, type UserMessage, type WaitForWaitingInputRoundData, type WaitForWaitingInputRoundError, type WaitForWaitingInputRoundErrors, type WaitForWaitingInputRoundResponse, type WaitForWaitingInputRoundResponses, type WaitingInputRound, type WaitingInputRoundState } from './types.gen'; diff --git a/web/src/api/generated/types.gen.ts b/web/src/api/generated/types.gen.ts index fec9711..c2593dc 100644 --- a/web/src/api/generated/types.gen.ts +++ b/web/src/api/generated/types.gen.ts @@ -225,11 +225,8 @@ export type QueueMutationResponse = { export type AgentSnapshot = { runId: RunId; - flowStatus: FlowStatus; - errorType: FlowErrorType | null; - errorMessage: string | null; history: HistoryPage; - description: AgentDescription | null; + description: AgentDescription; queued: Array; steered: Array; }; @@ -239,28 +236,6 @@ export type HistoryPage = { nextBeforeSequence: Sequence | null; }; -export const FlowStatus = { - RUNNING: 'running', - COMPLETED: 'completed', - FAILED: 'failed', - TERMINATED: 'terminated', - CANCELED: 'canceled', - CONTINUED_AS_NEW: 'continued_as_new' -} as const; - -export type FlowStatus = typeof FlowStatus[keyof typeof FlowStatus]; - -export const FlowErrorType = { - STEP_DECISION: 'step_decision', - CLIENT_API: 'client_api', - WORKER_METHOD: 'worker_method', - INVALID_USER_CODE: 'invalid_user_code', - INTERNAL: 'internal', - TIMEOUT: 'timeout' -} as const; - -export type FlowErrorType = typeof FlowErrorType[keyof typeof FlowErrorType]; - export type SequencedMessage = { sequence: Sequence; message: AgentMessage; diff --git a/web/src/conversation-state.test.ts b/web/src/conversation-state.test.ts index bea7356..4d3ed2d 100644 --- a/web/src/conversation-state.test.ts +++ b/web/src/conversation-state.test.ts @@ -9,8 +9,6 @@ import { describe, expect, it } from "vitest"; import { AgentStatus, EventKind, - FlowErrorType, - FlowStatus, MessageRole, TaskStatus, type AgentSnapshot, @@ -104,30 +102,6 @@ describe("conversationReducer", () => { }); }); - it("renders a terminal Snapshot without inventing active Agent state", () => { - const state = conversationReducer(initialConversationState(), { - type: "snapshot-loaded", - snapshot: { - runId: "run-terminal", - flowStatus: FlowStatus.FAILED, - errorType: FlowErrorType.WORKER_METHOD, - errorMessage: "worker stopped", - history: { messages: [], nextBeforeSequence: null }, - description: null, - queued: [], - steered: [], - }, - }); - - expect(state).toMatchObject({ - kind: "ready", - lifecycle: "terminal", - connection: "terminal", - snapshot: { runId: "run-terminal", description: null }, - error: "worker stopped", - }); - }); - it("keeps reasoning summaries separate by model invocation source", () => { let state = conversationReducer(initialConversationState(), { type: "snapshot-loaded", @@ -222,7 +196,6 @@ describe("conversationReducer", () => { it("removes only exact consumed queue IDs and replays idempotently", () => { const initial = snapshot("run-1", "queued-1", "first"); - if (initial.description === null) throw new Error("expected description"); initial.queued.push({ messageId: "queued-moved", value: { content: "moved before consumption", planMode: false }, @@ -436,7 +409,7 @@ describe("conversationReducer", () => { }, }, }); - if (state.kind !== "ready" || state.lifecycle !== "active") { + if (state.kind !== "ready") { throw new Error("expected active state"); } expect(displayedPlanTaskStatus(state, 0)).toBe(TaskStatus.IN_PROGRESS); @@ -462,7 +435,7 @@ describe("conversationReducer", () => { }, }, }); - if (state.kind !== "ready" || state.lifecycle !== "active") { + if (state.kind !== "ready") { throw new Error("expected active state"); } expect(displayedPlanTaskStatus(state, 0)).toBe(TaskStatus.IN_PROGRESS); @@ -471,7 +444,7 @@ describe("conversationReducer", () => { type: "snapshot-loaded", snapshot: withPlan(initial, 5, TaskStatus.COMPLETED), }); - if (state.kind !== "ready" || state.lifecycle !== "active") { + if (state.kind !== "ready") { throw new Error("expected active state"); } expect(state.planProgress).toBeNull(); @@ -606,7 +579,7 @@ describe("conversationReducer", () => { type: "snapshot-loaded", snapshot: snapshot("run-1", "queued-1", "existing"), }); - if (state.kind !== "ready" || state.lifecycle !== "active") { + if (state.kind !== "ready") { throw new Error("expected active state"); } state = conversationReducer(state, { @@ -720,8 +693,6 @@ describe("conversationReducer", () => { messageId: "queued-3", value: { content: "second", planMode: false }, }); - if (durable.description === null) - throw new Error("expected active Snapshot"); durable.description.pendingQueuedMessageCount = 2; state = conversationReducer(state, { type: "snapshot-loaded", @@ -732,8 +703,6 @@ describe("conversationReducer", () => { it("projects an accepted answer when its Activity arrives", () => { const pending = snapshot("run-1", "queued-1", "existing"); - if (pending.description === null) - throw new Error("expected active Snapshot"); pending.description.pendingUserInput = { callId: "input-call-1", questions: [ @@ -846,8 +815,6 @@ describe("conversationReducer", () => { it("preserves loaded archive chunks across Snapshot reconciliation", () => { const current = snapshot("run-1", "queued-1", "current"); - if (current.description === null) - throw new Error("expected active Snapshot"); current.history = { messages: [sequencedMessage(11, "current")], nextBeforeSequence: 11, @@ -910,9 +877,6 @@ function snapshot( ): AgentSnapshot { return { runId, - flowStatus: FlowStatus.RUNNING, - errorType: null, - errorMessage: null, history: { messages: [ { @@ -960,7 +924,6 @@ function snapshot( function snapshotWithStatus(status: AgentStatus): AgentSnapshot { const value = snapshot("run-1", "queued-1", "hello"); - if (value.description === null) throw new Error("expected active Snapshot"); return { ...value, description: { ...value.description, status }, @@ -972,7 +935,6 @@ function withPlan( revision: number, status: TaskStatus, ): AgentSnapshot { - if (value.description === null) throw new Error("expected active Snapshot"); return { ...value, description: { diff --git a/web/src/conversation-state.ts b/web/src/conversation-state.ts index f7dda94..0103ec0 100644 --- a/web/src/conversation-state.ts +++ b/web/src/conversation-state.ts @@ -9,7 +9,6 @@ import { EventKind, MessageRole, type TaskStatus, - type AgentDescription, type AgentEvent, type AgentSnapshot, type CallId, @@ -22,7 +21,7 @@ import { } from "./api/generated"; export type ActiveConnectionState = "live" | "reconnecting" | "stale"; -export type ConnectionState = ActiveConnectionState | "terminal"; +export type ConnectionState = ActiveConnectionState; export type ReconciliationState = "open" | "syncing" | "stale"; export type QueueCommandAction = "delete" | "steer" | "edit"; @@ -116,9 +115,6 @@ interface PlanProgressHint { tasks: PlanTaskProgress[]; } -type ActiveSnapshot = AgentSnapshot & { description: AgentDescription }; -type TerminalSnapshot = AgentSnapshot & { description: null }; - interface ReadyConversationBase { kind: "ready"; subscriptionGeneration: number; @@ -140,19 +136,11 @@ interface ReadyConversationBase { } export interface ActiveConversationState extends ReadyConversationBase { - lifecycle: "active"; - snapshot: ActiveSnapshot; + snapshot: AgentSnapshot; connection: ActiveConnectionState; } -export interface TerminalConversationState extends ReadyConversationBase { - lifecycle: "terminal"; - snapshot: TerminalSnapshot; - connection: "terminal"; -} - -export type ReadyConversationState = - ActiveConversationState | TerminalConversationState; +export type ReadyConversationState = ActiveConversationState; export type ConversationState = | { kind: "loading" } @@ -197,7 +185,6 @@ export function conversationReducer( message: action.message, }; } - if (state.lifecycle === "terminal") return state; return { ...state, connection: "stale", @@ -206,7 +193,6 @@ export function conversationReducer( }; case "snapshot-requested": if (state.kind === "ready") { - if (state.lifecycle === "terminal") return state; return { ...state, connection: action.connection, @@ -215,7 +201,7 @@ export function conversationReducer( } return { kind: "loading" }; case "older-requested": - return state.kind === "ready" && state.lifecycle === "active" + return state.kind === "ready" ? { ...state, historyRequest: { @@ -227,28 +213,22 @@ export function conversationReducer( case "older-loaded": return mergeOlderHistory(state, action.id, action.page); case "older-failed": - if ( - state.kind !== "ready" || - state.lifecycle === "terminal" || - state.historyRequest?.id !== action.id - ) { + if (state.kind !== "ready" || state.historyRequest?.id !== action.id) { return state; } return { ...state, historyRequest: null, error: action.message }; case "stream-update": - return state.kind === "ready" && state.lifecycle === "active" + return state.kind === "ready" ? applyLiveUpdate(state, action.update) : state; case "stream-recovered": return action.updates.reduce( (current, update) => - current.kind === "ready" && current.lifecycle === "active" - ? applyLiveUpdate(current, update) - : current, + current.kind === "ready" ? applyLiveUpdate(current, update) : current, state, ); case "stream-failed": - if (state.kind !== "ready" || state.lifecycle === "terminal") { + if (state.kind !== "ready") { return state; } return { @@ -257,28 +237,20 @@ export function conversationReducer( error: action.message, }; case "composer-changed": - return state.kind === "ready" && state.lifecycle === "active" + return state.kind === "ready" ? { ...state, composer: action.value } : state; case "plan-mode-changed": - return state.kind === "ready" && state.lifecycle === "active" + return state.kind === "ready" ? { ...state, isPlanMode: action.value } : state; case "command-started": - if ( - state.kind !== "ready" || - state.lifecycle === "terminal" || - state.pendingCommand !== null - ) { + if (state.kind !== "ready" || state.pendingCommand !== null) { return state; } return beginCommand(state, action.id, action.command); case "command-succeeded": - if ( - state.kind !== "ready" || - state.lifecycle === "terminal" || - state.pendingCommand?.id !== action.id - ) { + if (state.kind !== "ready" || state.pendingCommand?.id !== action.id) { return state; } return completeCommand(state, action.id); @@ -291,11 +263,7 @@ function reconcileSnapshot( state: ConversationState, snapshot: AgentSnapshot, ): ReadyConversationState { - if (snapshot.description === null) { - return terminalState({ ...snapshot, description: null }, state); - } - const previous = - state.kind === "ready" && state.lifecycle === "active" ? state : null; + const previous = state.kind === "ready" ? state : null; const previousRun = previous?.snapshot.runId === snapshot.runId ? previous : null; const history = @@ -356,7 +324,6 @@ function reconcileSnapshot( snapshot.description.status !== AgentStatus.CALLING_MODEL; return { kind: "ready", - lifecycle: "active", snapshot: activeSnapshot, connection: "live", subscriptionGeneration: @@ -393,45 +360,12 @@ function reconcileSnapshot( }; } -function terminalState( - snapshot: AgentSnapshot & { description: null }, - previous: ConversationState, -): TerminalConversationState { - const priorReady = previous.kind === "ready" ? previous : null; - return { - kind: "ready", - lifecycle: "terminal", - snapshot, - connection: "terminal", - reconciliation: "open", - subscriptionGeneration: priorReady?.subscriptionGeneration ?? 0, - historyRequest: null, - pendingCommand: null, - pendingAnsweredUserInput: null, - optimisticSubmissions: [], - composer: priorReady?.composer ?? "", - isPlanMode: priorReady?.isPlanMode ?? false, - assistant: null, - reasoning: completeReasoning(priorReady?.reasoning ?? []), - activities: priorReady?.activities ?? [], - consumedUserMessages: [], - planProgress: null, - isWaitingForInput: false, - commandError: null, - error: snapshot.errorMessage, - }; -} - function mergeOlderHistory( state: ConversationState, requestID: number, page: HistoryPage, ): ConversationState { - if ( - state.kind !== "ready" || - state.lifecycle === "terminal" || - state.historyRequest?.id !== requestID - ) { + if (state.kind !== "ready" || state.historyRequest?.id !== requestID) { return state; } return { @@ -633,11 +567,7 @@ function failCommand( id: number, message: string, ): ConversationState { - if ( - state.kind !== "ready" || - state.lifecycle === "terminal" || - state.pendingCommand?.id !== id - ) { + if (state.kind !== "ready" || state.pendingCommand?.id !== id) { return state; } const command = state.pendingCommand.command; @@ -660,7 +590,7 @@ function failCommand( function reconcileOptimisticSubmissions( submissions: OptimisticSubmission[], - snapshot: AgentSnapshot & { description: AgentDescription }, + snapshot: AgentSnapshot, ): OptimisticSubmission[] { const claimed = new Set(); return submissions.filter((submission) => { diff --git a/web/src/snapshot-coordinator.test.ts b/web/src/snapshot-coordinator.test.ts index d78b8cf..7ab6e1a 100644 --- a/web/src/snapshot-coordinator.test.ts +++ b/web/src/snapshot-coordinator.test.ts @@ -161,11 +161,26 @@ async function flushPromises(): Promise { function snapshot(runId: string): AgentSnapshot { return { runId, - flowStatus: "running", - errorType: null, - errorMessage: null, history: { messages: [], nextBeforeSequence: null }, - description: null, + description: { + status: "waiting_for_message", + waitingInputRound: 1, + model: "mock/reliable", + systemPrompt: "Be helpful.", + firstRetainedSequence: 1, + lastSequence: 0, + summarizedThroughSequence: 0, + pendingApproval: null, + pendingToolRecovery: null, + pendingTimer: null, + pendingUserInput: null, + plan: null, + isPlanExecutionRequested: false, + pendingQueuedMessageCount: 0, + pendingSteeredMessageCount: 0, + availableMcpServers: [], + availableTools: [], + }, queued: [], steered: [], }; diff --git a/web/tests/full-stack.spec.ts b/web/tests/full-stack.spec.ts index 9d1686e..a0caf3a 100644 --- a/web/tests/full-stack.spec.ts +++ b/web/tests/full-stack.spec.ts @@ -12,11 +12,7 @@ import { type Request, } from "@playwright/test"; -import { - EventStream, - FlowStatus, - type AgentSnapshot, -} from "../src/api/generated/index"; +import { EventStream, type AgentSnapshot } from "../src/api/generated/index"; const apiOrigin = process.env["SUPERAGENT_E2E_API_ORIGIN"] ?? "http://127.0.0.1:8080"; @@ -768,7 +764,7 @@ test("reconciles stale queue, question, and approval controls without damaging t }), ).toHaveCount(1); const snapshot = await readAgentSnapshot(page, flowId); - expect(snapshot.flowStatus).toBe(FlowStatus.RUNNING); + expect(snapshot.description.status).toBe("waiting_for_message"); }); test("resumes each live Stream after interruption without duplicate timeline entries", async ({ @@ -1234,7 +1230,7 @@ test("recovers visibly after invalid write_todos arguments", async ({ await expect(composer).toBeFocused(); const snapshot = await readAgentSnapshot(page, await displayedFlowID(page)); - expect(snapshot.description?.plan).toBeFalsy(); + expect(snapshot.description.plan).toBeFalsy(); }); test("disables busy Plan actions and continues a stalled active Plan", async ({ @@ -1311,9 +1307,6 @@ test("disables busy Plan actions and continues a stalled active Plan", async ({ await expect(plan.getByText("Plan revision 1")).toBeVisible(); await expect(continueAction).toBeEnabled({ timeout: 20_000 }); const beforeContinue = await readAgentSnapshot(page, flowId); - if (beforeContinue.description === null) { - throw new Error("Plan Flow became terminal before Continue"); - } const sequenceBeforeContinue = beforeContinue.description.lastSequence; const snapshotsBeforeContinue = snapshotStatuses.length; @@ -1338,7 +1331,7 @@ test("disables busy Plan actions and continues a stalled active Plan", async ({ await expect .poll(async () => { const snapshot = await readAgentSnapshot(page, flowId); - return snapshot.description?.lastSequence ?? sequenceBeforeContinue; + return snapshot.description.lastSequence; }) .toBeGreaterThan(sequenceBeforeContinue); expect(executeStatuses).toEqual([202, 202]); @@ -1368,9 +1361,9 @@ test("disables busy Plan actions and continues a stalled active Plan", async ({ `${apiOrigin}/products/ai-agent/snapshot?flowId=${flowId}`, ); const body = (await response.json()) as { - description?: { plan?: { revision?: number } | null } | null; + description: { plan?: { revision?: number } | null }; }; - return body.description?.plan?.revision; + return body.description.plan?.revision; }) .toBe(2); } diff --git a/web/tests/portal.spec.ts b/web/tests/portal.spec.ts index 799ac0f..fc767e0 100644 --- a/web/tests/portal.spec.ts +++ b/web/tests/portal.spec.ts @@ -103,9 +103,6 @@ test("starts a Flow against a separately deployed API", async ({ page }) => { contentType: "application/json", json: { runId: "browser-run", - flowStatus: "running", - errorType: null, - errorMessage: null, history: { messages: [], nextBeforeSequence: null }, description: { status: "waiting_for_message", From 86872b71d17d569b300c6be4966f84449fc5fb73 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 22:28:06 -0700 Subject: [PATCH 6/8] fix: standardize step heartbeat timeout --- README.md | 10 +++--- docs/adr/0011-dex-owned-tool-retries.md | 5 +-- docs/flow-model.md | 11 +++--- internal/agent/flow.go | 15 ++------ internal/agent/tool_recovery_test.go | 9 +++-- internal/agent/types.go | 1 - internal/mcp/config.go | 19 ---------- internal/mcp/config_test.go | 35 ++++++++----------- internal/mcp/registry.go | 27 +++++--------- internal/mcp/registry_test.go | 10 ++---- .../public-api-consumer/consumer_test.go | 4 +-- web/mcp-servers.example.yaml | 2 -- 12 files changed, 47 insertions(+), 101 deletions(-) diff --git a/README.md b/README.md index 5a0b109..e24aa5d 100644 --- a/README.md +++ b/README.md @@ -152,11 +152,11 @@ persisted in Dex state or logged. Copy [`web/mcp-servers.example.yaml`](web/mcp-servers.example.yaml) to configure trusted MCP servers. -Each configured tool defaults to `running_type: short_running` and a 60-second -heartbeat timeout. Use `long_running` when more than half of expected calls -exceed five seconds. This is a Dex placement optimization, not a timeout or -SLA; short-running calls may fall back and complete normally. Keep the -heartbeat default unless a healthy tool can remain silent longer. +Each configured tool defaults to `running_type: short_running`. Use +`long_running` when more than half of expected calls exceed five seconds. This +is a Dex placement optimization, not a timeout or SLA; short-running calls may +fall back and complete normally. Every regular Step attempt uses Dex's +one-minute heartbeat timeout. For a cross-origin frontend deployment, add its exact origin to `SUPERAGENT_HTTP_ALLOWED_ORIGINS`. Wildcards and credentialed cross-origin diff --git a/docs/adr/0011-dex-owned-tool-retries.md b/docs/adr/0011-dex-owned-tool-retries.md index 17b7943..b58d91a 100644 --- a/docs/adr/0011-dex-owned-tool-retries.md +++ b/docs/adr/0011-dex-owned-tool-retries.md @@ -21,8 +21,9 @@ configured tools may route to `RecoverToolExecution` and continue with unknown. New Agent Flows default Step durability to ASYNC. Short-running tools inherit that default and may fall back to regular execution. Long-running tools override -Execute durability to SYNC. Tool policy also supplies heartbeat timeout, while -attempt timeout bounds both Dex execution and the registry child context. +Execute durability to SYNC. Every regular attempt uses a one-minute heartbeat +timeout. Attempt timeout bounds both Dex execution and the registry child +context. Approval and CallID remain stable across attempts. External effects promise recoverable at-least-once execution, not exactly-once execution. diff --git a/docs/flow-model.md b/docs/flow-model.md index 246dcdb..453465d 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -24,9 +24,9 @@ that default. `CompactContext`, `CallModel`, and tools declared long-running override Execute durability to SYNC. A short-running tool may fall back from local to regular execution; that is an expected optimization path and does not change its ASYNC durability. Registry policy supplies each tool's attempt, -heartbeat, retry, and recovery settings. Ordinary Step methods use a one-minute -timeout, while model methods retain their explicit ten-minute timeout and -five-minute heartbeat. +retry, and recovery settings. Every regular attempt uses a one-minute heartbeat +timeout. Ordinary Step methods use a one-minute method timeout, while model +methods retain their explicit ten-minute method timeout. The Worker negotiates the highest common protocol with the Server before Attribute index synchronization or Worker binding. Deploy the Server before the @@ -244,9 +244,8 @@ every completed Snapshot read. Hidden pages pause the timer and live reads. `long_running` when more than half of expected calls are likely to exceed five seconds; it overrides Execute durability to SYNC. This classification is an optimization hint, not a runtime guarantee. -- Tool heartbeat defaults to one minute. Increase it only when healthy regular - execution can remain silent for longer. `AttemptTimeout` also bounds the - registry context because ASYNC local execution ignores Dex method timeouts. +- Tool heartbeat timeout is one minute. `AttemptTimeout` also bounds the registry + context because ASYNC local execution ignores Dex method timeouts. - The `mock/dex` model alone exposes `simulate_tool_failure`; `/tool-failure` uses it to verify retry exhaustion and the manual recovery surface locally. - Known business failures return a normal tool result. Transient or ambiguous diff --git a/internal/agent/flow.go b/internal/agent/flow.go index a202d6e..56c8294 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -617,8 +617,6 @@ func validateToolExecutionPolicy(definition ToolDefinition) error { return errors.New("maximum attempts exceeds the Dex limit") case definition.AttemptTimeout < 0: return errors.New("attempt timeout must not be negative") - case definition.HeartbeatTimeout < 0: - return errors.New("heartbeat timeout must not be negative") case definition.RetryTotalDuration < 0: return errors.New("retry total duration must not be negative") default: @@ -662,7 +660,7 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { } return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), + HeartbeatTimeout: time.Minute, ExecuteDurability: toolExecuteDurability(definition), ExecuteLoadAttributeMaps: toolStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ @@ -677,7 +675,7 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { func (flow *Flow) parallelToolStepOptions(definition ToolDefinition) *dex.StepOptions { return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, - HeartbeatTimeout: effectiveToolHeartbeatTimeout(definition), + HeartbeatTimeout: time.Minute, ExecuteDurability: toolExecuteDurability(definition), ExecuteRetry: &dex.RetryPolicy{ MaximumAttempts: int32(definition.MaximumAttempts), // #nosec G115 -- validated before scheduling. @@ -690,13 +688,6 @@ func (flow *Flow) parallelToolStepOptions(definition ToolDefinition) *dex.StepOp } } -func effectiveToolHeartbeatTimeout(definition ToolDefinition) time.Duration { - if definition.HeartbeatTimeout == 0 { - return time.Minute - } - return definition.HeartbeatTimeout -} - func toolExecuteDurability(definition ToolDefinition) dex.StepDurability { if definition.RunningType.Effective() == ToolRunningTypeLongRunning { return dex.StepDurabilitySync @@ -1794,7 +1785,7 @@ var ( } modelStepOptions = &dex.StepOptions{ ExecuteMethodTimeout: 10 * time.Minute, - HeartbeatTimeout: 5 * time.Minute, + HeartbeatTimeout: time.Minute, ExecuteDurability: dex.StepDurabilitySync, ExecuteLoadAttributeMaps: messageContextStepOptions.ExecuteLoadAttributeMaps, ExecuteRetry: &dex.RetryPolicy{ diff --git a/internal/agent/tool_recovery_test.go b/internal/agent/tool_recovery_test.go index 929f1be..30067cd 100644 --- a/internal/agent/tool_recovery_test.go +++ b/internal/agent/tool_recovery_test.go @@ -98,7 +98,7 @@ func TestToolDefinitionsExposeFailureSimulationOnlyToLocalMock(t *testing.T) { } } -func TestToolStepOptionsMapRunningTypeAndHeartbeat(t *testing.T) { +func TestToolStepOptionsMapRunningTypeWithOneMinuteHeartbeat(t *testing.T) { flow := &Flow{} short := parallelDefinitionForTestOnly("short") shortOptions := flow.toolStepOptions(short) @@ -114,15 +114,14 @@ func TestToolStepOptionsMapRunningTypeAndHeartbeat(t *testing.T) { long := short long.RunningType = ToolRunningTypeLongRunning - long.HeartbeatTimeout = 15 * time.Minute longOptions := flow.toolStepOptions(long) if longOptions.ExecuteDurability != dex.StepDurabilitySync || - longOptions.HeartbeatTimeout != 15*time.Minute { + longOptions.HeartbeatTimeout != time.Minute { t.Fatalf("long options = %+v", longOptions) } parallelLongOptions := flow.parallelToolStepOptions(long) if parallelLongOptions.ExecuteDurability != dex.StepDurabilitySync || - parallelLongOptions.HeartbeatTimeout != 15*time.Minute { + parallelLongOptions.HeartbeatTimeout != time.Minute { t.Fatalf("parallel long options = %+v", parallelLongOptions) } } @@ -134,7 +133,7 @@ func TestRegisteredStepOptionsUseBoundedTimeoutsAndModelSyncDurability(t *testin } if modelStepOptions.ExecuteDurability != dex.StepDurabilitySync || modelStepOptions.ExecuteMethodTimeout != 10*time.Minute || - modelStepOptions.HeartbeatTimeout != 5*time.Minute { + modelStepOptions.HeartbeatTimeout != time.Minute { t.Fatalf("model Step options = %+v", modelStepOptions) } } diff --git a/internal/agent/types.go b/internal/agent/types.go index 913f70a..761b58d 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -1099,7 +1099,6 @@ type ToolDefinition struct { RequiresApproval bool RunningType ToolRunningType AttemptTimeout time.Duration - HeartbeatTimeout time.Duration MaximumAttempts int RetryTotalDuration time.Duration SupportsParallelExecution bool diff --git a/internal/mcp/config.go b/internal/mcp/config.go index de9c56c..8555c3c 100644 --- a/internal/mcp/config.go +++ b/internal/mcp/config.go @@ -34,7 +34,6 @@ import ( const ( maximumToolAttempts = 10 maximumToolTimeout = 24 * 60 * 60 - maximumHeartbeatTimeout = 24 * 60 * 60 maximumToolRetrySeconds = 7 * 24 * 60 * 60 ) @@ -158,8 +157,6 @@ type ToolPolicy struct { TimeoutSeconds float64 `yaml:"timeout_seconds"` // RunningType defaults to short_running for ASYNC local execution with fallback. RunningType RunningType `yaml:"running_type"` - // HeartbeatTimeoutSeconds defaults to 60 for regular execution. - HeartbeatTimeoutSeconds *float64 `yaml:"heartbeat_timeout_seconds"` // MaximumAttempts defaults to three for trusted reads and one otherwise. MaximumAttempts *int `yaml:"maximum_attempts"` // RetryTotalSeconds defaults to 300 and bounds all attempts. @@ -257,10 +254,6 @@ func applyDefaults(server *ServerConfig) { if policy.RunningType == "" { policy.RunningType = RunningTypeShortRunning } - if policy.HeartbeatTimeoutSeconds == nil { - value := float64(60) - policy.HeartbeatTimeoutSeconds = &value - } if policy.RetryExhaustionPolicy == "" { policy.RetryExhaustionPolicy = RetryExhaustionPolicyManualRecovery } @@ -315,14 +308,6 @@ func validateServer(server ServerConfig) error { if err := policy.RunningType.Validate(); err != nil { return fmt.Errorf("running_type for %q: %w", name, err) } - if policy.HeartbeatTimeoutSeconds == nil || !isFinitePositive(*policy.HeartbeatTimeoutSeconds) || - *policy.HeartbeatTimeoutSeconds > maximumHeartbeatTimeout { - return fmt.Errorf( - "heartbeat_timeout_seconds for %q must be positive and at most %d", - name, - maximumHeartbeatTimeout, - ) - } if !isFinitePositive(policy.RetryTotalSeconds) || policy.RetryTotalSeconds > maximumToolRetrySeconds { return fmt.Errorf("retry_total_seconds for %q must be positive and at most %d", name, maximumToolRetrySeconds) } @@ -382,10 +367,6 @@ func cloneToolPolicies(source map[string]ToolPolicy) map[string]ToolPolicy { attempts := *policy.MaximumAttempts policy.MaximumAttempts = &attempts } - if policy.HeartbeatTimeoutSeconds != nil { - heartbeatTimeout := *policy.HeartbeatTimeoutSeconds - policy.HeartbeatTimeoutSeconds = &heartbeatTimeout - } if policy.ReadOnly != nil { readOnly := *policy.ReadOnly policy.ReadOnly = &readOnly diff --git a/internal/mcp/config_test.go b/internal/mcp/config_test.go index 8702fa3..c5d3b81 100644 --- a/internal/mcp/config_test.go +++ b/internal/mcp/config_test.go @@ -42,7 +42,6 @@ func TestLoadConfigAppliesSafeDefaults(t *testing.T) { } policy := servers[0].Tools["query"] if policy.TimeoutSeconds != 60 || policy.RunningType != RunningTypeShortRunning || - policy.HeartbeatTimeoutSeconds == nil || *policy.HeartbeatTimeoutSeconds != 60 || policy.RetryTotalSeconds != 300 || policy.RetryExhaustionPolicy != RetryExhaustionPolicyManualRecovery { t.Fatalf("policy defaults = %+v", policy) @@ -57,19 +56,31 @@ func TestLoadConfigAcceptsLongRunningToolPolicy(t *testing.T) { tools: compile: running_type: long_running - heartbeat_timeout_seconds: 900 `) servers, err := LoadConfig(path) if err != nil { t.Fatal(err) } policy := servers[0].Tools["compile"] - if policy.RunningType != RunningTypeLongRunning || policy.HeartbeatTimeoutSeconds == nil || - *policy.HeartbeatTimeoutSeconds != 900 { + if policy.RunningType != RunningTypeLongRunning { t.Fatalf("policy = %+v", policy) } } +func TestLoadConfigRejectsHeartbeatOverride(t *testing.T) { + path := writeConfig(t, `servers: + - name: build + transport: stdio + command: build-server + tools: + compile: + heartbeat_timeout_seconds: 900 +`) + if _, err := LoadConfig(path); err == nil { + t.Fatal("LoadConfig() error = nil") + } +} + func TestLoadConfigRejectsUnknownRunningType(t *testing.T) { path := writeConfig(t, `servers: - name: build @@ -194,22 +205,6 @@ func TestLoadConfigRejectsUnsafeRetryPolicy(t *testing.T) { } } -func TestLoadConfigRejectsUnsafeHeartbeatTimeout(t *testing.T) { - for _, value := range []string{".nan", "0", "-1"} { - path := writeConfig(t, `servers: - - name: search - transport: stdio - command: search-server - tools: - query: - heartbeat_timeout_seconds: `+value+` -`) - if _, err := LoadConfig(path); err == nil { - t.Fatalf("heartbeat_timeout_seconds %s: LoadConfig() error = nil", value) - } - } -} - func TestResolveEnvironmentRequiresEveryConfiguredSource(t *testing.T) { const missing = "SUPERAGENT_TEST_MISSING_MCP_SECRET" t.Setenv(missing, "present") diff --git a/internal/mcp/registry.go b/internal/mcp/registry.go index 3447e54..b03243f 100644 --- a/internal/mcp/registry.go +++ b/internal/mcp/registry.go @@ -39,10 +39,9 @@ import ( ) const ( - defaultToolTimeout = 60 * time.Second - defaultToolHeartbeatTimeout = time.Minute - defaultRetryDuration = 5 * time.Minute - maximumPublicNameSize = 64 + defaultToolTimeout = 60 * time.Second + defaultRetryDuration = 5 * time.Minute + maximumPublicNameSize = 64 ) var invalidNameCharacter = regexp.MustCompile(`[^A-Za-z0-9_]`) @@ -442,11 +441,10 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo policy, configured := server.Tools[tool.Name] if !configured { policy = ToolPolicy{ - TimeoutSeconds: 60, - RunningType: RunningTypeShortRunning, - HeartbeatTimeoutSeconds: float64Pointer(60), - RetryTotalSeconds: 300, - RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, + TimeoutSeconds: 60, + RunningType: RunningTypeShortRunning, + RetryTotalSeconds: 300, + RetryExhaustionPolicy: RetryExhaustionPolicyManualRecovery, } } readOnly := policy.ReadOnly @@ -469,15 +467,11 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo if attemptTimeout == 0 { attemptTimeout = defaultToolTimeout } - heartbeatTimeout := defaultToolHeartbeatTimeout - if policy.HeartbeatTimeoutSeconds != nil { - heartbeatTimeout = time.Duration(*policy.HeartbeatTimeoutSeconds * float64(time.Second)) - } retryDuration := time.Duration(policy.RetryTotalSeconds * float64(time.Second)) if retryDuration == 0 { retryDuration = defaultRetryDuration } - if maximumAttempts <= 0 || attemptTimeout <= 0 || heartbeatTimeout <= 0 || retryDuration <= 0 { + if maximumAttempts <= 0 || attemptTimeout <= 0 || retryDuration <= 0 { return agent.RegisteredTool{}, fmt.Errorf("invalid policy for %q", publicName) } var runningType agent.ToolRunningType @@ -507,7 +501,6 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo RequiresApproval: readOnly == nil || !*readOnly, RunningType: runningType, AttemptTimeout: attemptTimeout, - HeartbeatTimeout: heartbeatTimeout, MaximumAttempts: maximumAttempts, RetryTotalDuration: retryDuration, SupportsParallelExecution: readOnly != nil && *readOnly, @@ -516,10 +509,6 @@ func registeredTool(server ServerConfig, tool *mcpsdk.Tool) (agent.RegisteredToo }, nil } -func float64Pointer(value float64) *float64 { - return &value -} - func schemaObject(value any) (agent.JSONObject, error) { encoded, err := json.Marshal(value) if err != nil { diff --git a/internal/mcp/registry_test.go b/internal/mcp/registry_test.go index effb8b3..370eebf 100644 --- a/internal/mcp/registry_test.go +++ b/internal/mcp/registry_test.go @@ -21,7 +21,6 @@ import ( "log/slog" "strings" "testing" - "time" mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/superdurable/superagent/internal/agent" @@ -42,8 +41,7 @@ func TestRegisteredToolDefaultsWritesToOneAttemptAndApproval(t *testing.T) { t.Fatalf("registeredTool() error = %v", err) } if !registered.Definition.RequiresApproval || registered.Definition.MaximumAttempts != 1 || - registered.Definition.RunningType != agent.ToolRunningTypeShortRunning || - registered.Definition.HeartbeatTimeout != time.Minute { + registered.Definition.RunningType != agent.ToolRunningTypeShortRunning { t.Fatalf("unsafe defaults = %+v", registered.Definition) } } @@ -55,16 +53,14 @@ func TestRegisteredToolProjectsLongRunningPolicy(t *testing.T) { Command: "server", Tools: map[string]ToolPolicy{ "compile": { - RunningType: RunningTypeLongRunning, - HeartbeatTimeoutSeconds: float64Pointer(900), + RunningType: RunningTypeLongRunning, }, }, }, &mcpsdk.Tool{Name: "compile", InputSchema: map[string]any{"type": "object"}}) if err != nil { t.Fatal(err) } - if registered.Definition.RunningType != agent.ToolRunningTypeLongRunning || - registered.Definition.HeartbeatTimeout != 15*time.Minute { + if registered.Definition.RunningType != agent.ToolRunningTypeLongRunning { t.Fatalf("definition = %+v", registered.Definition) } } diff --git a/script/testdata/public-api-consumer/consumer_test.go b/script/testdata/public-api-consumer/consumer_test.go index 361462d..c566fcd 100644 --- a/script/testdata/public-api-consumer/consumer_test.go +++ b/script/testdata/public-api-consumer/consumer_test.go @@ -20,7 +20,6 @@ import ( "context" "net/http" "testing" - "time" "github.com/superdurable/dex/sdk-go/dex" "github.com/superdurable/superagent/agent" @@ -108,8 +107,7 @@ func TestExternalModuleCanConstructAndRegisterAgent(t *testing.T) { t.Fatal("public runtime metadata limit is unavailable") } definition := agent.ToolDefinition{ - RunningType: agent.ToolRunningTypeLongRunning, - HeartbeatTimeout: time.Minute, + RunningType: agent.ToolRunningTypeLongRunning, } if definition.RunningType != agent.ToolRunningTypeLongRunning { t.Fatal("public tool running policy is unavailable") diff --git a/web/mcp-servers.example.yaml b/web/mcp-servers.example.yaml index 194c9aa..210348c 100644 --- a/web/mcp-servers.example.yaml +++ b/web/mcp-servers.example.yaml @@ -12,8 +12,6 @@ servers: # Defaults to short_running. Use long_running when most calls exceed five seconds. running_type: short_running timeout_seconds: 30 - # Defaults to 60. Raise only for healthy tools with longer silent intervals. - heartbeat_timeout_seconds: 60 maximum_attempts: 3 retry_total_seconds: 120 # Defaults to manual_recovery. Use this only when automatic progress is safe. From 9a50ccab9c06335e3f05d61d3e1747b95af308c4 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 22:41:04 -0700 Subject: [PATCH 7/8] refactor: clarify tool execution step policy --- Makefile | 4 +- docs/adr/0011-dex-owned-tool-retries.md | 7 +- docs/flow-model.md | 42 +++++++-- internal/agent/flow.go | 113 ++++++++++-------------- internal/agent/tool_recovery_test.go | 16 +++- 5 files changed, 101 insertions(+), 81 deletions(-) diff --git a/Makefile b/Makefile index d15a6f0..5dbf30e 100644 --- a/Makefile +++ b/Makefile @@ -80,8 +80,8 @@ check-flow-definition: install-dexcli sed -n '/"diagnostics"/,$$p' "$${flow_definition}" >&2; \ exit 1; \ fi; \ - if grep -Fq '"name": "ExecuteTool"' "$${flow_definition}"; then \ - echo "Flow definition must not contain the removed ExecuteTool Step" >&2; \ + if ! grep -Fq '"name": "ExecuteTool"' "$${flow_definition}"; then \ + echo "Flow definition must contain the canonical ExecuteTool Step" >&2; \ exit 1; \ fi; \ for channel in answeredUserInputsChannel queuedUserMessagesChannel steeredUserMessagesChannel toolApprovalsChannel toolRecoveryDecisionsChannel parallelToolResultsChannel planExecutionsChannel; do \ diff --git a/docs/adr/0011-dex-owned-tool-retries.md b/docs/adr/0011-dex-owned-tool-retries.md index b58d91a..318f47a 100644 --- a/docs/adr/0011-dex-owned-tool-retries.md +++ b/docs/adr/0011-dex-owned-tool-retries.md @@ -12,13 +12,18 @@ could not recover correctly across Worker loss. ## Decision -`ToolDefinition` policy is applied to `ExecuteToolWithRetry` through movement +`ToolDefinition` policy is applied to `ExecuteTool` through movement StepOptions. Each registry call performs one external attempt. Known business failures return a normal error result. Transient or ambiguous failures return a Go error and use Dex retry. Exhaustion follows the tool definition's recovery policy. It defaults to the manual boundary introduced by ADR 0013; explicitly configured tools may route to `RecoverToolExecution` and continue with unknown. +Tool execution Steps have no registered StepOptions. The serial safe-boundary +router and each parallel movement resolve the selected `ToolDefinition` and +attach complete options with `WithStepOptions`. Manual retries re-resolve the +definition instead of reusing process-local policy. + New Agent Flows default Step durability to ASYNC. Short-running tools inherit that default and may fall back to regular execution. Long-running tools override Execute durability to SYNC. Every regular attempt uses a one-minute heartbeat diff --git a/docs/flow-model.md b/docs/flow-model.md index 453465d..7e06355 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -59,21 +59,21 @@ CallModel RouteTool -> CheckSteered -> AwaitToolApproval (untrusted write) - -> CheckSteered -> ExecuteToolWithRetry (approved/read-only external tool) + -> CheckSteered -> ExecuteTool (approved/read-only external tool) -> ExecuteParallelTool + AwaitParallelToolResults (bounded contiguous safe reads) -> CheckSteered -> DurableWait (timer tool) -> AwaitUser (durable input tool) -> next tool or CompactContext (built-in/result) AwaitToolApproval - -> CheckSteered -> ExecuteToolWithRetry (approved) + -> CheckSteered -> ExecuteTool (approved) -> next tool or CompactContext (rejected) -> CompactContext (steered) -ExecuteToolWithRetry +ExecuteTool -> next tool or CompactContext (success or known failure) -> RecoverToolExecution (configured automatic unknown) - -> PrepareManualToolRecovery (default retry exhaustion) + -> PrepareManualToolRecovery (manual unknown or retry exhaustion) RecoverToolExecution -> next tool or CompactContext (one unknown outcome) @@ -116,15 +116,40 @@ history, and makes the model replan. | `CheckSteered` | bounded steered batch | Apply steering at a safe boundary or route the explicit continuation | | `RouteTool` | none | Validate built-in arguments and select approval, MCP execution, timer, input, or next-call path | | `AwaitToolApproval` | exact call-ID approval or steering | Persist waiting status; consume one decision or replan on steering | -| `ExecuteToolWithRetry` | none | Perform one external tool attempt under dynamically selected Dex timeout and retry policy | +| `ExecuteTool` | none | Perform one external tool attempt under dynamically selected Dex timeout and retry policy | | `RecoverToolExecution` | none | Record one unknown result for an explicitly configured automatic recovery, then continue | | `ExecuteParallelTool` | none | Perform one bounded-wave branch effect and publish exactly one typed result without shared-state mutation | -| `RecoverParallelToolExecution` | none | Convert one exhausted branch to an unknown typed result and publish it | +| `RecoverParallelToolExecution` | none | Normalize one returned-unknown or exhausted branch and publish its typed result | | `AwaitParallelToolResults` | all started branch results | Join results, preserve model order, and either commit the batch or enter one manual recovery | -| `PrepareManualToolRecovery` | none | Capture the serial exhausted call and redacted Dex error type | +| `PrepareManualToolRecovery` | none | Capture a serial returned-unknown or exhausted call and its redacted error type | | `AwaitManualToolRecovery` | exact recovery decision or steering | Persist recovery state; retry selected calls, continue unknowns, stop the sequence, or replan | | `DurableWait` | Timer or steering | Persist waiting status; record completion or interruption and continue | +### Tool StepOptions resolution + +`ExecuteTool` and `ExecuteParallelTool` register without static StepOptions. +Their execution policy is resolved for each movement from the selected +`ToolDefinition`, so a prior call cannot leak policy into the next call. + +For a serial call, `CheckSteered` re-reads the current call, Agent config, and +state after approval and steering boundaries. It schedules `ExecuteTool` with +`WithStepOptions`. For a parallel wave, `RouteTool` creates one +`ExecuteParallelTool` movement per call with its own options. Manual retries +re-resolve each selected definition and use the same parallel movement path. + +Each movement supplies attempt timeout, the fixed one-minute heartbeat, retry +attempts and total duration, durability, and exhausted-retry routing. Serial +execution also loads the retained message map that it may append to. Each +failure target carries its own StepOptions. Parallel exhaustion becomes a typed +branch result so the join can decide whether the batch needs manual recovery. + +Dex merges movement options over registered options. Because these execution +Steps register `nil`, the movement is their complete Step-level policy. A +short-running definition leaves Execute durability unset and therefore inherits +the Flow's ASYNC default. A long-running definition explicitly selects SYNC. +ASYNC fallback changes where the attempt runs; it does not change that resolved +durability. + Dex Server `v0.10.0` and Go SDK `v0.9.1` expose Channel size metadata in `WaitFor` and `Execute`. `AwaitUser.WaitFor` reads the sizes of `SteeredUserMessages`, `QueuedUserMessages`, and the current @@ -239,7 +264,8 @@ every completed Snapshot read. Hidden pages pause the timer and live reads. ## External effects and recovery -- Tool execution policy is copied from `ToolDefinition` into Dex StepOptions. +- Tool execution policy is copied from `ToolDefinition` into movement-scoped + Dex StepOptions immediately before scheduling each execution. - `short_running` is the default and inherits Flow ASYNC durability. Use `long_running` when more than half of expected calls are likely to exceed five seconds; it overrides Execute durability to SYNC. This classification is an diff --git a/internal/agent/flow.go b/internal/agent/flow.go index 56c8294..c0cb73d 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -83,6 +83,13 @@ func (*Flow) GetFlowType() string { // GetSteps registers the state-machine nodes. func (flow *Flow) GetSteps() []dex.StepDef { + // Tool calls enter routeToolStep after a checkSteeredStep safe boundary. + // Built-ins complete there; external writes may wait in awaitToolApprovalStep. + // Serial effects run in executeToolStep and use recoverToolExecutionStep for + // configured automatic recovery. Safe reads fan out through + // executeParallelToolStep, recover per branch, and join before shared state + // changes. prepareManualToolRecoveryStep and awaitManualToolRecoveryStep own + // operator decisions and retry selected calls with their original IDs. return []dex.StepDef{ dex.DefineStartStep(initStep{flow: flow}), dex.DefineStep(awaitUserStep{flow: flow}), @@ -92,7 +99,7 @@ func (flow *Flow) GetSteps() []dex.StepDef { dex.DefineStep(checkSteeredStep{flow: flow}), dex.DefineStep(routeToolStep{flow: flow}), dex.DefineStep(awaitToolApprovalStep{flow: flow}), - dex.DefineStep(executeToolWithRetryStep{flow: flow}), + dex.DefineStep(executeToolStep{flow: flow}), dex.DefineStep(recoverToolExecutionStep{flow: flow}), dex.DefineStep(executeParallelToolStep{flow: flow}), dex.DefineStep(recoverParallelToolExecutionStep{flow: flow}), @@ -624,7 +631,9 @@ func validateToolExecutionPolicy(definition ToolDefinition) error { } } -func (flow *Flow) currentToolStepOptions(ctx dex.Context) (*dex.StepOptions, error) { +// currentSerialToolStepOptions resolves the current call before scheduling its +// serial execution movement. WithStepOptions carries the result to Dex. +func (flow *Flow) currentSerialToolStepOptions(ctx dex.Context) (*dex.StepOptions, error) { call, err := flow.currentToolCall(ctx) if err != nil { return nil, err @@ -644,10 +653,12 @@ func (flow *Flow) currentToolStepOptions(ctx dex.Context) (*dex.StepOptions, err if err := validateToolExecutionPolicy(definition); err != nil { return nil, fmt.Errorf("tool %q: %w", definition.Name, err) } - return flow.toolStepOptions(definition), nil + return flow.serialToolStepOptions(definition), nil } -func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { +// serialToolStepOptions maps one definition to movement-scoped serial policy. +// Exhausted retries route according to that tool's recovery policy. +func (flow *Flow) serialToolStepOptions(definition ToolDefinition) *dex.StepOptions { failureStep := dex.ProceedToOnExecuteFailure( recoverToolExecutionStep{flow: flow}, messageMutationStepOptions, @@ -672,6 +683,8 @@ func (flow *Flow) toolStepOptions(definition ToolDefinition) *dex.StepOptions { } } +// parallelToolStepOptions maps one definition to one branch movement. Branch +// exhaustion always becomes a typed result so the join can resolve the batch. func (flow *Flow) parallelToolStepOptions(definition ToolDefinition) *dex.StepOptions { return &dex.StepOptions{ ExecuteMethodTimeout: definition.AttemptTimeout, @@ -1655,7 +1668,7 @@ const ( continueCompactContext continuation = "compact_context" continueRouteTool continuation = "route_tool" continueAwaitToolApproval continuation = "await_tool_approval" - continueExecuteToolRetry continuation = "execute_tool_with_retry" + continueExecuteTool continuation = "execute_tool" continueDurableWait continuation = "durable_wait" stepTypeInit stepType = "Init" @@ -1666,7 +1679,7 @@ const ( stepTypeCheckSteered stepType = "CheckSteered" stepTypeRouteTool stepType = "RouteTool" stepTypeAwaitApproval stepType = "AwaitToolApproval" - stepTypeExecuteRetry stepType = "ExecuteToolWithRetry" + stepTypeExecuteTool stepType = "ExecuteTool" stepTypeRecoverTool stepType = "RecoverToolExecution" stepTypeExecuteParallel stepType = "ExecuteParallelTool" stepTypeRecoverParallel stepType = "RecoverParallelToolExecution" @@ -2293,13 +2306,13 @@ func (step checkSteeredStep) Execute(ctx dex.Context, input continuation) (*dex. return dex.GoTo(routeToolStep{flow: step.flow}, nil), nil case continueAwaitToolApproval: return dex.GoTo(awaitToolApprovalStep{flow: step.flow}, nil), nil - case continueExecuteToolRetry: - options, err := step.flow.currentToolStepOptions(ctx) + case continueExecuteTool: + options, err := step.flow.currentSerialToolStepOptions(ctx) if err != nil { return nil, err } return dex.GoTo( - executeToolWithRetryStep{flow: step.flow}, + executeToolStep{flow: step.flow}, nil, dex.WithStepOptions(options), ), nil @@ -2524,7 +2537,7 @@ func (step routeToolStep) Execute(ctx dex.Context, _ dex.None) (*dex.StepDecisio } return dex.GoTo(checkSteeredStep{flow: step.flow}, continueAwaitToolApproval), nil } - return dex.GoTo(checkSteeredStep{flow: step.flow}, continueExecuteToolRetry), nil + return dex.GoTo(checkSteeredStep{flow: step.flow}, continueExecuteTool), nil } type awaitToolApprovalStep struct { @@ -2581,7 +2594,7 @@ func (step awaitToolApprovalStep) Execute(ctx dex.Context, _ dex.None) (*dex.Ste return nil, deleteErr } if approvals[0].Approved { - return dex.GoTo(checkSteeredStep{flow: step.flow}, continueExecuteToolRetry), nil + return dex.GoTo(checkSteeredStep{flow: step.flow}, continueExecuteTool), nil } result, encodeErr := encodeToolResult(toolResultPayload{ Status: toolResultStatusFailed, @@ -2702,29 +2715,18 @@ func (flow *Flow) finishToolBatch(ctx dex.Context, batch toolBatchState) (contin return continueCompactContext, nil } -type executeToolWithRetryStep struct { +// executeToolStep has no registered StepOptions. checkSteeredStep resolves the +// current definition and attaches complete serial options to every movement. +type executeToolStep struct { dex.StepDefaultsNoWaitFor[dex.None] flow *Flow } -var _ dex.Step[dex.None] = executeToolWithRetryStep{} +var _ dex.Step[dex.None] = executeToolStep{} -func (executeToolWithRetryStep) GetStepType() string { return string(stepTypeExecuteRetry) } +func (executeToolStep) GetStepType() string { return string(stepTypeExecuteTool) } -func (step executeToolWithRetryStep) GetStepOptions() *dex.StepOptions { - return &dex.StepOptions{ - ExecuteMethodTimeout: toolStepOptions.ExecuteMethodTimeout, - HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, - ExecuteLoadAttributeMaps: toolStepOptions.ExecuteLoadAttributeMaps, - ExecuteRetry: toolStepOptions.ExecuteRetry, - ExecuteFailure: dex.ProceedToOnExecuteFailure( - prepareManualToolRecoveryStep{flow: step.flow}, - manualToolRecoveryStepOptions, - ), - } -} - -func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex.StepDecision, error) { +func (step executeToolStep) Execute(ctx dex.Context, _ dex.None) (*dex.StepDecision, error) { if err := step.flow.updateStatus(ctx, AgentStatusExecutingTool); err != nil { return nil, err } @@ -2773,21 +2775,7 @@ func (step executeToolWithRetryStep) Execute(ctx dex.Context, _ dex.None) (*dex. } if result.Outcome == ToolOutcomeUnknown && definition.RetryExhaustionPolicy.Effective() == ToolRetryExhaustionPolicyManualRecovery { - resultCopy := result - batch := toolBatchState{ - BatchID: fmt.Sprintf("tool-batch-%d-%d", state.LastSequence, state.PendingToolIndex), - FirstIndex: state.PendingToolIndex, - RecoveryRevision: 1, - Records: []toolBatchRecord{{ - Index: state.PendingToolIndex, - Call: call, - Result: &resultCopy, - HasResult: true, - ErrorType: "tool_reported_unknown", - RetryExhaustionPolicy: ToolRetryExhaustionPolicyManualRecovery, - }}, - } - return dex.GoTo(awaitManualToolRecoveryStep{flow: step.flow}, batch), nil + return dex.GoTo(prepareManualToolRecoveryStep{flow: step.flow}, nil), nil } if result.Outcome == ToolOutcomeUnknown { return dex.GoTo(recoverToolExecutionStep{flow: step.flow}, nil), nil @@ -2836,6 +2824,8 @@ func (step recoverToolExecutionStep) Execute(ctx dex.Context, _ dex.None) (*dex. return dex.GoTo(checkSteeredStep{flow: step.flow}, next), nil } +// executeParallelToolStep has no registered StepOptions. Initial fan-out and +// manual retries attach complete branch options to every movement. type executeParallelToolStep struct { dex.StepDefaultsNoWaitFor[parallelToolExecutionInput] flow *Flow @@ -2845,18 +2835,6 @@ var _ dex.Step[parallelToolExecutionInput] = executeParallelToolStep{} func (executeParallelToolStep) GetStepType() string { return string(stepTypeExecuteParallel) } -func (step executeParallelToolStep) GetStepOptions() *dex.StepOptions { - return &dex.StepOptions{ - ExecuteMethodTimeout: toolStepOptions.ExecuteMethodTimeout, - HeartbeatTimeout: toolStepOptions.HeartbeatTimeout, - ExecuteRetry: toolStepOptions.ExecuteRetry, - ExecuteFailure: dex.ProceedToOnExecuteFailure( - recoverParallelToolExecutionStep{flow: step.flow}, - defaultStepOptions, - ), - } -} - func (step executeParallelToolStep) Execute( ctx dex.Context, input parallelToolExecutionInput, @@ -2900,15 +2878,14 @@ func (step executeParallelToolStep) Execute( if err := result.Outcome.Validate(); err != nil { return nil, fmt.Errorf("tool %q outcome: %w", input.Call.Name, err) } - errorType := "" if result.Outcome == ToolOutcomeUnknown { - errorType = "tool_reported_unknown" + return dex.GoTo(recoverParallelToolExecutionStep{flow: step.flow}, input), nil } if err := parallelToolResultsChannel.Publish(ctx, input.ResultInstance, parallelToolResult{ Index: input.Index, Call: input.Call, Result: result, - ErrorType: errorType, + ErrorType: "", RetryExhaustionPolicy: input.RetryExhaustionPolicy.Effective(), }); err != nil { return nil, err @@ -2935,11 +2912,11 @@ func (recoverParallelToolExecutionStep) Execute( ctx dex.Context, input parallelToolExecutionInput, ) (*dex.StepDecision, error) { - failure := ctx.RecoveryError() - if failure == nil { - return nil, errors.New("parallel tool recovery is missing the exhausted failure") + errorType := "tool_reported_unknown" + if failure := ctx.RecoveryError(); failure != nil { + errorType = failure.ErrorType } - result, err := unknownToolResult(failure.ErrorType) + result, err := unknownToolResult(errorType) if err != nil { return nil, err } @@ -2947,7 +2924,7 @@ func (recoverParallelToolExecutionStep) Execute( Index: input.Index, Call: input.Call, Result: result, - ErrorType: failure.ErrorType, + ErrorType: errorType, RetryExhaustionPolicy: input.RetryExhaustionPolicy.Effective(), }); err != nil { return nil, err @@ -3042,9 +3019,9 @@ func (step prepareManualToolRecoveryStep) Execute( ctx dex.Context, _ dex.None, ) (*dex.StepDecision, error) { - failure := ctx.RecoveryError() - if failure == nil { - return nil, errors.New("manual tool recovery is missing the exhausted failure") + errorType := "tool_reported_unknown" + if failure := ctx.RecoveryError(); failure != nil { + errorType = failure.ErrorType } call, err := step.flow.currentToolCall(ctx) if err != nil { @@ -3054,7 +3031,7 @@ func (step prepareManualToolRecoveryStep) Execute( if err != nil { return nil, err } - result, err := unknownToolResult(failure.ErrorType) + result, err := unknownToolResult(errorType) if err != nil { return nil, err } @@ -3068,7 +3045,7 @@ func (step prepareManualToolRecoveryStep) Execute( Call: call, Result: &resultCopy, HasResult: true, - ErrorType: failure.ErrorType, + ErrorType: errorType, RetryExhaustionPolicy: ToolRetryExhaustionPolicyManualRecovery, }}, } diff --git a/internal/agent/tool_recovery_test.go b/internal/agent/tool_recovery_test.go index 30067cd..e04978e 100644 --- a/internal/agent/tool_recovery_test.go +++ b/internal/agent/tool_recovery_test.go @@ -101,7 +101,7 @@ func TestToolDefinitionsExposeFailureSimulationOnlyToLocalMock(t *testing.T) { func TestToolStepOptionsMapRunningTypeWithOneMinuteHeartbeat(t *testing.T) { flow := &Flow{} short := parallelDefinitionForTestOnly("short") - shortOptions := flow.toolStepOptions(short) + shortOptions := flow.serialToolStepOptions(short) if shortOptions.ExecuteDurability != dex.StepDurabilityDefault || shortOptions.HeartbeatTimeout != time.Minute { t.Fatalf("short options = %+v", shortOptions) @@ -114,7 +114,7 @@ func TestToolStepOptionsMapRunningTypeWithOneMinuteHeartbeat(t *testing.T) { long := short long.RunningType = ToolRunningTypeLongRunning - longOptions := flow.toolStepOptions(long) + longOptions := flow.serialToolStepOptions(long) if longOptions.ExecuteDurability != dex.StepDurabilitySync || longOptions.HeartbeatTimeout != time.Minute { t.Fatalf("long options = %+v", longOptions) @@ -126,6 +126,18 @@ func TestToolStepOptionsMapRunningTypeWithOneMinuteHeartbeat(t *testing.T) { } } +func TestToolExecutionStepsUseOnlyMovementOptions(t *testing.T) { + if got := (executeToolStep{}).GetStepType(); got != "ExecuteTool" { + t.Fatalf("serial Step type = %q", got) + } + if options := (executeToolStep{}).GetStepOptions(); options != nil { + t.Fatalf("serial registered options = %+v", options) + } + if options := (executeParallelToolStep{}).GetStepOptions(); options != nil { + t.Fatalf("parallel registered options = %+v", options) + } +} + func TestRegisteredStepOptionsUseBoundedTimeoutsAndModelSyncDurability(t *testing.T) { if defaultStepOptions.WaitForMethodTimeout != time.Minute || defaultStepOptions.ExecuteMethodTimeout != time.Minute { From e37dacc0ab116ebecfe12717805b8126159f8519 Mon Sep 17 00:00:00 2001 From: Quanzheng Long Date: Thu, 17 Sep 2026 22:43:05 -0700 Subject: [PATCH 8/8] docs: map tool call step topology --- docs/flow-model.md | 34 ++++++++++++++++++++++++++++++++++ internal/agent/flow.go | 22 +++++++++++++++------- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/docs/flow-model.md b/docs/flow-model.md index 7e06355..5fdb542 100644 --- a/docs/flow-model.md +++ b/docs/flow-model.md @@ -99,6 +99,40 @@ DurableWait -> CompactContext (steered) ``` +### Tool-call routing + +```text +ModelReply.ToolCalls + | + v +AgentState.PendingToolCalls + | + v +RouteTool + |- built-ins + | |- write_todos -> completes in RouteTool + | |- durable_wait -> CheckSteered -> DurableWait + | `- request_user_input -> AwaitUser + | + |- two or more consecutive eligible external calls + | Conditions: no approval, SupportsParallelExecution, parallel limit > 1 + | -> parallelToolMovements + | -> GoToMany( + | AwaitParallelToolResults, + | ExecuteParallelTool x N + | ) + | + `- any other external call + -> AwaitToolApproval, when required + -> CheckSteered + -> ExecuteTool +``` + +Built-ins never enter an external tool execution Step. `RouteTool` applies these +branches to the current pending call. Parallel routing only consumes the +consecutive eligible prefix, bounded by `MaxParallelToolCalls`. The join +restores model order before shared Agent state changes. + `CheckSteered` is the safe-boundary router. It never cancels an in-flight model or MCP call. A steered message clears stale approval, timer, and pending input state, persists cancellation results for abandoned calls, enters application diff --git a/internal/agent/flow.go b/internal/agent/flow.go index c0cb73d..b024cb4 100644 --- a/internal/agent/flow.go +++ b/internal/agent/flow.go @@ -83,13 +83,21 @@ func (*Flow) GetFlowType() string { // GetSteps registers the state-machine nodes. func (flow *Flow) GetSteps() []dex.StepDef { - // Tool calls enter routeToolStep after a checkSteeredStep safe boundary. - // Built-ins complete there; external writes may wait in awaitToolApprovalStep. - // Serial effects run in executeToolStep and use recoverToolExecutionStep for - // configured automatic recovery. Safe reads fan out through - // executeParallelToolStep, recover per branch, and join before shared state - // changes. prepareManualToolRecoveryStep and awaitManualToolRecoveryStep own - // operator decisions and retry selected calls with their original IDs. + // Tool-call Step topology: + // + // ModelReply.ToolCalls -> AgentState.PendingToolCalls -> routeToolStep + // |- built-ins: + // | write_todos -> finish in routeToolStep + // | durable_wait -> checkSteeredStep -> durableWaitStep + // | request_user_input -> awaitUserStep + // |- consecutive eligible external calls: + // | parallelToolMovements -> GoToMany( + // | awaitParallelToolResultsStep, executeParallelToolStep x N) + // `- other external call: + // awaitToolApprovalStep when required -> checkSteeredStep -> executeToolStep + // + // Serial failures recover through recoverToolExecutionStep or the manual + // recovery Steps. Parallel failures normalize per branch before the join. return []dex.StepDef{ dex.DefineStartStep(initStep{flow: flow}), dex.DefineStep(awaitUserStep{flow: flow}),