diff --git a/.github/actions/port-toolchain/action.yml b/.github/actions/port-toolchain/action.yml new file mode 100644 index 0000000..6eabe46 --- /dev/null +++ b/.github/actions/port-toolchain/action.yml @@ -0,0 +1,14 @@ +name: Port toolchain (Go) +description: Install the toolchain the port verifier needs (Go). + +runs: + using: composite + steps: + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: Download modules + shell: bash + run: go mod download diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..c3a4fc9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,76 @@ +name: CI + +# This repo had no CI. Without it the port verifier's go test/vet calls only ever +# ran inside the sync job, so a generated PR reached review with no independent +# signal. This runs the same checks on every PR and push. + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.25" + cache: true + + - name: gofmt + run: | + unformatted=$(gofmt -l . | grep -v '^tmp/' || true) + if [ -n "$unformatted" ]; then + echo "::error::gofmt needed: $unformatted" + exit 1 + fi + + - run: go build ./... + - run: go vet ./... + + # Deterministic tests. e2e_test.go skips without OPENROUTER_API_KEY. + - run: go test ./... + + # Reports the port's own mechanical gate. Advisory here, BLOCKING inside the + # sync job (scripts/upstream) where it gates whether state.yaml advances. + # + # Advisory on purpose: the port is currently a minor version behind upstream, so + # the required-API check fails by design until the first sync lands. Making that + # a red required check on every unrelated PR just teaches people to ignore CI. + # The signal still shows up in the job summary. + verify-port: + runs-on: ubuntu-latest + timeout-minutes: 15 + if: github.event_name == 'pull_request' + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/port-toolchain + - name: Port verifier (advisory) + id: verify + continue-on-error: true + run: | + set -o pipefail + ./.upstreamer/scripts/verify.sh 2>&1 | tee /tmp/verify.log + + - name: Summarize + if: always() + run: | + { + echo "## Port verifier" + echo + if [ "${{ steps.verify.outcome }}" = "success" ]; then + echo "Port is in sync with its parity floor." + else + echo "Parity gaps below. Expected until the port catches up to upstream —" + echo "advisory here, blocking inside the sync job." + fi + echo + echo '```' + cat /tmp/verify.log 2>/dev/null || echo "(no output)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/upstreamer-port.yaml b/.github/workflows/upstreamer-port.yaml new file mode 100644 index 0000000..17317c6 --- /dev/null +++ b/.github/workflows/upstreamer-port.yaml @@ -0,0 +1,162 @@ +name: Upstreamer Port + +# Ports @openrouter/agent into this repo. Two triggers: +# 1. repository_dispatch from typescript-agent's publish.yaml on a new npm release +# (event type: openrouter-agent-published) — the intended path. Ports track +# published releases, not every commit to upstream main. +# 2. Weekly cron as a safety net for missed dispatches, plus manual dispatch. +# +# Opens a PR. Never pushes to main. A failed parity eval leaves +# .upstreamer/state.yaml unchanged, so the next run retries the same delta. + +on: + repository_dispatch: + types: [openrouter-agent-published] + schedule: + - cron: "23 6 * * 1" + workflow_dispatch: + inputs: + ref: + description: "Upstream ref to port (blank = upstream default branch HEAD)" + required: false + type: string + force: + description: "Re-run even if the upstream commit is unchanged" + required: false + default: false + type: boolean + +permissions: + contents: write + pull-requests: write + actions: write # to dispatch ci.yaml onto the generated PR branch + +concurrency: + group: upstreamer-port + cancel-in-progress: false + +jobs: + port: + runs-on: ubuntu-latest + timeout-minutes: 150 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: oven-sh/setup-bun@v2 + + - name: Install opencode + run: bun install -g opencode-ai + + - name: Set up language toolchain + uses: ./.github/actions/port-toolchain + + # Ports track published releases, not upstream main. When no ref arrives + # (cron, or a manual dispatch with the input left blank), resolve the + # latest published @openrouter/agent version from the public npm registry + # and port its release tag. This makes the cron fully equivalent to the + # repository_dispatch fast path — same tag either way — so the pipeline + # works with no cross-repo token at all if the dispatch is unavailable. + - name: Resolve target ref + id: target + run: | + set -euo pipefail + REF="${{ inputs.ref || github.event.client_payload.ref }}" + if [ -z "$REF" ]; then + VERSION="$(curl -fsSL 'https://registry.npmjs.org/@openrouter%2Fagent/latest' | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')" + REF="@openrouter/agent@${VERSION}" + echo "No ref provided — resolved latest npm release: $REF" + fi + echo "ref=$REF" >> "$GITHUB_OUTPUT" + + - name: Run port + env: + # Provide these in repo settings: + # Secret OPENROUTER_API_KEY — sk-or-... key opencode uses for inference + # Variable OPENCODE_MODEL — e.g. openrouter/~anthropic/claude-opus-latest + OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} + OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }} + UPSTREAMER_TIMEOUT_SECONDS: 7200 + run: | + set -euo pipefail + if [ -z "${OPENROUTER_API_KEY:-}" ]; then + echo "::error::OPENROUTER_API_KEY secret is not set. See .upstreamer/port.env.example." + exit 1 + fi + args=(--ref "${{ steps.target.outputs.ref }}") + [ "${{ inputs.force }}" = "true" ] && args+=(--force) + ./scripts/upstream "${args[@]}" + + - name: Check for changes + id: diff + run: | + if [ -n "$(git status --porcelain -- . ':!tmp')" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "No changes — upstream unchanged or port was a no-op." + fi + + # State only advances when the verifier AND the parity eval passed, so an + # unchanged state file next to a changed tree means the port did not pass. + # Label the PR accordingly instead of letting it look green. + - name: Detect eval failure + if: steps.diff.outputs.changed == 'true' + id: gate + run: | + if git diff --quiet -- .upstreamer/state.yaml; then + echo "passed=false" >> "$GITHUB_OUTPUT" + echo "::warning::state.yaml did not advance — parity eval did not pass. See .upstreamer/eval-report.md." + else + echo "passed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Open PR + id: open-pr + if: steps.diff.outputs.changed == 'true' + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: upstreamer/sync + delete-branch: true + title: >- + ${{ steps.gate.outputs.passed == 'true' + && 'port: sync with @openrouter/agent upstream' + || 'port: sync with @openrouter/agent upstream (EVAL FAILED — do not merge)' }} + commit-message: "port: sync with @openrouter/agent upstream" + labels: >- + ${{ steps.gate.outputs.passed == 'true' + && 'upstreamer, automated' + || 'upstreamer, automated, eval-failed' }} + body: | + Automated Upstreamer port of `@openrouter/agent` into this repo. + + - Contract: `.upstreamer/upstreamer.md` + - Run log: `.upstreamer/logs/` + - Parity eval: `.upstreamer/eval-report.md` + - Parity eval passed: **${{ steps.gate.outputs.passed }}** + + Review the diff as a port, not as a normal PR: check behavioral parity + against the TypeScript reference, not just that it compiles. If + `.upstreamer/state.yaml` did not advance, the eval did not pass and this + PR must not be merged as-is. + + # Events created with the native GITHUB_TOKEN deliberately do not trigger + # other workflows (GitHub's recursion guard), so the PR opened above gets + # no CI checks on its own. workflow_dispatch is exempt from that guard: + # kick ci.yaml at the PR branch explicitly. This keeps the whole pipeline + # on the native token — no PAT anywhere in this repo. + - name: Trigger CI on the port PR + if: steps.diff.outputs.changed == 'true' && steps.open-pr.outputs.pull-request-operation != 'none' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh workflow run ci.yaml --repo "$GITHUB_REPOSITORY" --ref upstreamer/sync + + - name: Upload logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: upstreamer-logs + path: .upstreamer/logs/ + if-no-files-found: ignore diff --git a/.gitignore b/.gitignore index ce8a4f9..e766856 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,12 @@ coverage.txt .env .env.* !.env.example + +# Upstreamer port machinery +# Upstream checkout + scratch work +tmp/upstreamer/ +# Run logs +.upstreamer/logs/ +# LOCAL SECRETS - never commit +.upstreamer/port.env +!.upstreamer/port.env.example diff --git a/.upstreamer/eval-report.md b/.upstreamer/eval-report.md new file mode 100644 index 0000000..6e39cb4 --- /dev/null +++ b/.upstreamer/eval-report.md @@ -0,0 +1,296 @@ +PASS WITH WARNINGS + +# Eval Report — go-agent, @openrouter/agent 0.7.2 → 0.8.0 (Round 4) + +Fresh review context. No prior reasoning trusted — every claim below was +independently verified by reading upstream source, reading the port's code, +and writing/running throwaway probes against `StateAccessor` (not the raw +`State` pointer). Probes were deleted before finishing; `git status` before +and after this review is identical except for this report file. + +## Headline + +The Round-3 finding (an approval/HITL resume that itself re-pauses never +called `StateAccessor.Save`, so a `StateAccessor`-only caller could reload +stale state and re-execute an already-approved tool) **is genuinely fixed**, +and — going beyond just re-checking that one line — I traced every pause +path in `run()` and independently confirmed via fresh probes that the two +sibling pause boundaries the task specifically flagged as at-risk +(`awaiting_hitl` and `awaiting_client_tools`), plus the mixed-approval pause, +**all correctly persist via `StateAccessor.Save`** through a different, +independent code path (a `break` out of the main loop that falls through to +an unconditional save at the end of `run()`, rather than a `return`). + +While hunting for "the next place a similar bug could hide" (as the task +explicitly asked), I found one real, reproducible, but narrower-scope gap: +**go-agent does not persist state incrementally per model turn the way +upstream does** (upstream's `saveResponseToState`/`saveToolResultsToState` +save after every turn; go-agent only saves at the very end of `run()` or at +an explicit pause). A `StateAccessor`-only caller who hits a genuine +mid-run turn-level error (network failure, rate limit, etc.) on turn N+1 +loses visibility into turn N's already-executed tool work in the accessor, +even though that tool's side effect already happened. This is real and +worth fixing, but — unlike the Round-3 bug — it is (a) triggered by an +exceptional/error condition outside the documented pause/resume contract, +not the normal, expected, celebrated approval-pause path; (b) pre-existing +since the very first port (confirmed present in upstream's 0.7.2-era source +too, so it predates this whole 0.8.0 delta, not just this round); and (c) +not a violation of eval.md's actual enumerated "State" requirement, which is +scoped to "State survives a pause/resume cycle mid-approval" — a bullet this +round's fix (plus my own probes of the sibling boundaries) now fully +satisfies. I'm flagging it as a warning/follow-up rather than a blocker. + +Verdict: **PASS WITH WARNINGS.** + +--- + +## 1. Upstream reference re-confirmed from source + +`tmp/upstreamer/upstream/packages/agent/src/lib/model-result.ts`: + +- `saveStateSafely` (2428-2445): the single choke point that calls + `stateAccessor.save`. It is unconditional — no caller checks the resulting + status before deciding whether to save. +- `processApprovalDecisions` (2735-2881): computes `nextStatus` (which may be + `awaiting_approval`, `awaiting_hitl`, or `in_progress`) and calls + `await this.saveStateSafely(stateUpdates)` at line 2859 **before** the + `if (nextStatus !== 'in_progress') { return; }` check at 2875 — i.e. the + save happens regardless of whether the resume resolves or re-pauses. This + is the exact behavior the Round-3/4 fix needed to match. +- `persistHitlPause` (1711-1727) and `persistClientToolsPause` (1753-1769) + each call `saveStateSafely(stateUpdates)` unconditionally when a HITL or + manual-tool pause happens on the **initial** (non-resume) path — confirming + the initial pause paths, not just resumes, must persist. +- `handleApprovalCheck`'s mixed-approval-turn save (1690) is likewise + unconditional before pausing with `awaiting_approval`. +- `saveResponseToState` (721-758) and `saveToolResultsToState` (776+) are + called after **every** turn's response and after **every** tool-round's + results (call sites at lines 3003, 3155, 3200, 3246, 3286, 3304) — i.e. + upstream persists incrementally per turn, not just at pause/completion + boundaries. This is the source of the warning-level finding in §4. + +## 2. Go port control flow re-traced end to end (`model_result.go` `run()`) + +Traced every `return` and every `break` in `run()` (lines 182-498): + +- **Resume-repause branch** (227-251, this round's fix): `paused, err := + m.prepareResumeRequest(&req); if paused { ...StateAccessor.Save...; return + }`. Confirmed present, calls `Save` before `return`, mirrors upstream's + unconditional `saveStateSafely` in `processApprovalDecisions`. This is a + faithful, minimal, correctly-placed fix. +- **Main loop pause exits** (`ConversationStatusAwaitingApproval` at 380-381 + and 437-439 for the mixed-approval case; `isPausedStatus` break at 391 and + 448-449 for HITL/client-tools/interrupted) all use `break`, not `return` — + which drops out of the `for turn` loop and falls through to the + **unconditional** `StateAccessor.Save` at lines 493-497. Since none of + these pause paths early-`return`, they all reach that save. This is a + structurally different (but equally correct) mechanism than the + resume-repause fix, and it already covered these cases correctly — I did + not find a second instance of the Round-3 bug class on the *initial* pause + paths. +- `executeToolCallsForTurn` (763-822) sets + `ConversationStatusAwaitingHITL`/`ConversationStatusAwaitingClientTools` + directly on `m.state` and returns to the caller, which then `break`s — so + the same unconditional end-of-`run()` save covers both. +- Genuine mid-loop **errors** (`m.err = err; return`, e.g. `SendResponse` + failure, stream-consume failure, marshal failure) skip the end-of-`run()` + save entirely, same as upstream would skip a `saveStateSafely` call it + never reaches because the enclosing `async` function threw first — *except* + upstream also has the extra `saveResponseToState`/`saveToolResultsToState` + calls sprinkled through the loop that persist progress from **earlier, + already-completed** turns before a **later** turn's request throws. Go-agent + has no equivalent per-turn checkpoint; see §4. + +## 3. Fresh probes (written, run, deleted — not the port's own tests) + +Three throwaway tests in `probe_pause_stateaccessor_test.go`, using +`StateAccessor` end-to-end (a `memoryStateAccessor` test double, not +`ModelResult.State()`) and a **third** `CallModel` call reloading from the +same accessor to check for duplicate tool execution, exactly like the +port's own `TestStateAccessorPersistsWhenResumeRepauses`: + +- `TestProbeStateAccessorSavedOnHITLPause`: an auto tool + a HITL tool + (`OnToolCalled` returning `false`) pause the run with `awaiting_hitl`. + `accessor.saves > 0`, `accessor.state.Status == awaiting_hitl`, the + regular tool's output was already in `accessor.state.Messages`. A second + `CallModel` call reloading from the same accessor and approving the HITL + call did **not** re-run the regular tool (`regularRan == 1`). +- `TestProbeStateAccessorSavedOnAwaitingClientToolsPause`: an auto tool + an + unresolved manual tool pause with `awaiting_client_tools`. Accessor saved, + status and pending call correctly reflected, regular tool's output + persisted before the pause. +- `TestProbeStateAccessorSavedOnMixedApprovalPause`: an auto tool + an + approval-required tool pause with `awaiting_approval`. Accessor saved, the + auto tool's unsent result was persisted to the accessor **before** the + pause. A second `CallModel` call reloading from the same accessor and + approving the pending call did not re-run the auto tool, ran the danger + tool exactly once, and both outputs reached the resume request as + `function_call_output` items in the correct order. + +All three passed, including under `-race`. (My first draft of these probes +had a synchronization bug — checking counters immediately after `CallModel` +returns without blocking on `.State(ctx)`/`.Text(ctx)` first, since +`CallModel` starts `run()` in a goroutine and returns immediately. Fixed by +blocking before asserting; this was a bug in my probe, not the port.) + +A fourth, throwaway probe (`probe_midrun_error_test.go`, +`TestProbeStateAccessorSavedAfterEarlierTurnWhenLaterTurnErrors`) is the +source of the §4 warning: a tool executes successfully on turn 1, a second +call errors on turn 2 (simulated transport failure), and +`accessor.saves == 0` — turn 1's already-executed tool work never reached +the accessor. + +All probe files were deleted before finishing. `git status --porcelain` +before and after probing is identical (only `.upstreamer/eval-report.md` is +new/modified across the whole session). + +## 4. Warning: no per-turn incremental `StateAccessor` persistence + +Confirmed via probe (above) and via reading +`tmp/upstreamer/upstream/packages/agent/src/lib/model-result.ts` at both the +0.7.2 baseline (`adc7939`) and the 0.8.0 target (`680bceb`): upstream calls +`saveResponseToState`/`saveToolResultsToState` after **every** model +response and **every** tool-execution round in the main loop, not just at +pause/completion boundaries. `go-agent`'s `run()` only reaches +`StateAccessor.Save` at the very end (line 493-497, after the `for` loop +exits via `break` or falls through normally) or at the Round-4-fixed +resume-repause early return. Any hard error inside the loop (`m.err = err; +return`) skips persistence of everything accumulated in prior, already- +completed turns of the *same* run. + +Impact: a caller who relies **solely** on `StateAccessor` (never manually +re-threading `ModelResult.State()`, which the contract explicitly allows as +an alternative) and who experiences a genuine mid-run turn-level failure +after at least one earlier turn already executed a tool with a real side +effect, has no durable record of that side effect. A naive retry from the +stale accessor state could re-execute that tool a second time — the same +failure mode as the Round-3 bug, but triggered by an exceptional error +condition on an ordinary multi-turn loop, rather than by the normal, +documented, partial-approval-resume pattern the Round-3/4 fix targets. + +This predates the 0.8.0 delta (confirmed present in upstream's own 0.7.2-era +source, so it is not something this round — or even the initial port round — +was tasked with introducing or fixing as part of *this* sync), is not a +violation of eval.md's actual "State" bullet (scoped specifically to +"a pause/resume cycle mid-approval," which is satisfied — see §2-3), and does +not affect the correctness of `ModelResult.State()`/`.Text()`/etc. for a +caller who does use those directly. I'm recording it here as an actionable, +honestly-disclosed finding for a follow-up round rather than a blocker for +this one. Recommend: either add per-turn `saveResponseToState`/ +`saveToolResultsToState`-equivalent checkpoints inside the loop, or add an +explicit compatibility note documenting that `StateAccessor`-only callers +should treat a `CallModel` error as "state as of the last successful pause," +not "state as of the last successful turn." + +## 5. Full test suite + +`go test ./... -v`: 60 tests (57 port + 3 of my probes, later deleted), 1 +skip (`TestE2ESimpleResponsesCall`, requires a live API key), 0 failures. +Read the assertions directly rather than trusting names: +- `TestStateAccessorPersistsWhenResumeRepauses` (the port's own Round-4 test): + pauses on two approval-required calls, resumes approving only one (which + re-pauses), asserts `accessor.saves` increased across that re-pause, then + makes a **third** `CallModel` call from the same accessor and asserts the + first tool did not re-execute. This is real parity coverage, not a + shape-only test — it matches my own independent probes' methodology. +- `TestAwaitingClientToolsStatusForUnresolvedManualTool`, + `TestMixedRegularHITLPreservesRegularOutput`, + `TestMixedApprovalExecutesAutoToolsBeforePausing`, + `TestMixedApprovalRejectStillSendsAutoOutput`: cover the mixed-turn + ordering and manual/HITL pause requirements directly, asserting + `function_call` → `function_call_output` ordering and pre-pause + persistence, not just status strings. +- `TestHooksSessionStartEndDoNotRefireOnApprovalResume`, + `TestPostModelCallTurnTypeDistinguishesResumeFromInitial`: cover the + Round-2/Round-3 fixes; still pass, confirming no regression. +- `TestSDKStreamErrorPropagatesToConsumers`: confirms `Text`, + `FullResponsesStream`, and `ToolStream` consumers all surface a stream + error rather than hanging — required quality "Streaming" satisfied. +- `TestClaudeToFromRoundTripLossless`, + `TestFormatCompatibilityCarriesMetadataAndUnsupportedContent`: confirm the + Claude/Chat round-trip preserves metadata, reasoning, tool use, and + unsupported content — required quality "Compatibility helpers" satisfied. + +## 6. Verifier + +`.upstreamer/scripts/verify.sh`: **PASS, 0 failures** — gofmt clean, `go +build`/`go vet`/`go test` all clean, all 24 required exported symbols +present, hooks manager + versioned state serialization present, go-sdk +pinned at v0.5.4, `service_tier: auto` workaround retained, no leaked +TS/JS artifacts, LICENSE/README/go.mod/scripts/upstream all present. + +## 7. Required Qualities re-checked from scratch (not just state/approval) + +- **Public API completeness**: every symbol in the contract's Required + Public API list (`CallModel`, `NewOpenRouter`, `NewTool`/`MustNewTool`/ + `NewServerTool`, all nine `ModelResult` consumers, `CreateInitialState`/ + `AppendToMessages`/`UpdateState`/`PartitionToolCalls`, all five stop + conditions, `ToClaudeMessage`/`FromClaudeMessages`/`FromChatMessages`/ + `ToChatMessage`, `ExtractUnsupportedContent`/`HasUnsupportedContent`/ + `GetUnsupportedContentSummary`) confirmed present and exported via direct + `grep` against each file (not just the verifier's own list) — + `model_result.go`, `agent.go`, `tool.go`, `conversation_state.go`, + `stop_conditions.go`, `anthropic_compat.go`, `chat_compat.go`, + `stream_transformers.go`. +- **Version honesty**: upstream target commit `680bceb4598f228d3e2ec58e2416e4335cdff059` + has `packages/agent/package.json` version `0.8.0`, matching the + changelog's claim. `HooksManager` and the full nine-hook surface + (`PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, + `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, `PostModelCall`) + are present with typed `OnXxx` registration methods in `hooks_manager.go` — + not a hollow claim. +- **The load-bearing loop**: `TestFollowUpRequestPreservesAccumulatedInputHistory` + and `TestApprovalResumeReplaysFunctionCallBeforeOutput` confirm + `previous_response_id` carry-forward and correct accumulated-history + ordering across turns. +- **Streaming**: confirmed above (§5). +- **Approval/HITL ordering**: confirmed above (§3, §5) — mixed-turn + auto-before-pause ordering and `function_call`→`function_call_output` + replay ordering both hold, independently re-verified with my own probes, + not just by trusting the port's test names. +- **Hooks**: all nine present; `TestHooksStopForceResumeAndAppendPrompt`, + `TestHooksPermissionRequestAllowBypassesApprovalGate`/`Deny.../AskUser...`, + `TestHooksUserPromptSubmitMutatesInitialInput`/`Rejection...` each assert + the specific documented behavior, not just that a handler fired. +- **Compatibility helpers**: confirmed above (§5). +- **Divergences documented**: the six Idiomatic Divergences in + `upstreamer.md` are unaffected by this round. The Round-4-specific + changelog entry and README updates (`git diff README.md`, + `git diff upstreamer-changelog.md`) accurately describe the fix without + overclaiming — the changelog explicitly scopes the fix to "an approval/HITL + resume that itself left calls pending" and does not claim broader + incremental-persistence parity, which is honest given the §4 finding. + The §4 finding itself is **not yet documented** anywhere in + `upstreamer-changelog.md`'s Compatibility Notes — recommend adding it in a + follow-up. +- **Repo-owned files intact**: `git diff --stat HEAD -- .github LICENSE + go.mod` is empty — no changes to CI, license, or the substrate pin. + +## 8. Changelog honesty + +The new changelog bullet ("Fixed a `CallModelInput.StateAccessor` +persistence bug: an approval/HITL resume that itself left calls pending...") +accurately and narrowly describes exactly what was fixed, matches what I +verified in the code, and does not overclaim broader persistence guarantees +it doesn't provide (consistent with the §4 finding still being open). No +overclaiming found. + +--- + +## Verdict: PASS WITH WARNINGS + +The Round-4 targeted fix is real, correctly implemented, and I independently +verified — via fresh code tracing and fresh probes exercising +`StateAccessor` end-to-end (not `ModelResult.State()`) — that the same fix +class already correctly covers the two sibling pause boundaries the task +flagged as most at-risk (`awaiting_hitl`, `awaiting_client_tools`) plus the +mixed-approval pause. All Required Qualities re-checked from scratch pass. +`go test ./... -v` and `.upstreamer/scripts/verify.sh` both pass cleanly. + +One real, reproducible, but out-of-this-round's-scope finding remains open +(§4: no per-turn incremental `StateAccessor` persistence, unlike upstream) — +recorded here as an honest warning and a recommended follow-up, not a +blocker, because it predates this delta, does not violate eval.md's actual +"pause/resume cycle mid-approval" requirement (which is now fully satisfied +across all pause types), and does not affect the correctness of the +primary `ModelResult` consumer API. diff --git a/.upstreamer/eval.md b/.upstreamer/eval.md new file mode 100644 index 0000000..f528aa3 --- /dev/null +++ b/.upstreamer/eval.md @@ -0,0 +1,96 @@ +# Port Parity Eval — Go + +Run after mechanical verification passes, in a **fresh review context**. You are +grading a port you did not write. Do not rely on the converter's reasoning, +summary, or changelog — read the code. + +## Goal + +Decide whether this repository is a faithful, usable Go port of +`@openrouter/agent` at the target upstream commit. The question is not "does it +build" — the verifier already answered that. The question is **"would a user of +this package get the same behavior a TypeScript user gets?"** + +This eval exists because a port can compile, pass its own tests, and still be a +version behind on real behavior. That failure mode is the one to catch. + +## Inputs + +- Contract: `.upstreamer/upstreamer.md` +- Upstream reference: `tmp/upstreamer/upstream/packages/agent/` +- This repo: `*.go` at the repo root +- Last ported commit: `.upstreamer/state.yaml` +- Converter's run log: `.upstreamer/logs/` (read last, and skeptically) + +## Method + +1. Read the contract's Required Public API list. +2. For each entry, find it in this repo **and** find its upstream counterpart. + Compare behavior, not names. +3. Read the upstream delta yourself: + ```bash + git -C tmp/upstreamer/upstream diff .. -- packages/agent/src + ``` + For each behavioral change, verify the port reflects it. A missing edge case + is a real finding. +4. Run the test suite and read what it actually asserts. Tests that assert the + port's own shape rather than upstream behavior are not parity coverage. +5. Prefer reading upstream tests: they encode the behavior contract most + precisely. Check the port covers the same cases. + +## Required Qualities + +**Public API completeness.** Every symbol in the contract's Required Public API +list is present, exported, and reachable from the package's public entry point. + +**Version honesty.** The port's declared/recorded upstream version matches what +was actually ported. A port claiming 0.8.0 while missing `HooksManager` is a FAIL, +not a warning. + +**The load-bearing loop.** Tool execution, multi-turn continuation, and stop +conditions behave as upstream: correct request sequence, accumulated input and +history preserved across turns, `previous_response_id` carried forward. + +**Streaming.** Event order and turn boundaries match. Every consumer sees stream +and transport errors — no consumer hangs or ends silently. Multi-consumer fan-out +works. + +**State.** Serialization round-trips. The version constant exists and a version +mismatch raises/returns an error rather than silently accepting a foreign blob. +State survives a pause/resume cycle mid-approval. + +**Approval / HITL ordering.** Mixed turns are the classic bug: when one turn has +both auto-executable and approval-required calls, auto-executable outputs are +recorded *before* the pause and replayed with the decisions. Regular-tool output +produced before a HITL pause is not lost. Resume order is `function_call` then +`function_call_output`. + +**Hooks.** Lifecycle hooks fire at the right points. Session id is threaded +per-emit so a shared manager is concurrency-safe. `SessionEnd` and drain happen +even on no-tools stream error paths. + +**Compatibility helpers.** Claude/Chat conversion round-trips preserve metadata, +reasoning, tool use, and unsupported content. + +**Divergences are the documented ones.** Every difference from upstream is either +in the contract's Idiomatic Divergences section or recorded as a compatibility +note. An undocumented divergence is a finding. + +**Repo-owned files intact.** CI, license, release config, and package identity +were not rewritten by the port. + +## Verdict + +Return `PASS`, `PASS WITH WARNINGS`, or `FAIL` with concrete findings — file, +symbol, and what specifically differs from upstream. + +- `FAIL` — a required API symbol is missing, a behavioral parity gap exists in the + load-bearing loop / state / approval ordering / hooks, or the declared version + overstates what was ported. +- `PASS WITH WARNINGS` — parity holds on behavior; gaps are cosmetic, type-level, + or already documented as divergences. +- `PASS` — no findings. + +Be specific and be willing to fail. A false PASS is worse than no eval: it +advances `.upstreamer/state.yaml` and the next run skips past the gap, which is +exactly how a port silently falls a version behind. diff --git a/.upstreamer/port.env.example b/.upstreamer/port.env.example new file mode 100644 index 0000000..49b1b2c --- /dev/null +++ b/.upstreamer/port.env.example @@ -0,0 +1,24 @@ +# Upstreamer port credentials + model choice. +# +# Copy to .upstreamer/port.env and fill in. That path is gitignored — never commit it. +# cp .upstreamer/port.env.example .upstreamer/port.env +# +# In CI these same two names come from repo secrets/variables instead of this file +# (see .github/workflows/upstreamer-port.yaml). + +# OpenRouter API key that opencode uses for inference. Starts with sk-or-. +# Create at https://openrouter.ai/settings/keys +# The wrapper writes this into ~/.local/share/opencode/auth.json so headless runs +# work without the interactive `opencode /connect` flow. +OPENROUTER_API_KEY= + +# Model opencode drives the port with, as an OpenRouter model id prefixed with the +# provider key. Overrides the `model:` field in .upstreamer/upstreamer.md. +# +# This is a load-bearing SDK port with a strict parity eval — use a strong coding +# model. Suggested starting point: +OPENCODE_MODEL=openrouter/~anthropic/claude-opus-latest + +# Optional: hard wall-clock cap for one run, in seconds. A full-surface +# reconciliation is much slower than an incremental delta port. +UPSTREAMER_TIMEOUT_SECONDS=7200 diff --git a/.upstreamer/scripts/verify.sh b/.upstreamer/scripts/verify.sh new file mode 100755 index 0000000..3627e86 --- /dev/null +++ b/.upstreamer/scripts/verify.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Mechanical verification for the Go port. Objective checks only — +# judgment-heavy parity review lives in .upstreamer/eval.md. +set -uo pipefail +cd "$(dirname "$0")/../.." + +FAILURES=0 +pass() { echo " PASS: $1"; } +fail() { echo " FAIL: $1"; FAILURES=$((FAILURES + 1)); } + +run() { + local label="$1"; shift + if "$@" >/tmp/verify-out 2>&1; then + pass "$label" + else + fail "$label" + sed 's/^/ /' /tmp/verify-out | tail -40 + fi +} + +echo "=== Verification: go-agent ===" +echo + +echo "-- Toolchain" +if command -v go >/dev/null 2>&1; then + unformatted=$(gofmt -l . 2>/dev/null | grep -v '^tmp/' || true) + [ -z "$unformatted" ] && pass "gofmt clean" || fail "gofmt: $unformatted" + run "go build ./..." go build ./... + run "go vet ./..." go vet ./... + run "go test ./..." go test ./... +else + fail "go not installed (required to build and test this module)" +fi +echo + +# Required public API. This is the parity floor from .upstreamer/upstreamer.md. +# Presence only — the eval judges behavior. Uses `go doc` so it sees the real +# exported surface rather than grepping for source text. +echo "-- Required exported API" +REQUIRED_SYMBOLS=( + CallModel NewOpenRouter NewTool MustNewTool NewServerTool ModelResult + CreateInitialState AppendToMessages UpdateState PartitionToolCalls + StepCountIs HasToolCall MaxTokensUsed MaxCost FinishReasonIs + ToClaudeMessage FromClaudeMessages ToChatMessage FromChatMessages + ExtractUnsupportedContent HasUnsupportedContent GetUnsupportedContentSummary + ToolContextStore ToolEventBroadcaster +) +if command -v go >/dev/null 2>&1; then + if surface=$(go doc -all . 2>/dev/null); then + missing="" + for sym in "${REQUIRED_SYMBOLS[@]}"; do + grep -qE "\b(func|type|var|const)\b.*\b${sym}\b" <<<"$surface" || missing="$missing $sym" + done + [ -z "${missing// /}" ] && pass "all ${#REQUIRED_SYMBOLS[@]} required symbols exported" \ + || fail "missing exported symbols:$missing" + else + fail "go doc failed — cannot verify exported API" + fi +fi +echo + +# Hooks and versioned state are the 0.8.0 parity floor. Named loosely because +# the Go equivalents may not match TS spelling exactly; the eval checks behavior. +echo "-- 0.8.0 parity surface present" +grep -rqE '\bHooksManager\b' --include='*.go' . 2>/dev/null \ + && pass "hooks manager present" \ + || fail "no HooksManager equivalent found (upstream #7/#67 not ported)" +grep -rqiE 'conversation_?state_?version|SerializeConversationState' --include='*.go' . 2>/dev/null \ + && pass "versioned state serialization present" \ + || fail "no versioned conversation-state serialization found (upstream #66 not ported)" +echo + +echo "-- go-sdk substrate pin" +pinned=$(grep -m1 'OpenRouterTeam/go-sdk' go.mod | awk '{print $2}') +[ -n "$pinned" ] && pass "go-sdk pinned at $pinned" || fail "go-sdk not found in go.mod" + +echo "-- service_tier workaround retained" +grep -rq 'service_tier\|ServiceTier' --include='*.go' . 2>/dev/null \ + && pass "service_tier pin retained" \ + || fail "service_tier=auto workaround missing (breaks real request serialization)" +echo + +echo "-- No leaked TypeScript artifacts" +leaked=$(find . -path ./tmp -prune -o -type f \( -name '*.ts' -o -name '*.js' \ + -o -name 'package.json' -o -name 'tsconfig*.json' -o -name 'pnpm-lock.yaml' \) -print 2>/dev/null) +[ -z "$leaked" ] && pass "no TS/JS artifacts" || fail "leaked upstream artifacts: $leaked" + +echo "-- Repo-owned files present" +for f in LICENSE README.md go.mod scripts/upstream; do + [ -e "$f" ] && pass "$f present" || fail "$f missing (port must not delete repo-owned files)" +done +echo + +if [ "$FAILURES" -eq 0 ]; then + echo "=== PASS: 0 failures ===" + exit 0 +fi +echo "=== FAIL: $FAILURES failure(s) ===" +exit 1 diff --git a/.upstreamer/skills/upstreamer-converter/SKILL.md b/.upstreamer/skills/upstreamer-converter/SKILL.md new file mode 100644 index 0000000..6274a8f --- /dev/null +++ b/.upstreamer/skills/upstreamer-converter/SKILL.md @@ -0,0 +1,151 @@ +--- +name: upstreamer-converter +description: Port an upstream source repository into this repository following an upstreamer.md contract. Use when running scripts/upstream, syncing this port with upstream, or reconciling the ported public API surface against the upstream reference. +--- + +# Upstreamer Converter (source port) + +Port an upstream repository into this repository following the contract at +`.upstreamer/upstreamer.md`. The contract is the source of truth; this skill +supplies execution discipline. + +Adapted from `mountgram/upstreamer` (MIT). Key difference: upstream's converter +generates a fresh downstream tree from scratch each time. This one maintains a +**living port of a versioned SDK** — the repo already exists, has consumers, and +publishes to a package registry. Incremental correctness matters more than +regeneration. + +## Step 0: Read the contract + +Read `.upstreamer/upstreamer.md` completely before touching anything. Parse the +frontmatter (`upstream`, `model`). Treat every section as binding: scope, +required public API, naming maps, idiomatic divergences, substrate pins, output +shape, verification. If this skill conflicts with the contract, follow the +contract and note the conflict in the final report. + +## Step 1: Establish the delta + +1. The upstream checkout is already at the target commit. Do not re-clone or + change its checkout. +2. Read `upstream_commit` from `.upstreamer/state.yaml`. This is the last commit + successfully ported *and* verified *and* eval-passed. +3. If a last commit exists and force is 0: + ```bash + git -C tmp/upstreamer/upstream diff --name-status .. + git -C tmp/upstreamer/upstream log --oneline .. + ``` + Scope work to changed files plus their consequences in this repo. Do not + refactor unrelated ported code — a large unreviewable diff is a failed port + even if it is correct. +4. If no last commit exists, or force is 1, reconcile the **entire** required + public API list in the contract against this repo. Report every gap found. +5. Read the changed upstream files in full. Diffs alone hide behavioral intent; + the surrounding code and its tests carry it. + +## Step 2: Port, don't transliterate + +For each upstream change: + +1. Apply the contract's naming map. Never invent a mapping that the contract + does not specify — if a new upstream symbol has no mapping, derive one that + follows the documented convention and **list it in the final report** so it + can be added to the contract. +2. Honor the contract's idiomatic divergences. These are deliberate and + permanent. Do not "fix" them toward the TypeScript shape. +3. Preserve *observable behavior*: call ordering, error surfaces, stream event + sequence and boundaries, state-shape compatibility, pause/resume semantics. + These are the port's actual contract with users. Type-level convenience is + secondary and the contract says where it is allowed to be looser. +4. Where the upstream behavior cannot be reproduced faithfully in this language, + do not silently approximate. Implement the closest honest equivalent and + record it as a compatibility note in `upstreamer-changelog.md`. +5. Never port build tooling, package manifests from upstream, CI, changesets, or + generated output. The contract's drop list is authoritative. + +## Step 3: Never clobber repo-owned files + +This repo is not disposable generated output. These are owned by the repo and +must not be rewritten by a port run unless the contract explicitly says to: + +- `.github/` — CI and release workflows +- `LICENSE` +- `.upstreamer/` — except `state.yaml`, `eval-report.md`, and `logs/` +- `scripts/upstream` +- Release/publish configuration and package identity (name, module path) + +Package **version** and dependency pins change only where the contract's +substrate-pin section directs it. + +## Step 4: Tests + +Ported behavior without a test proves nothing. For every behavioral change: + +1. Add or update deterministic tests in this repo's existing test layout and style. +2. Cover the specific upstream behavior that changed, not just the happy path. + Upstream fixes are usually edge cases — that edge case is the test. +3. Tests must pass without network access or paid credentials. Live/e2e tests + must skip cleanly when credentials are absent. + +## Step 5: Mechanical verification + +Run the verifier and fix what it reports: + +```bash +.upstreamer/scripts/verify.sh +``` + +It checks objective facts: toolchain build, lint, type check, tests, required +public API symbols present, no upstream-language artifacts leaked, declared +version consistent with the ported upstream version. A verifier failure is never +acceptable to hand off. + +## Step 6: Qualitative parity eval + +After mechanical verification passes, run `.upstreamer/eval.md` in a **fresh +subagent or separate review context**. This matters: the converter cannot +usefully grade its own port. The evaluator reads the contract, the upstream +reference, and this repo directly, and returns `PASS`, `PASS WITH WARNINGS`, or +`FAIL` with concrete findings. Write the result to `.upstreamer/eval-report.md`. + +`FAIL` is a blocker. Fix, re-run mechanical verification, re-run the eval. Up to +three focused attempts. + +## Step 7: State, or bankruptcy + +If the verifier passed and the eval returned `PASS` or `PASS WITH WARNINGS`, +write the target commit to `.upstreamer/state.yaml`: + +```yaml +upstream_commit: +``` + +Otherwise **declare bankruptcy**: + +1. Do not touch `.upstreamer/state.yaml`. +2. Write the failed eval result, what you attempted, the remaining blocker, and + the recommended human next action to `.upstreamer/eval-report.md`. +3. Say clearly in the final report that the eval failed. + +A stale state file is the correct outcome for a failed port. It is what makes the +next run retry the same delta instead of skipping past it. Never advance state to +make a run look successful. + +## Final report + +Begin with `Run summary`: + +1. **Upstream changes since last run** — meaningful commits/files/behavior + inspected. For a full reconciliation, say so and summarize the snapshot. +2. **Changes made here** — modules, public API, tests touched. +3. **Why these changes** — tie back to the contract, especially judgment calls. +4. **Verification** — commands run and results. +5. **Parity eval** — result and `eval-report.md` path. + +Then: previous and target upstream commit; incremental vs full; any new naming +mappings you had to derive (so they can be added to the contract); any parity gap +left open and why; any place the contract was ambiguous or wrong. + +Also update `upstreamer-changelog.md` at the repo root with user-facing +release-note bullets. That file is for users of this package: behavior changes, +new API, compatibility notes. Keep commit hashes, `state.yaml`, and verifier +internals out of it. diff --git a/.upstreamer/state.yaml b/.upstreamer/state.yaml new file mode 100644 index 0000000..1410c3a --- /dev/null +++ b/.upstreamer/state.yaml @@ -0,0 +1,9 @@ +# Last upstream commit successfully ported AND verified AND eval-passed. +# 680bceb ports @openrouter/agent 0.7.2 -> 0.8.0: HooksManager lifecycle +# hooks (upstream #7/#67), versioned ConversationState serialization +# (#66), PostModelCall telemetry + SessionEnd usage totals, default +# final-answer directive for AllowFinalResponse (#68), and +# awaiting_client_tools (#64). Eval verdict: PASS WITH WARNINGS — see +# .upstreamer/eval-report.md. +# Only scripts/upstream runs should change this. +upstream_commit: 680bceb4598f228d3e2ec58e2416e4335cdff059 diff --git a/.upstreamer/upstreamer.md b/.upstreamer/upstreamer.md new file mode 100644 index 0000000..cc4174f --- /dev/null +++ b/.upstreamer/upstreamer.md @@ -0,0 +1,216 @@ +--- +upstream: OpenRouterTeam/typescript-agent +downstream: OpenRouterTeam/go-agent +model: openrouter/~anthropic/claude-opus-latest +--- + +# Go Port Contract — `@openrouter/agent` → `github.com/OpenRouterTeam/go-agent` + +This repository is an idiomatic Go port of the OpenRouter TypeScript Agent SDK +(`@openrouter/agent`, in `packages/agent/` upstream). TypeScript is the +**reference spec**. Behavioral divergence is a bug unless it appears in the +Idiomatic Divergences section below. + +## Scope + +Port **only** `packages/agent/` from upstream. + +Explicitly out of scope: + +- `packages/mcp/` (`@openrouter/mcp`). Not ported. Do not begin porting it as a + side effect of a sync. Adding it is a deliberate contract change. +- Upstream JS/TS infrastructure: `package.json`, `pnpm-lock.yaml`, `tsconfig*`, + `turbo.json`, `biome.json`, vitest config, `.changeset/`, `.github/`, `esm/` + build output, `node_modules/`. +- Upstream README/docs prose. This repo's `README.md` is its own product surface. + +## Substrate Pin + +The port sits on the official OpenRouter Go SDK and must not reimplement HTTP, +auth, retries, or generated model types. + +- Target: `github.com/OpenRouterTeam/go-sdk v0.5.4` (current `go.mod` pin). +- `CallModel` sends through `client.Beta.Responses.Send` — the Responses API. +- Request, response, item, stream, usage, and tool union types alias the + generated SDK's component types where possible. + +Do **not** bump `go-sdk` on your own initiative. If an upstream change *requires* +a newer `go-sdk`, stop and report it as a blocker. + +Known workaround to preserve: the Responses request pins `service_tier` to +`auto`, working around a marshalling bug in the current Go SDK that emitted an +invalid `service_tier` token. Do not remove this until the SDK pin moves past the +fix. + +## Module Version + +Go modules version by git tag, not by a manifest field. Do not invent a version +field. Record the ported `@openrouter/agent` version in +`upstreamer-changelog.md` and let release tagging happen separately. + +## Required Public API + +Every symbol below must be exported and behaviorally faithful. This list is the +parity floor — the verifier enforces presence, the eval enforces behavior. + +Entry point and client: +- `CallModel(ctx, client, CallModelInput)`, `NewOpenRouter(OpenRouterOptions)` + +Tools: +- `NewTool[T](ToolConfig[T])`, `MustNewTool[T]`, `NewServerTool(ServerToolConfig)` +- Regular, generator, manual, HITL, and server tool shapes; JSON Schema + generation and sanitization; typed input decoding; tool-event streaming + +Result consumption (`ModelResult`): +- `Text(ctx)`, `Response(ctx)`, `TextStream(ctx)`, `ReasoningStream(ctx)`, + `ToolStream(ctx)`, `ToolCallsStream(ctx)`, `ToolCalls(ctx)`, + `FullResponsesStream(ctx)`, `NewMessagesStream(ctx)` +- Turn boundary events, state access, cancellation +- SDK stream and transport errors must surface through **every** consumer, never + hang or end silently + +State: +- `CreateInitialState`, `AppendToMessages`, `UpdateState`, `PartitionToolCalls` +- **Versioned serialization contract** (upstream #66): serialize/deserialize + conversation state, a `CONVERSATION_STATE_VERSION` equivalent, and Go error + values for invalid and version-mismatched state. Round-trip stable; mismatch + returns an error rather than silently accepting. + +Stop conditions: +- `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs` + +Lifecycle hooks (upstream #7, #67 — the 0.8.0 headline): +- A `HooksManager` equivalent with options, hook name constants, hook + definition/entry/handler/registry types, tool matchers, `PostModelCall` + telemetry, `SessionEnd` usage totals +- Session-id threading per emit so a shared manager is concurrency-safe +- `SessionEnd` / drain guaranteed on no-tools stream error paths + +Approval / HITL: +- `ApproveToolCalls` / `RejectToolCalls` on `CallModelInput`; `OnResponseReceived` + on `ToolConfig[T]` +- Mixed-turn ordering: when one model turn contains both auto-executable and + approval-required calls, auto-executable tools run and their outputs are + recorded **before** the pause; those outputs replay alongside the decisions +- Mixed regular + HITL turns must not lose work: an already-produced regular-tool + output is persisted and replayed in correct `function_call` → + `function_call_output` order on resume +- `previous_response_id` carried forward +- Unresolved manual tool calls persist as an `awaiting_client_tools` equivalent + (upstream #64) + +Final-turn control: +- `AllowFinalResponse` on `CallModelInput`, plus the default final-answer + directive for bare `true` (upstream #68) + +Compatibility: +- `ToClaudeMessage` → `(ClaudeMessage, error)`; `FromClaudeMessages` and + `FromChatMessages` → `([]…, error)`; `ToChatMessage` is a direct value + conversion +- `ToClaudeMessage` emits the full Claude assistant shape: `usage` block + (`input_tokens`, `output_tokens`, `cache_creation_input_tokens`, + `cache_read_input_tokens`), mapped `stop_reason` + (`tool_use`/`end_turn`/`max_tokens`), `stop_sequence`, reasoning as `thinking` + blocks, top-level `unsupported_content`. A `ToClaudeMessage` → + `FromClaudeMessages` round trip preserves metadata, reasoning, tool use, and + unsupported content. +- `ExtractUnsupportedContent(ClaudeMessage, originalType)`, + `HasUnsupportedContent(ClaudeMessage)`, + `GetUnsupportedContentSummary(ClaudeMessage) map[string]int` + +Support: +- Tool context storage, tool event broadcasting, reusable fan-out streams, + next-turn params, async params, turn context, stream guards, middleware-based + request/response interception + +## Naming Map + +TypeScript `camelCase` → Go `PascalCase` for exported identifiers. + +Established mappings — do not re-derive: + +| TypeScript | Go | +| --- | --- | +| `callModel(...)` | `CallModel(ctx, client, CallModelInput)` | +| `new OpenRouter(...)` | `NewOpenRouter(OpenRouterOptions)` | +| `tool(...)` | `NewTool[T](ToolConfig[T])` / `MustNewTool[T]` | +| `serverTool(...)` | `NewServerTool(ServerToolConfig)` | +| `result.getText()` | `result.Text(ctx)` | +| `result.getResponse()` | `result.Response(ctx)` | +| `result.getTextStream()` | `result.TextStream(ctx)` | +| `result.getReasoningStream()` | `result.ReasoningStream(ctx)` | +| `result.getToolStream()` | `result.ToolStream(ctx)` | +| `result.getToolCallsStream()` | `result.ToolCallsStream(ctx)` | +| `result.getToolCalls()` | `result.ToolCalls(ctx)` | +| `result.getFullResponsesStream()` | `result.FullResponsesStream(ctx)` | +| `result.getNewMessagesStream()` | `result.NewMessagesStream(ctx)` | +| `createInitialState()` | `CreateInitialState()` | +| `appendToMessages(...)` | `AppendToMessages(...)` | +| `updateState(...)` | `UpdateState(...)` | +| `partitionToolCalls(...)` | `PartitionToolCalls(...)` | +| `stepCountIs`, `hasToolCall`, `maxTokensUsed`, `maxCost`, `finishReasonIs` | `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs` | +| `toClaudeMessage` / `fromClaudeMessages` | `ToClaudeMessage` `(ClaudeMessage, error)` / `FromClaudeMessages` `([]…, error)` | +| `toChatMessage` / `fromChatMessages` | `ToChatMessage` / `FromChatMessages` `([]…, error)` | +| `extractUnsupportedContent(message, type)` | `ExtractUnsupportedContent(ClaudeMessage, originalType)` | +| `hasUnsupportedContent(message)` | `HasUnsupportedContent(ClaudeMessage)` | +| `getUnsupportedContentSummary(message)` | `GetUnsupportedContentSummary(ClaudeMessage) map[string]int` | +| `approveToolCalls` / `rejectToolCalls` | `ApproveToolCalls` / `RejectToolCalls` on `CallModelInput` | +| `allowFinalResponse` | `AllowFinalResponse` on `CallModelInput` | +| HITL `onResponseReceived` | `OnResponseReceived` on `ToolConfig[T]` | + +If upstream adds a symbol with no mapping here, follow the convention and +**report the new mapping** so it can be added to this table. + +## Idiomatic Divergences + +Deliberate and permanent. Do not converge these toward TypeScript. + +1. **`context.Context` for cancellation**, not `AbortSignal`. Every blocking + consumer takes a `ctx`. +2. **Explicit errors, not exceptions.** Operations that can genuinely fail return + `(T, error)`. A real encoding failure is returned or wrapped — never degraded + into a placeholder value. +3. **Struct tags + `invopop/jsonschema`** replace Zod. Schema support validates + common object/string/number/array/required cases; highly specialized draft + features stay at the dynamic JSON boundary. +4. **`SDKHooks` becomes public middleware** on `OpenRouterOptions`, installed via + `openrouter.WithClient`, because the Go SDK does not expose its generated + internal hooks package. +5. **Generics where TypeScript used conditional types.** `NewTool[T]` carries the + input type. Go cannot express upstream's full per-tool narrowing; runtime + parity wins. +6. **Channels for streams.** Fan-out uses the reusable-stream helper; a stream + error must reach every consumer. + +## Output Shape + +This repo IS the downstream. Flat package at the repo root, `package agent`: + +```text +*.go # port, one file per upstream lib module +*_test.go # tests alongside +go.mod / go.sum # substrate pin +README.md # this package's own docs +upstreamer-changelog.md # user-facing port notes +``` + +File naming follows the existing layout: upstream `lib/tool-executor.ts` → +`tool_executor.go`. Keep that correspondence for new files so the port stays +navigable against the reference. Do not introduce nested packages without a +contract change — the flat single-package shape is intentional. + +## Verification + +`.upstreamer/scripts/verify.sh` must pass: `gofmt` check, `go build ./...`, +`go vet ./...`, `go test ./...`, plus the required-public-API presence check and +no-TS-artifact check. + +Then `.upstreamer/eval.md` must return PASS or PASS WITH WARNINGS from a fresh +review context before state advances. + +## Final Report + +Include: upstream delta, files and exported API touched, tests added, any new +naming mappings derived, any parity gap deliberately left open with reasoning, +verifier result, eval result and report path, and whether the `go-sdk` substrate +pin blocked anything. diff --git a/PORTING.md b/PORTING.md new file mode 100644 index 0000000..fb4db6f --- /dev/null +++ b/PORTING.md @@ -0,0 +1,117 @@ +# Porting + +This package is a **port** of the OpenRouter TypeScript Agent SDK +(`@openrouter/agent`). TypeScript is the reference spec; this repo tracks it +automatically using [Upstreamer](https://github.com/mountgram/upstreamer) (MIT). + +Behavioral divergence from the TypeScript reference is a **bug**, unless it is +listed in the Idiomatic Divergences section of `.upstreamer/upstreamer.md`. + +## How it works + +``` +typescript-agent publishes @openrouter/agent to npm + │ + │ repository_dispatch: openrouter-agent-published + ▼ +.github/workflows/upstreamer-port.yaml + │ + ▼ +scripts/upstream + │ 1. fetch upstream, resolve target commit + │ 2. compare against .upstreamer/state.yaml — skip if unchanged + │ 3. opencode runs the port against .upstreamer/upstreamer.md + │ 4. .upstreamer/scripts/verify.sh (mechanical gate) + │ 5. .upstreamer/eval.md (parity gate, fresh context) + │ 6. advance state.yaml — ONLY if both gates pass + ▼ + Pull request (never a direct push to main) +``` + +A weekly cron backs up the dispatch in case one is missed, and +`workflow_dispatch` allows a manual run against any ref. + +## The contract is the product + +`.upstreamer/upstreamer.md` is the durable artifact — it defines scope, the +required public API, naming maps, permanent idiomatic divergences, and the +substrate pin. The ported source is an *output* of that contract. + +So when the port gets something wrong, **fix the contract**, not just the +generated code. A code-only fix gets re-broken on the next sync; a contract fix +holds. + +## Files + +| Path | What | +|------|------| +| `.upstreamer/upstreamer.md` | The rewrite contract. Binding. | +| `.upstreamer/state.yaml` | Last commit ported *and* verified *and* eval-passed. | +| `.upstreamer/scripts/verify.sh` | Mechanical gate: build, lint, types, tests, required API. | +| `.upstreamer/eval.md` | Parity gate: fresh-context behavioral review. | +| `.upstreamer/eval-report.md` | Latest eval result, or a bankruptcy report. | +| `.upstreamer/skills/upstreamer-converter/` | Execution discipline for the porting agent. | +| `.upstreamer/port.env` | Local secrets. **Gitignored.** | +| `scripts/upstream` | The wrapper. | + +## Two gates, and why state matters + +**Mechanical** (`verify.sh`) — objective: does it build, lint, type-check, pass +tests, and export every symbol the contract requires. + +**Parity** (`eval.md`) — judgment, run in a fresh context that reads the upstream +reference directly: does it actually *behave* like upstream. This is the gate that +catches a port which compiles cleanly while sitting a version behind on real +behavior. + +If either gate fails the run **declares bankruptcy**: `state.yaml` is left +untouched and `.upstreamer/eval-report.md` explains why. A stale state file is the +correct outcome for a failed port — it makes the next run retry the same delta +instead of skipping past the gap. The workflow labels such a PR `eval-failed` and +marks the title `do not merge`. + +Never hand-edit `state.yaml` to make a run look successful. + +## Running it locally + +```bash +# one-time +bun install -g opencode-ai # or: npm install -g opencode-ai +cp .upstreamer/port.env.example .upstreamer/port.env +# then edit .upstreamer/port.env and fill in OPENROUTER_API_KEY + OPENCODE_MODEL + +./scripts/upstream # sync if upstream changed +./scripts/upstream --force # re-run after editing the contract +./scripts/upstream --ref v0.8.0 # port a specific upstream ref +./scripts/upstream -- --print-logs # pass args through to opencode +``` + +`--force` is the escape hatch for a changed contract with unchanged upstream. +Expect to use it often while the contract is still settling. + +## Credentials + +Two values, same names locally and in CI: + +| Name | Where | What | +|------|-------|------| +| `OPENROUTER_API_KEY` | local: `.upstreamer/port.env` · CI: repo **secret** | `sk-or-…` key opencode uses for inference | +| `OPENCODE_MODEL` | local: `.upstreamer/port.env` · CI: repo **variable** | e.g. `openrouter/~anthropic/claude-opus-latest` | + +The wrapper writes the key into `~/.local/share/opencode/auth.json` so headless +runs work without the interactive `opencode /connect` flow. + +This is a load-bearing SDK port behind a strict parity eval — use a strong coding +model. `OPENCODE_MODEL` overrides the `model:` field in the contract, so you can +change models without a code change. + +## Reviewing a port PR + +Review it as a *port*, not a normal diff: + +1. Check `.upstreamer/eval-report.md` first. If state did not advance, stop. +2. Read the upstream delta yourself for anything load-bearing — the tool loop, + state serialization, approval/HITL ordering, hooks, streaming. +3. Confirm new tests assert *upstream behavior*, not merely the port's own shape. +4. Any new naming mapping the run derived should be promoted into the contract's + naming table. diff --git a/README.md b/README.md index 617dec0..90e0693 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,9 @@ go-agent is an idiomatic Go port of `@openrouter/agent`: a small orchestration layer on top of the official OpenRouter Go SDK for tool execution, streaming response consumption, multi-turn state, approval gates, tool context, stop conditions, and Claude/Chat format compatibility. +> **This package is a port.** `@openrouter/agent` (TypeScript) is the reference +> spec; this repo is kept in sync automatically. See [PORTING.md](PORTING.md). + ## Install ```bash @@ -77,11 +80,36 @@ Tool input schemas are generated from Go structs with `invopop/jsonschema`, sani ## State, Approval, And Context -Use `CreateInitialState`, `AppendToMessages`, `UpdateState`, `PartitionToolCalls`, and `StateAccessor` to persist multi-turn conversations. Approval checks can be configured per tool or per call; resume by passing `ApproveToolCalls` or `RejectToolCalls` with the saved state. go-agent replays the original `function_call` before its `function_call_output` and carries `previous_response_id`, matching the TypeScript resume shape. `ToolContextStore` provides concurrency-safe per-tool and shared context with snapshot, get, set, merge, and subscribe operations. +Use `CreateInitialState`, `AppendToMessages`, `UpdateState`, `PartitionToolCalls`, and `StateAccessor` to persist multi-turn conversations. Approval checks can be configured per tool or per call; resume by passing `ApproveToolCalls` or `RejectToolCalls` with the saved state. go-agent replays the original `function_call` before its `function_call_output` and carries `previous_response_id`, matching the TypeScript resume shape. `ToolContextStore` provides concurrency-safe per-tool and shared context with snapshot, get, set, merge, and subscribe operations. Unresolved manual (client-executed) tool calls pause the run with `ConversationStatusAwaitingClientTools`, distinct from `ConversationStatusAwaitingHITL`. + +Use `SerializeConversationState` / `DeserializeConversationState` for a versioned, durable-storage-friendly encoding of `ConversationState` (`ConversationStateVersion`). A version mismatch returns `*UnsupportedStateVersionError`; a malformed blob returns `*InvalidStateError` — callers get an explicit error instead of a silently misinterpreted state. ## Stop Conditions -Use `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs`, and `IsStopConditionMet`. Multiple stop conditions are ORed, matching the TypeScript package. `MaxTokensUsed` compares cumulative `total_tokens` only. Set `AllowFinalResponse` to execute pending tools at the stop boundary and make one final no-tools request for a closing assistant message. +Use `StepCountIs`, `HasToolCall`, `MaxTokensUsed`, `MaxCost`, `FinishReasonIs`, and `IsStopConditionMet`. Multiple stop conditions are ORed, matching the TypeScript package. `MaxTokensUsed` compares cumulative `total_tokens` only. + +`AllowFinalResponse` is **default-on**: when a stop condition halts the loop mid-tool-call, go-agent executes the pending tool calls and issues one more request with `tool_choice: "none"` (tools stay in the request so the prompt-cache prefix survives) so the run ends with a natural-language answer. Omitting the option, or setting it to `true`, appends `agent.DefaultFinalResponseDirective` as a final user message; a non-empty string overrides that wording; `""` forbids tool calls without appending any message; `false` disables the forced final turn entirely. + +## Lifecycle Hooks + +`HooksManager` (`agent.NewHooksManager`) supports the nine built-in lifecycle hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, and `PostModelCall` — plus fully custom hooks via the generic `agent.On`/`agent.Emit`. Register handlers with the typed `OnXxx` methods (e.g. `manager.OnPreToolUse(...)`) and pass the manager on `CallModelInput.Hooks`: + +```go +hooks := agent.NewHooksManager() +hooks.OnPreToolUse(agent.HookEntry[agent.PreToolUsePayload, agent.PreToolUseResult]{ + Handler: func(payload agent.PreToolUsePayload, hctx agent.LifecycleHookContext) (agent.HookHandlerResult[agent.PreToolUseResult], error) { + return agent.VoidResult[agent.PreToolUseResult](), nil + }, +}) + +result, err := agent.CallModel(ctx, client, agent.CallModelInput{ + Model: "openai/gpt-4o-mini", + Input: "...", + Hooks: hooks, +}) +``` + +`SessionStart`/`SessionEnd` fire once per non-resuming run (`SessionEnd` carries aggregated token usage across that run's model calls); an approval/HITL resume call is a continuation of the same session and does not get its own pair. `PostModelCall` fires once per model response, tagged `initial`/`resume`/`tool_round`/`final`/`retry`. `PreToolUse`/`PostToolUse`/`PostToolUseFailure` fire around every client-tool execution path, including during a resume. `PermissionRequest` fires before the human-approval pause and can `allow`/`deny`/`ask_user` (default) a gated call. `Stop` fires whenever a stop condition halts the loop mid-tool-call and can force a resume and/or inject a prompt. `UserPromptSubmit` fires once per non-resuming run against the initial user input and can mutate or reject it. Session identity is threaded per emit, so one `HooksManager` is safe to share across concurrent `CallModel` runs. Call `manager.Drain()` to await fire-and-forget handler work; go-agent always drains on every exit path, including no-tools error paths. ## Format Compatibility diff --git a/async_params.go b/async_params.go index c6e9908..e56158a 100644 --- a/async_params.go +++ b/async_params.go @@ -9,12 +9,22 @@ import ( type DynamicValue[T any] func(context.Context, TurnContext) (T, error) type CallModelInput struct { - Model string - ModelFunc DynamicValue[string] - Input any - Tools []Tool - StopWhen []StopCondition - AllowFinalResponse any + Model string + ModelFunc DynamicValue[string] + Input any + Tools []Tool + StopWhen []StopCondition + AllowFinalResponse any + // StrictFinalResponse: when true, skips the one-shot retry that would + // otherwise fire when a completed run's final response has no output + // after at least one tool round. Default false: that empty final is + // retried once via a `toolChoice: none` resend. Either way an empty + // final response is never a hard error here — unlike upstream's + // `validateFinalResponse`, an empty `Output` items array is not on its + // own a reliable invalidity signal against go-sdk's response shape, + // which also carries a separate `OutputText` convenience field. See + // upstreamer-changelog.md's compatibility note. + StrictFinalResponse bool State *ConversationState StateAccessor StateAccessor Context ContextInput @@ -28,6 +38,9 @@ type CallModelInput struct { AfterTurn func(context.Context, TurnContext, StepResult) error AdditionalInstructions string InstructionsFunc DynamicValue[string] + // Hooks accepts either a *HooksManager or an InlineHookConfig. See + // ResolveHooks. nil means no hooks. + Hooks any } type CallModelInputWithState = CallModelInput diff --git a/conversation_state.go b/conversation_state.go index 51cd4d2..887ef1b 100644 --- a/conversation_state.go +++ b/conversation_state.go @@ -21,7 +21,134 @@ func GenerateConversationID() string { func CreateInitialState() ConversationState { now := time.Now().UnixMilli() - return ConversationState{ID: GenerateConversationID(), Messages: []components.InputsUnion1{}, Status: ConversationStatusInProgress, CreatedAt: now, UpdatedAt: now} + return ConversationState{Version: ConversationStateVersion, ID: GenerateConversationID(), Messages: []components.InputsUnion1{}, Status: ConversationStatusInProgress, CreatedAt: now, UpdatedAt: now} +} + +// UnsupportedStateVersionError is returned by DeserializeConversationState +// when a state blob's version is not supported by this SDK build. +type UnsupportedStateVersionError struct { + Found int + Supported []int +} + +func (e *UnsupportedStateVersionError) Error() string { + return fmt.Sprintf("unsupported ConversationState version %d; supported version(s): %s", e.Found, joinInts(e.Supported)) +} + +// InvalidStateError is returned by DeserializeConversationState when the +// input is not well-formed JSON, or is missing/has the wrong type for a +// required ConversationState field. +type InvalidStateError struct { + Message string +} + +func (e *InvalidStateError) Error() string { return e.Message } + +func joinInts(vals []int) string { + out := "" + for i, v := range vals { + if i > 0 { + out += ", " + } + out += fmt.Sprintf("%d", v) + } + return out +} + +// SerializeConversationState serializes state to a stable JSON string for +// durable storage. Guarantees the version field is present (injects +// ConversationStateVersion when the input state lacks one). Treat the +// returned JSON as opaque: round-trip via SerializeConversationState / +// DeserializeConversationState rather than introspecting the shape directly. +// +// Compat policy: additive field changes within a major version. On version +// bumps, migrations run inside DeserializeConversationState. The +// StateAccessor Load/Save contract is unchanged — these helpers are opt-in. +func SerializeConversationState(state ConversationState) (string, error) { + toEncode := state + if toEncode.Version == 0 { + toEncode.Version = ConversationStateVersion + } + b, err := json.Marshal(toEncode) + if err != nil { + return "", fmt.Errorf("serialize conversation state: %w", err) + } + return string(b), nil +} + +// DeserializeConversationState parses and validates a previously serialized +// ConversationState. +// +// Accepts version-less legacy blobs and states with version 1, normalizing +// both to ConversationStateVersion. Returns *UnsupportedStateVersionError for +// any other version (fail loudly so stores don't silently misinterpret +// future shapes) and *InvalidStateError for malformed JSON or missing +// required fields (id, messages, status, createdAt, updatedAt). +// +// Compat policy: absence of version means v1. Migrations for future +// versions happen here; callers should treat the JSON as opaque. +func DeserializeConversationState(data string) (ConversationState, error) { + var raw map[string]any + if err := json.Unmarshal([]byte(data), &raw); err != nil { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf("invalid ConversationState JSON: %v", err)} + } + + // Version check runs before structural validation: a future-version blob + // may have a different shape, and it must fail with + // UnsupportedStateVersionError rather than a misleading InvalidStateError + // about v1 fields. + if rawVersion, present := raw["Version"]; present { + version, ok := rawVersion.(float64) + if !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState field "Version" must be a number when present (got %s)`, describeJSONType(rawVersion))} + } + if int(version) != ConversationStateVersion { + return ConversationState{}, &UnsupportedStateVersionError{Found: int(version), Supported: []int{ConversationStateVersion}} + } + } + + if _, ok := raw["ID"].(string); !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState missing or invalid field "ID" (expected string, got %s)`, describeJSONType(raw["ID"]))} + } + if _, ok := raw["Messages"].([]any); !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState missing or invalid field "Messages" (expected array, got %s)`, describeJSONType(raw["Messages"]))} + } + if _, ok := raw["Status"].(string); !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState missing or invalid field "Status" (expected string, got %s)`, describeJSONType(raw["Status"]))} + } + if _, ok := raw["CreatedAt"].(float64); !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState missing or invalid field "CreatedAt" (expected number, got %s)`, describeJSONType(raw["CreatedAt"]))} + } + if _, ok := raw["UpdatedAt"].(float64); !ok { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf(`ConversationState missing or invalid field "UpdatedAt" (expected number, got %s)`, describeJSONType(raw["UpdatedAt"]))} + } + + var state ConversationState + if err := json.Unmarshal([]byte(data), &state); err != nil { + return ConversationState{}, &InvalidStateError{Message: fmt.Sprintf("invalid ConversationState JSON: %v", err)} + } + state.Version = ConversationStateVersion + return state, nil +} + +func describeJSONType(value any) string { + if value == nil { + return "null" + } + switch value.(type) { + case []any: + return "array" + case map[string]any: + return "object" + case string: + return "string" + case float64: + return "number" + case bool: + return "boolean" + default: + return fmt.Sprintf("%T", value) + } } func UpdateState(state ConversationState, mutate func(*ConversationState)) ConversationState { diff --git a/conversation_state_test.go b/conversation_state_test.go index 15682bf..35798c2 100644 --- a/conversation_state_test.go +++ b/conversation_state_test.go @@ -2,6 +2,7 @@ package agent import ( "context" + "errors" "testing" "github.com/OpenRouterTeam/go-sdk/models/components" @@ -51,3 +52,98 @@ func TestPartitionToolCallsApproval(t *testing.T) { t.Fatalf("expected approval partition to hold pending call") } } + +func TestConversationStateSerializationRoundTrip(t *testing.T) { + state := CreateInitialState() + if state.Version != ConversationStateVersion { + t.Fatalf("expected fresh state to carry ConversationStateVersion, got %d", state.Version) + } + + json, err := SerializeConversationState(state) + if err != nil { + t.Fatal(err) + } + restored, err := DeserializeConversationState(json) + if err != nil { + t.Fatal(err) + } + if restored.Version != ConversationStateVersion || restored.ID != state.ID || restored.Status != state.Status { + t.Fatalf("round trip mismatch: got %+v, want %+v", restored, state) + } +} + +func TestConversationStateSerializationLegacyVersionless(t *testing.T) { + legacy := `{"ID":"conv_legacy","Messages":[],"Status":"complete","CreatedAt":1600000000000,"UpdatedAt":1600000000100}` + restored, err := DeserializeConversationState(legacy) + if err != nil { + t.Fatal(err) + } + if restored.Version != ConversationStateVersion { + t.Fatalf("expected version-less legacy blob to normalize to version %d, got %d", ConversationStateVersion, restored.Version) + } + if restored.ID != "conv_legacy" || restored.Status != ConversationStatusComplete { + t.Fatalf("unexpected restored state: %+v", restored) + } +} + +func TestConversationStateSerializationUnsupportedVersion(t *testing.T) { + future := `{"Version":2,"ID":"conv_future","Messages":[],"Status":"in_progress","CreatedAt":1,"UpdatedAt":1}` + _, err := DeserializeConversationState(future) + var verErr *UnsupportedStateVersionError + if !errors.As(err, &verErr) { + t.Fatalf("expected *UnsupportedStateVersionError, got %v", err) + } + if verErr.Found != 2 || len(verErr.Supported) != 1 || verErr.Supported[0] != ConversationStateVersion { + t.Fatalf("unexpected error details: %+v", verErr) + } + + // The version guard must run before structural validation: a reshaped + // future blob should still fail with UnsupportedStateVersionError, not a + // misleading InvalidStateError about missing v1 fields. + reshaped := `{"Version":2,"conversationId":"conv_future","history":[]}` + _, err = DeserializeConversationState(reshaped) + if !errors.As(err, &verErr) { + t.Fatalf("expected *UnsupportedStateVersionError for reshaped future blob, got %v", err) + } +} + +func TestConversationStateSerializationInvalidShapes(t *testing.T) { + cases := []struct { + name string + json string + }{ + {"missing id", `{"Messages":[],"Status":"in_progress"}`}, + {"messages not array", `{"ID":"x","Messages":"nope","Status":"in_progress"}`}, + {"missing status", `{"ID":"x","Messages":[]}`}, + {"missing createdAt", `{"ID":"x","Messages":[],"Status":"in_progress","UpdatedAt":1}`}, + {"missing updatedAt", `{"ID":"x","Messages":[],"Status":"in_progress","CreatedAt":1}`}, + {"not json", `not-json{`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := DeserializeConversationState(tc.json) + var invErr *InvalidStateError + if !errors.As(err, &invErr) { + t.Fatalf("expected *InvalidStateError, got %v", err) + } + }) + } +} + +func TestConversationStateSerializationInjectsVersion(t *testing.T) { + versionless := ConversationState{ID: "conv_no_ver", Messages: []components.InputsUnion1{}, Status: ConversationStatusInProgress, CreatedAt: 42, UpdatedAt: 43} + if versionless.Version != 0 { + t.Fatalf("expected zero-value Version on a hand-built state") + } + out, err := SerializeConversationState(versionless) + if err != nil { + t.Fatal(err) + } + restored, err := DeserializeConversationState(out) + if err != nil { + t.Fatal(err) + } + if restored.Version != ConversationStateVersion { + t.Fatalf("expected serialize to inject version %d, got %d", ConversationStateVersion, restored.Version) + } +} diff --git a/hooks_emit.go b/hooks_emit.go new file mode 100644 index 0000000..e90ba60 --- /dev/null +++ b/hooks_emit.go @@ -0,0 +1,262 @@ +package agent + +import ( + "log" + "time" +) + +// erasedHandler is a type-erased HookHandler, used internally so a single +// HooksManager registry can hold entries of many different (P, R) type pairs. +// Payload/result values cross this boundary as `any`; the type-safe surface +// is On/Off/EmitXxx in hooks_manager.go. +type erasedHandler func(payload any, hctx LifecycleHookContext) (erasedOutcome, error) + +type erasedOutcome struct { + Result any + HasResult bool + Async *AsyncOutput +} + +type erasedFilter func(any) bool + +type erasedEntry struct { + handler erasedHandler + matcher ToolMatcher + filter erasedFilter + // orig holds the original typed HookHandler[P, R], boxed as `any`, so Off + // can attempt a best-effort reflect.Pointer() match against it. + orig any +} + +// hookBehavior is the erased, data-driven per-hook mutation/block table, +// mirroring upstream's HOOK_BEHAVIOR. Hooks absent from hookBehaviors are +// observation-only: their handlers' results are collected but never alter +// the payload or short-circuit the chain. Custom hooks never participate. +type hookBehavior struct { + // mutate applies a result's mutation field onto payload, returning the + // (possibly new) payload and whether a mutation was actually applied. + mutate func(payload any, result any) (any, bool) + // isBlocked reports whether result triggers this hook's short-circuit, + // and the reason (if any) to surface to the caller. + isBlocked func(result any) (bool, string) +} + +var hookBehaviors = map[string]hookBehavior{ + string(HookNamePreToolUse): { + mutate: func(payload any, result any) (any, bool) { + p, ok := payload.(PreToolUsePayload) + r, ok2 := result.(PreToolUseResult) + if !ok || !ok2 || r.MutatedInput == nil { + return payload, false + } + p.ToolInput = r.MutatedInput + return p, true + }, + isBlocked: func(result any) (bool, string) { + r, ok := result.(PreToolUseResult) + if !ok || !r.Blocked() { + return false, "" + } + reason := r.BlockReason + if reason == "" { + reason = "Blocked by PreToolUse hook" + } + return true, reason + }, + }, + string(HookNameUserPromptSubmit): { + mutate: func(payload any, result any) (any, bool) { + p, ok := payload.(UserPromptSubmitPayload) + r, ok2 := result.(UserPromptSubmitResult) + if !ok || !ok2 || r.MutatedPrompt == nil { + return payload, false + } + p.Prompt = *r.MutatedPrompt + return p, true + }, + isBlocked: func(result any) (bool, string) { + r, ok := result.(UserPromptSubmitResult) + if !ok || !r.Rejected() { + return false, "" + } + reason := r.RejectReason + if reason == "" { + reason = "Prompt rejected by hook" + } + return true, reason + }, + }, +} + +// chainOptions configures one executeHandlerChain call. +type chainOptions struct { + hookName string + throwOnHandlerError bool + toolName string + hasToolName bool + onAsyncTimeout func() +} + +// chainOutcome is executeHandlerChain's erased result, converted to a typed +// EmitResult by the caller (HooksManager.emitAny's typed wrappers). +type chainOutcome struct { + results []any + pending []<-chan struct{} + finalPayload any + blocked bool + blockReason string + mutated bool +} + +// executeHandlerChain runs entries sequentially against initialPayload. +// +// Supports: +// - ToolMatcher and filter-based skipping (matcher fails closed: an entry +// with a matcher and no options.toolName is skipped) +// - Sync results collected into results +// - Async fire-and-forget via a returned AsyncOutput — the handler's Work +// channel is tracked in pending without being awaited here +// - Per-hook mutation piping and block short-circuit, driven by hookBehaviors +// - Cooperative abort via hctx.Ctx: the chain checks ctx.Err() between +// handlers and bails out when canceled, so AbortInflight has a +// deterministic effect on the chain itself +// +// Payload isolation: results/mutation are computed by-value on Go structs +// (not shared references), so mutation piping never mutates the caller's +// payload — a stronger guarantee than upstream's "top-level-only" note (which +// exists there because JS objects are mutable references; Go struct copies +// are not). +func executeHandlerChain(entries []*erasedEntry, initialPayload any, hctx LifecycleHookContext, opts chainOptions) (chainOutcome, error) { + var results []any + var pending []<-chan struct{} + currentPayload := initialPayload + blocked := false + blockReason := "" + mutated := false + + behavior, hasBehavior := hookBehaviors[opts.hookName] + + for i, entry := range entries { + if hctx.Ctx != nil && hctx.Ctx.Err() != nil { + break + } + + if entry == nil { + continue + } + + gate, err := evaluateEntryGate(entry, currentPayload, i, opts) + if err != nil { + return chainOutcome{}, err + } + if !gate { + continue + } + + outcome, err := entry.handler(currentPayload, hctx) + if err != nil { + if opts.throwOnHandlerError { + return chainOutcome{}, err + } + log.Printf("[HooksManager] Handler %d for hook %q threw: %v", i, opts.hookName, err) + continue + } + + if outcome.Async != nil { + if outcome.Async.Work != nil { + pending = append(pending, trackAsyncWork(outcome.Async, opts.hookName, opts.onAsyncTimeout)) + } + continue + } + if !outcome.HasResult { + continue + } + + results = append(results, outcome.Result) + + if hasBehavior && behavior.mutate != nil { + newPayload, changed := behavior.mutate(currentPayload, outcome.Result) + if changed { + currentPayload = newPayload + mutated = true + } + } + + if hasBehavior && behavior.isBlocked != nil { + isBlocked, reason := behavior.isBlocked(outcome.Result) + if isBlocked { + blocked = true + if reason != "" { + blockReason = reason + } + break + } + } + } + + return chainOutcome{ + results: results, + pending: pending, + finalPayload: currentPayload, + blocked: blocked, + blockReason: blockReason, + mutated: mutated, + }, nil +} + +// evaluateEntryGate evaluates an entry's matcher and filter under the +// chain's error policy. Matchers fail closed: if a matcher is registered and +// no toolName is available for this emit, the handler is skipped rather than +// invoked globally. +func evaluateEntryGate(entry *erasedEntry, payload any, index int, opts chainOptions) (bool, error) { + defer func() { + // Guard against a panicking matcher/filter function the same way + // upstream's try/catch does for a throwing matcher or filter. + if r := recover(); r != nil { + log.Printf("[HooksManager] Matcher/filter for handler %d of hook %q panicked: %v", index, opts.hookName, r) + } + }() + + matcherPasses := entry.matcher == nil || (opts.hasToolName && MatchesTool(entry.matcher, opts.toolName)) + if !matcherPasses { + return false, nil + } + if entry.filter == nil { + return true, nil + } + return entry.filter(payload), nil +} + +// trackAsyncWork returns a channel that closes when the handler's detached +// AsyncOutput.Work settles OR the timeout fires, whichever is first. On +// timeout, onTimeout is invoked (the manager uses it to cancel the emit's +// context) and the tracking channel still closes so Drain stops waiting. +func trackAsyncWork(async *AsyncOutput, hookName string, onTimeout func()) <-chan struct{} { + done := make(chan struct{}) + if async.Work == nil { + close(done) + return done + } + timeoutMs := async.AsyncTimeout + if timeoutMs <= 0 { + timeoutMs = DefaultAsyncTimeout + } + timer := time.NewTimer(time.Duration(timeoutMs) * time.Millisecond) + + go func() { + defer close(done) + defer timer.Stop() + select { + case err, ok := <-async.Work: + if ok && err != nil { + log.Printf("[HooksManager] Async work for hook %q rejected: %v", hookName, err) + } + case <-timer.C: + log.Printf("[HooksManager] Async work for hook %q exceeded its %dms timeout; abandoning wait.", hookName, timeoutMs) + if onTimeout != nil { + onTimeout() + } + } + }() + return done +} diff --git a/hooks_manager.go b/hooks_manager.go new file mode 100644 index 0000000..889ae7a --- /dev/null +++ b/hooks_manager.go @@ -0,0 +1,393 @@ +package agent + +import ( + "context" + "log" + "reflect" + "sync" +) + +// HooksManager is a typed, extensible hook system for agent lifecycle +// events (upstream #7/#67, the 0.8.0 headline). Supports both the nine +// built-in hooks (PreToolUse, PostToolUse, ...) and user-defined custom +// hooks registered via the package-level generic On/Emit functions (Go's +// generics stand-in for upstream's `AllHooks` conditional-type +// merging — idiomatic divergence #5 in upstreamer.md). +type HooksManager struct { + mu sync.Mutex + entries map[string][]*erasedEntry + pending map[<-chan struct{}]struct{} + inflight map[int]context.CancelFunc + nextToken int + throwOnHandlerError bool + sessionID string +} + +// NewHooksManager constructs a HooksManager. opts is variadic so the common +// zero-options case (agent.NewHooksManager()) needs no empty struct literal. +func NewHooksManager(opts ...HooksManagerOptions) *HooksManager { + m := &HooksManager{ + entries: map[string][]*erasedEntry{}, + pending: map[<-chan struct{}]struct{}{}, + inflight: map[int]context.CancelFunc{}, + } + if len(opts) > 0 { + m.throwOnHandlerError = opts[0].ThrowOnHandlerError + } + return m +} + +// SetSessionID sets the manager-level default session ID exposed as +// hctx.SessionID to handler invocations. +// +// This is a single mutable default on the manager instance: when one manager +// is shared by concurrent runs, callers MUST pass SessionID in EmitOptions +// instead (as CallModel does), otherwise the last SetSessionID call wins and +// concurrent emits observe the wrong id. +func (m *HooksManager) SetSessionID(sessionID string) { + m.mu.Lock() + m.sessionID = sessionID + m.mu.Unlock() +} + +func (m *HooksManager) defaultSessionID() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.sessionID +} + +// EmitOptions configures one Emit call. +type EmitOptions struct { + // ToolName scopes tool-matcher-gated hooks (PreToolUse, PostToolUse, ...). + ToolName string + // SessionID overrides the manager-level default from SetSessionID for + // this emit only. Pass it whenever the manager instance may be shared + // across concurrent runs (CallModel always does). + SessionID string +} + +// On registers a handler for hookName (built-in or custom) and returns an +// unsubscribe function. +func On[P, R any](m *HooksManager, hookName string, entry HookEntry[P, R]) func() { + var filter erasedFilter + if entry.Filter != nil { + f := entry.Filter + filter = func(payload any) bool { + p, ok := payload.(P) + if !ok { + return false + } + return f(p) + } + } + handler := entry.Handler + h := func(payload any, hctx LifecycleHookContext) (erasedOutcome, error) { + p, ok := payload.(P) + if !ok { + log.Printf("[HooksManager] payload type mismatch for hook %q handler; skipping", hctx.HookName) + return erasedOutcome{}, nil + } + result, err := handler(p, hctx) + if err != nil { + return erasedOutcome{}, err + } + return erasedOutcome{Result: result.Result, HasResult: result.HasResult, Async: result.Async}, nil + } + ee := &erasedEntry{handler: h, matcher: entry.Matcher, filter: filter, orig: entry.Handler} + return m.register(hookName, ee) +} + +func (m *HooksManager) register(hookName string, entry *erasedEntry) func() { + m.mu.Lock() + m.entries[hookName] = append(m.entries[hookName], entry) + m.mu.Unlock() + + return func() { + m.mu.Lock() + defer m.mu.Unlock() + list := m.entries[hookName] + for i, e := range list { + if e == entry { + m.entries[hookName] = append(list[:i:i], list[i+1:]...) + return + } + } + } +} + +// Off removes a specific handler function from a hook. Returns true if found +// and removed. +// +// Go function values are not comparable, so this is a best-effort match on +// the handler's code pointer via reflection (works for named functions and +// most closures, but two independently-created closures over identical code +// can share a pointer). The unsubscribe function returned by On is the +// precise removal path; prefer it when available. +func Off[P, R any](m *HooksManager, hookName string, handler HookHandler[P, R]) bool { + target := reflect.ValueOf(handler).Pointer() + m.mu.Lock() + defer m.mu.Unlock() + list := m.entries[hookName] + for i, e := range list { + h, ok := e.orig.(HookHandler[P, R]) + if !ok { + continue + } + if reflect.ValueOf(h).Pointer() == target { + m.entries[hookName] = append(list[:i:i], list[i+1:]...) + return true + } + } + return false +} + +// RemoveAll removes all handlers for a specific hook. +func (m *HooksManager) RemoveAll(hookName string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.entries, hookName) +} + +// RemoveAllHooks removes every handler for every hook. +func (m *HooksManager) RemoveAllHooks() { + m.mu.Lock() + defer m.mu.Unlock() + m.entries = map[string][]*erasedEntry{} +} + +// HasHandlers reports whether any handlers are registered for hookName. +func (m *HooksManager) HasHandlers(hookName string) bool { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.entries[hookName]) > 0 +} + +// Drain awaits all in-flight async handler work. Used for graceful shutdown; +// CallModel calls this unconditionally on every exit path so fire-and-forget +// hook work is never silently dropped. +func (m *HooksManager) Drain() { + for { + m.mu.Lock() + if len(m.pending) == 0 { + m.mu.Unlock() + return + } + snapshot := make([]<-chan struct{}, 0, len(m.pending)) + for ch := range m.pending { + snapshot = append(snapshot, ch) + } + m.mu.Unlock() + for _, ch := range snapshot { + <-ch + } + } +} + +// AbortInflight cancels the context passed to every in-flight Emit call. +// Handlers that kick off async work should observe hctx.Ctx to honor this. +// +// Does not remove pending async work from the drain set — callers that want +// to wait for handlers to wind down should still call Drain afterward. +func (m *HooksManager) AbortInflight() { + m.mu.Lock() + cancels := make([]context.CancelFunc, 0, len(m.inflight)) + for _, c := range m.inflight { + cancels = append(cancels, c) + } + m.mu.Unlock() + for _, c := range cancels { + c() + } +} + +// emitAny validates nothing at runtime (Go's type system is the validation +// layer — see hooks_schemas.go), invokes matching handlers in registration +// order, and returns the erased outcome. +func (m *HooksManager) emitAny(hookName string, payload any, opts EmitOptions) (chainOutcome, error) { + m.mu.Lock() + list := append([]*erasedEntry{}, m.entries[hookName]...) + m.mu.Unlock() + + sessionID := opts.SessionID + if sessionID == "" { + sessionID = m.defaultSessionID() + } + + ctx, cancel := context.WithCancel(context.Background()) + token := m.trackInflight(cancel) + + hctx := LifecycleHookContext{Ctx: ctx, HookName: hookName, SessionID: sessionID} + + outcome, err := executeHandlerChain(list, payload, hctx, chainOptions{ + hookName: hookName, + throwOnHandlerError: m.throwOnHandlerError, + toolName: opts.ToolName, + hasToolName: opts.ToolName != "", + onAsyncTimeout: cancel, + }) + + if len(outcome.pending) == 0 { + m.dropInflight(token) + } else { + m.trackPending(token, outcome.pending) + } + + return outcome, err +} + +func (m *HooksManager) trackInflight(cancel context.CancelFunc) int { + m.mu.Lock() + defer m.mu.Unlock() + m.nextToken++ + token := m.nextToken + m.inflight[token] = cancel + return token +} + +func (m *HooksManager) dropInflight(token int) { + m.mu.Lock() + delete(m.inflight, token) + m.mu.Unlock() +} + +// trackPending keeps this emit's inflight controller registered until every +// piece of detached work settles (mirrors upstream: the controller must stay +// registered so AbortInflight can still reach fire-and-forget handlers that +// outlive the emit call itself), and tracks each channel for Drain. +func (m *HooksManager) trackPending(token int, pending []<-chan struct{}) { + m.mu.Lock() + for _, ch := range pending { + m.pending[ch] = struct{}{} + } + remaining := len(pending) + m.mu.Unlock() + + for _, ch := range pending { + ch := ch + go func() { + <-ch + m.mu.Lock() + delete(m.pending, ch) + remaining-- + if remaining == 0 { + delete(m.inflight, token) + } + m.mu.Unlock() + }() + } +} + +// emitTyped is the shared implementation behind Emit and every EmitXxx +// built-in wrapper: it erases payload to `any` for emitAny and converts the +// erased outcome back to typed results. +func emitTyped[P, R any](m *HooksManager, hookName string, payload P, opts EmitOptions) (EmitResult[R, P], error) { + outcome, err := m.emitAny(hookName, payload, opts) + if err != nil { + return EmitResult[R, P]{}, err + } + results := make([]R, 0, len(outcome.results)) + for _, r := range outcome.results { + if typed, ok := r.(R); ok { + results = append(results, typed) + } + } + finalPayload, ok := outcome.finalPayload.(P) + if !ok { + finalPayload = payload + } + return EmitResult[R, P]{ + Results: results, + Pending: outcome.pending, + FinalPayload: finalPayload, + Blocked: outcome.blocked, + BlockReason: outcome.blockReason, + Mutated: outcome.mutated, + }, nil +} + +// Emit emits a custom (non-built-in) hook. Built-in hooks have typed +// EmitXxx methods on HooksManager (EmitPreToolUse, EmitPostToolUse, ...). +func Emit[P, R any](m *HooksManager, hookName string, payload P, opts EmitOptions) (EmitResult[R, P], error) { + return emitTyped[P, R](m, hookName, payload, opts) +} + +//#region Built-in hook convenience wrappers +// +// Go cannot express upstream's `AllHooks` merged registry as a single +// generic on()/emit() pair with per-call type inference from a string +// literal, so each built-in hook gets a concrete OnXxx/EmitXxx pair. Custom +// hooks use the package-level generic On/Emit with explicit type arguments. + +func (m *HooksManager) OnPreToolUse(entry HookEntry[PreToolUsePayload, PreToolUseResult]) func() { + return On(m, string(HookNamePreToolUse), entry) +} + +func (m *HooksManager) EmitPreToolUse(payload PreToolUsePayload, opts EmitOptions) (EmitResult[PreToolUseResult, PreToolUsePayload], error) { + return emitTyped[PreToolUsePayload, PreToolUseResult](m, string(HookNamePreToolUse), payload, opts) +} + +func (m *HooksManager) OnPostToolUse(entry HookEntry[PostToolUsePayload, EmptyHookResult]) func() { + return On(m, string(HookNamePostToolUse), entry) +} + +func (m *HooksManager) EmitPostToolUse(payload PostToolUsePayload, opts EmitOptions) (EmitResult[EmptyHookResult, PostToolUsePayload], error) { + return emitTyped[PostToolUsePayload, EmptyHookResult](m, string(HookNamePostToolUse), payload, opts) +} + +func (m *HooksManager) OnPostToolUseFailure(entry HookEntry[PostToolUseFailurePayload, EmptyHookResult]) func() { + return On(m, string(HookNamePostToolUseFailure), entry) +} + +func (m *HooksManager) EmitPostToolUseFailure(payload PostToolUseFailurePayload, opts EmitOptions) (EmitResult[EmptyHookResult, PostToolUseFailurePayload], error) { + return emitTyped[PostToolUseFailurePayload, EmptyHookResult](m, string(HookNamePostToolUseFailure), payload, opts) +} + +func (m *HooksManager) OnUserPromptSubmit(entry HookEntry[UserPromptSubmitPayload, UserPromptSubmitResult]) func() { + return On(m, string(HookNameUserPromptSubmit), entry) +} + +func (m *HooksManager) EmitUserPromptSubmit(payload UserPromptSubmitPayload, opts EmitOptions) (EmitResult[UserPromptSubmitResult, UserPromptSubmitPayload], error) { + return emitTyped[UserPromptSubmitPayload, UserPromptSubmitResult](m, string(HookNameUserPromptSubmit), payload, opts) +} + +func (m *HooksManager) OnStop(entry HookEntry[StopPayload, StopResult]) func() { + return On(m, string(HookNameStop), entry) +} + +func (m *HooksManager) EmitStop(payload StopPayload, opts EmitOptions) (EmitResult[StopResult, StopPayload], error) { + return emitTyped[StopPayload, StopResult](m, string(HookNameStop), payload, opts) +} + +func (m *HooksManager) OnPermissionRequest(entry HookEntry[PermissionRequestPayload, PermissionRequestResult]) func() { + return On(m, string(HookNamePermissionRequest), entry) +} + +func (m *HooksManager) EmitPermissionRequest(payload PermissionRequestPayload, opts EmitOptions) (EmitResult[PermissionRequestResult, PermissionRequestPayload], error) { + return emitTyped[PermissionRequestPayload, PermissionRequestResult](m, string(HookNamePermissionRequest), payload, opts) +} + +func (m *HooksManager) OnSessionStart(entry HookEntry[SessionStartPayload, EmptyHookResult]) func() { + return On(m, string(HookNameSessionStart), entry) +} + +func (m *HooksManager) EmitSessionStart(payload SessionStartPayload, opts EmitOptions) (EmitResult[EmptyHookResult, SessionStartPayload], error) { + return emitTyped[SessionStartPayload, EmptyHookResult](m, string(HookNameSessionStart), payload, opts) +} + +func (m *HooksManager) OnSessionEnd(entry HookEntry[SessionEndPayload, EmptyHookResult]) func() { + return On(m, string(HookNameSessionEnd), entry) +} + +func (m *HooksManager) EmitSessionEnd(payload SessionEndPayload, opts EmitOptions) (EmitResult[EmptyHookResult, SessionEndPayload], error) { + return emitTyped[SessionEndPayload, EmptyHookResult](m, string(HookNameSessionEnd), payload, opts) +} + +func (m *HooksManager) OnPostModelCall(entry HookEntry[PostModelCallPayload, EmptyHookResult]) func() { + return On(m, string(HookNamePostModelCall), entry) +} + +func (m *HooksManager) EmitPostModelCall(payload PostModelCallPayload, opts EmitOptions) (EmitResult[EmptyHookResult, PostModelCallPayload], error) { + return emitTyped[PostModelCallPayload, EmptyHookResult](m, string(HookNamePostModelCall), payload, opts) +} + +//#endregion diff --git a/hooks_manager_test.go b/hooks_manager_test.go new file mode 100644 index 0000000..d1957d2 --- /dev/null +++ b/hooks_manager_test.go @@ -0,0 +1,321 @@ +package agent + +import ( + "errors" + "testing" + "time" +) + +func TestHooksManagerOnEmitBasic(t *testing.T) { + m := NewHooksManager() + var seen PreToolUsePayload + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + seen = payload + return VoidResult[PreToolUseResult](), nil + }, + }) + + result, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "search", ToolInput: map[string]any{"q": "go"}}, EmitOptions{ToolName: "search"}) + if err != nil { + t.Fatal(err) + } + if seen.ToolName != "search" { + t.Fatalf("handler did not observe payload: %+v", seen) + } + if result.Blocked || result.Mutated { + t.Fatalf("unexpected blocked/mutated: %+v", result) + } +} + +func TestHooksManagerPreToolUseMutation(t *testing.T) { + m := NewHooksManager() + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + return SyncResult(PreToolUseResult{MutatedInput: map[string]any{"q": "mutated"}}), nil + }, + }) + + result, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "search", ToolInput: map[string]any{"q": "original"}}, EmitOptions{ToolName: "search"}) + if err != nil { + t.Fatal(err) + } + if !result.Mutated { + t.Fatalf("expected mutated=true") + } + if result.FinalPayload.ToolInput["q"] != "mutated" { + t.Fatalf("expected mutated input to be piped into final payload, got %+v", result.FinalPayload) + } +} + +func TestHooksManagerPreToolUseBlock(t *testing.T) { + m := NewHooksManager() + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + return SyncResult(PreToolUseResult{BlockReason: "not allowed"}), nil + }, + }) + // A second handler must never run once the chain is blocked. + var secondRan bool + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + secondRan = true + return VoidResult[PreToolUseResult](), nil + }, + }) + + result, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "danger", ToolInput: map[string]any{}}, EmitOptions{ToolName: "danger"}) + if err != nil { + t.Fatal(err) + } + if !result.Blocked { + t.Fatalf("expected blocked=true") + } + if result.BlockReason != "not allowed" { + t.Fatalf("expected block reason to surface, got %q", result.BlockReason) + } + if secondRan { + t.Fatalf("chain should short-circuit on block") + } +} + +func TestHooksManagerToolMatcherFailsClosed(t *testing.T) { + m := NewHooksManager() + var ran bool + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Matcher: "search", + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + ran = true + return VoidResult[PreToolUseResult](), nil + }, + }) + + // No ToolName in EmitOptions -> matcher fails closed, handler must not run. + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "search"}, EmitOptions{}); err != nil { + t.Fatal(err) + } + if ran { + t.Fatalf("matcher should fail closed without a toolName") + } + + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "other"}, EmitOptions{ToolName: "other"}); err != nil { + t.Fatal(err) + } + if ran { + t.Fatalf("matcher should skip a non-matching tool name") + } + + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "search"}, EmitOptions{ToolName: "search"}); err != nil { + t.Fatal(err) + } + if !ran { + t.Fatalf("matcher should run for a matching tool name") + } +} + +func TestHooksManagerFilterGate(t *testing.T) { + m := NewHooksManager() + var ran bool + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Filter: func(p PreToolUsePayload) bool { return p.ToolInput["risky"] == true }, + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + ran = true + return VoidResult[PreToolUseResult](), nil + }, + }) + + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "t", ToolInput: map[string]any{}}, EmitOptions{ToolName: "t"}); err != nil { + t.Fatal(err) + } + if ran { + t.Fatalf("filter should skip when predicate is false") + } + + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "t", ToolInput: map[string]any{"risky": true}}, EmitOptions{ToolName: "t"}); err != nil { + t.Fatal(err) + } + if !ran { + t.Fatalf("filter should run when predicate is true") + } +} + +func TestHooksManagerThrowOnHandlerError(t *testing.T) { + boom := errors.New("boom") + + lenient := NewHooksManager() + var secondRan bool + lenient.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + return HookHandlerResult[PreToolUseResult]{}, boom + }, + }) + lenient.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + secondRan = true + return VoidResult[PreToolUseResult](), nil + }, + }) + if _, err := lenient.EmitPreToolUse(PreToolUsePayload{ToolName: "t"}, EmitOptions{ToolName: "t"}); err != nil { + t.Fatalf("default mode should not propagate handler errors: %v", err) + } + if !secondRan { + t.Fatalf("default mode should continue the chain after a handler error") + } + + strict := NewHooksManager(HooksManagerOptions{ThrowOnHandlerError: true}) + strict.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + return HookHandlerResult[PreToolUseResult]{}, boom + }, + }) + if _, err := strict.EmitPreToolUse(PreToolUsePayload{ToolName: "t"}, EmitOptions{ToolName: "t"}); !errors.Is(err, boom) { + t.Fatalf("strict mode should propagate the handler error, got %v", err) + } +} + +func TestHooksManagerSessionIDThreading(t *testing.T) { + m := NewHooksManager() + m.SetSessionID("default-session") + + var seenDefault, seenOverride string + m.OnSessionStart(HookEntry[SessionStartPayload, EmptyHookResult]{ + Handler: func(payload SessionStartPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + if seenDefault == "" { + seenDefault = hctx.SessionID + } else { + seenOverride = hctx.SessionID + } + return VoidResult[EmptyHookResult](), nil + }, + }) + + if _, err := m.EmitSessionStart(SessionStartPayload{}, EmitOptions{}); err != nil { + t.Fatal(err) + } + if seenDefault != "default-session" { + t.Fatalf("expected manager-level default session id, got %q", seenDefault) + } + + if _, err := m.EmitSessionStart(SessionStartPayload{}, EmitOptions{SessionID: "run-specific"}); err != nil { + t.Fatal(err) + } + if seenOverride != "run-specific" { + t.Fatalf("expected per-emit session id override, got %q", seenOverride) + } +} + +func TestHooksManagerAsyncDrain(t *testing.T) { + m := NewHooksManager() + work := make(chan error) + var handlerCompleted bool + m.OnPostToolUse(HookEntry[PostToolUsePayload, EmptyHookResult]{ + Handler: func(payload PostToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + go func() { + time.Sleep(10 * time.Millisecond) + handlerCompleted = true + close(work) + }() + return AsyncResult[EmptyHookResult](AsyncOutput{Work: work}), nil + }, + }) + + if _, err := m.EmitPostToolUse(PostToolUsePayload{ToolName: "t"}, EmitOptions{ToolName: "t"}); err != nil { + t.Fatal(err) + } + // Async handler must not block emit from returning. + if handlerCompleted { + t.Fatalf("async handler should not have completed synchronously") + } + m.Drain() + if !handlerCompleted { + t.Fatalf("Drain should wait for detached async work to settle") + } +} + +func TestHooksManagerRemoveAllAndUnsubscribe(t *testing.T) { + m := NewHooksManager() + var calls int + unsubscribe := m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + calls++ + return VoidResult[PreToolUseResult](), nil + }, + }) + if !m.HasHandlers(string(HookNamePreToolUse)) { + t.Fatalf("expected registered handler to be visible via HasHandlers") + } + unsubscribe() + if m.HasHandlers(string(HookNamePreToolUse)) { + t.Fatalf("unsubscribe should remove the handler") + } + + m.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + calls++ + return VoidResult[PreToolUseResult](), nil + }, + }) + m.RemoveAll(string(HookNamePreToolUse)) + if m.HasHandlers(string(HookNamePreToolUse)) { + t.Fatalf("RemoveAll should clear every handler for the hook") + } + if _, err := m.EmitPreToolUse(PreToolUsePayload{ToolName: "t"}, EmitOptions{ToolName: "t"}); err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("expected no handler calls after removal, got %d", calls) + } +} + +func TestHooksManagerCustomHook(t *testing.T) { + type customPayload struct{ Value int } + type customResult struct{ Doubled int } + + m := NewHooksManager() + unsubscribe := On(m, "CustomDoubler", HookEntry[customPayload, customResult]{ + Handler: func(payload customPayload, hctx LifecycleHookContext) (HookHandlerResult[customResult], error) { + return SyncResult(customResult{Doubled: payload.Value * 2}), nil + }, + }) + defer unsubscribe() + + result, err := Emit[customPayload, customResult](m, "CustomDoubler", customPayload{Value: 21}, EmitOptions{}) + if err != nil { + t.Fatal(err) + } + if len(result.Results) != 1 || result.Results[0].Doubled != 42 { + t.Fatalf("expected custom hook result Doubled=42, got %+v", result.Results) + } +} + +func TestHooksManagerStopForceResumeAndAppendPrompt(t *testing.T) { + m := NewHooksManager() + m.OnStop(HookEntry[StopPayload, StopResult]{ + Handler: func(payload StopPayload, hctx LifecycleHookContext) (HookHandlerResult[StopResult], error) { + return SyncResult(StopResult{ForceResume: true, AppendPrompt: "keep going"}), nil + }, + }) + + result, err := m.EmitStop(StopPayload{Reason: StopReasonMaxTurns}, EmitOptions{}) + if err != nil { + t.Fatal(err) + } + if len(result.Results) != 1 || !result.Results[0].ForceResume { + t.Fatalf("expected ForceResume=true, got %+v", result.Results) + } + if result.Results[0].AppendPrompt != "keep going" { + t.Fatalf("expected AppendPrompt to round-trip, got %q", result.Results[0].AppendPrompt) + } +} + +func TestMatchesTool(t *testing.T) { + if !MatchesTool(nil, "anything") { + t.Fatalf("nil matcher should match everything") + } + if !MatchesTool("search", "search") || MatchesTool("search", "other") { + t.Fatalf("string matcher should require an exact match") + } + if !MatchesTool(func(name string) bool { return len(name) > 2 }, "search") { + t.Fatalf("predicate matcher should evaluate the function") + } +} diff --git a/hooks_matchers.go b/hooks_matchers.go new file mode 100644 index 0000000..130f144 --- /dev/null +++ b/hooks_matchers.go @@ -0,0 +1,29 @@ +package agent + +import "regexp" + +// MatchesTool evaluates a ToolMatcher against a tool name. +// +// - nil -> wildcard, matches all tools +// - string -> exact match +// - *regexp.Regexp -> MatchString +// - func(string) bool -> arbitrary predicate +// +// Unlike upstream's JS RegExp (whose `/g`/`/y` flags advance `lastIndex` +// across calls, making repeated `.test()` calls alternate true/false), Go's +// regexp.Regexp has no such stateful footgun, so MatchesTool needs no +// lastIndex reset — it is stateless by construction. +func MatchesTool(matcher ToolMatcher, toolName string) bool { + switch m := matcher.(type) { + case nil: + return true + case string: + return m == toolName + case *regexp.Regexp: + return m.MatchString(toolName) + case func(string) bool: + return m(toolName) + default: + return false + } +} diff --git a/hooks_resolve.go b/hooks_resolve.go new file mode 100644 index 0000000..c1efe1e --- /dev/null +++ b/hooks_resolve.go @@ -0,0 +1,70 @@ +package agent + +import "log" + +// InlineHookConfig is a lightweight alternative to constructing a +// HooksManager by hand: pass built-in hook entries directly on +// CallModelInput.Hooks. Only built-in hooks are supported inline; register +// custom hooks through a HooksManager instance via On. +type InlineHookConfig struct { + PreToolUse []HookEntry[PreToolUsePayload, PreToolUseResult] + PostToolUse []HookEntry[PostToolUsePayload, EmptyHookResult] + PostToolUseFailure []HookEntry[PostToolUseFailurePayload, EmptyHookResult] + UserPromptSubmit []HookEntry[UserPromptSubmitPayload, UserPromptSubmitResult] + Stop []HookEntry[StopPayload, StopResult] + PermissionRequest []HookEntry[PermissionRequestPayload, PermissionRequestResult] + SessionStart []HookEntry[SessionStartPayload, EmptyHookResult] + SessionEnd []HookEntry[SessionEndPayload, EmptyHookResult] + PostModelCall []HookEntry[PostModelCallPayload, EmptyHookResult] +} + +// ResolveHooks normalizes a CallModelInput.Hooks value into a *HooksManager. +// +// - nil -> nil (no hooks) +// - *HooksManager -> passthrough +// - InlineHookConfig -> construct a HooksManager and register every entry +// +// Any other value logs a warning and is treated as nil, mirroring upstream's +// defensive handling of a config value that bypassed the typed surface. +func ResolveHooks(hooks any) *HooksManager { + if hooks == nil { + return nil + } + switch v := hooks.(type) { + case *HooksManager: + return v + case InlineHookConfig: + manager := NewHooksManager() + for _, e := range v.PreToolUse { + manager.OnPreToolUse(e) + } + for _, e := range v.PostToolUse { + manager.OnPostToolUse(e) + } + for _, e := range v.PostToolUseFailure { + manager.OnPostToolUseFailure(e) + } + for _, e := range v.UserPromptSubmit { + manager.OnUserPromptSubmit(e) + } + for _, e := range v.Stop { + manager.OnStop(e) + } + for _, e := range v.PermissionRequest { + manager.OnPermissionRequest(e) + } + for _, e := range v.SessionStart { + manager.OnSessionStart(e) + } + for _, e := range v.SessionEnd { + manager.OnSessionEnd(e) + } + for _, e := range v.PostModelCall { + manager.OnPostModelCall(e) + } + return manager + default: + log.Printf("[ResolveHooks] Ignoring CallModelInput.Hooks of unsupported type %T; expected *HooksManager or InlineHookConfig.", hooks) + return nil + } +} diff --git a/hooks_schemas.go b/hooks_schemas.go new file mode 100644 index 0000000..0e167e8 --- /dev/null +++ b/hooks_schemas.go @@ -0,0 +1,244 @@ +package agent + +// HookName identifies a built-in lifecycle hook. Ported from upstream +// hooks-schemas.ts's `HookName` const object (upstream #7/#67, the 0.8.0 +// headline). Custom hook names are plain strings; only the built-ins get a +// typed constant. +type HookName string + +const ( + HookNamePreToolUse HookName = "PreToolUse" + HookNamePostToolUse HookName = "PostToolUse" + HookNamePostToolUseFailure HookName = "PostToolUseFailure" + HookNameUserPromptSubmit HookName = "UserPromptSubmit" + HookNameStop HookName = "Stop" + HookNamePermissionRequest HookName = "PermissionRequest" + HookNameSessionStart HookName = "SessionStart" + HookNameSessionEnd HookName = "SessionEnd" + HookNamePostModelCall HookName = "PostModelCall" +) + +// builtInHookNames mirrors upstream's BUILT_IN_HOOK_NAMES set, used by +// ResolveHooks to warn about (and skip) inline entries under an unrecognized +// name instead of silently never firing. +var builtInHookNames = map[string]bool{ + string(HookNamePreToolUse): true, + string(HookNamePostToolUse): true, + string(HookNamePostToolUseFailure): true, + string(HookNameUserPromptSubmit): true, + string(HookNameStop): true, + string(HookNamePermissionRequest): true, + string(HookNameSessionStart): true, + string(HookNameSessionEnd): true, + string(HookNamePostModelCall): true, +} + +// IsBuiltInHookName reports whether name is one of the nine built-in hooks. +func IsBuiltInHookName(name string) bool { return builtInHookNames[name] } + +//#region Payload & Result types +// +// Go has no runtime schema library standing in for Zod (idiomatic divergence: +// "Struct tags + invopop/jsonschema replace Zod" is about *tool* input/output +// schemas; hook payloads/results are plain compile-time-typed Go structs). +// Compile-time typing replaces upstream's runtime schema validation for these +// shapes: a well-typed Go handler cannot receive a malformed payload the way +// a JS handler behind a Zod schema could. See upstreamer-changelog.md for the +// compatibility note on this divergence. +// +// TS's `block?: boolean | string` / `reject?: boolean | string` union fields +// are split into two Go fields (a bool and a string) rather than boxed in an +// `any`; either one set triggers the same short-circuit behavior as upstream +// (`=== true` or a non-empty string). This keeps the common case +// (`return PreToolUseResult{Block: true}`) idiomatic while still allowing a +// reason string (`return PreToolUseResult{BlockReason: "not allowed"}`). + +// PreToolUsePayload is delivered before a client tool executes. +type PreToolUsePayload struct { + ToolName string + ToolInput map[string]any +} + +// PreToolUseResult can mutate the tool's input or block execution. +type PreToolUseResult struct { + // MutatedInput replaces ToolInput for the actual tool call when non-nil. + MutatedInput map[string]any + // Block, if true, denies the call with a generic reason. + Block bool + // BlockReason, if non-empty, denies the call and doubles as Block=true. + BlockReason string +} + +// Blocked reports whether this result triggers the PreToolUse short-circuit. +func (r PreToolUseResult) Blocked() bool { return r.Block || r.BlockReason != "" } + +// PostToolUsePayload is delivered after a client tool executes successfully. +type PostToolUsePayload struct { + ToolName string + ToolInput map[string]any + ToolOutput any + DurationMs float64 +} + +// PostToolUseFailurePayload is delivered when a tool execution throws or +// returns an error. Deliberately NOT fired when a tool never ran (a +// PermissionRequest deny, a user rejection on resume, or a PreToolUse block +// all synthesize a rejected result without execution). +type PostToolUseFailurePayload struct { + ToolName string + ToolInput map[string]any + Error error +} + +// StopReason is the reason the tool loop halted, passed to the Stop hook. +type StopReason string + +// StopReasonMaxTurns is currently the only reason the engine emits: the +// configured stopWhen condition (default StepCountIs) fired. +const StopReasonMaxTurns StopReason = "max_turns" + +// StopPayload is delivered when a stopWhen condition halts the loop. +type StopPayload struct { + Reason StopReason +} + +// StopResult lets a handler force the loop to resume and/or inject a prompt. +// +// ForceResume alone does not change any state: the stop condition typically +// fires again immediately, so a bare ForceResume burns through the +// consecutive-override cap in rapid succession and then stops. Pair it with +// AppendPrompt (which injects a user message, advancing the conversation) to +// make resumption useful. AppendPrompt is honored independently of +// ForceResume. Multiple handlers' AppendPrompt values are concatenated with +// newlines. +type StopResult struct { + ForceResume bool + AppendPrompt string +} + +// RiskLevel is PermissionRequest's coarse risk classification, derived from +// the approval gate's shape: a callback => high, blanket true => medium, +// otherwise low. +type RiskLevel string + +const ( + RiskLevelLow RiskLevel = "low" + RiskLevelMedium RiskLevel = "medium" + RiskLevelHigh RiskLevel = "high" +) + +// PermissionRequestPayload is delivered before the engine blocks for human +// approval, letting a hook allow/deny/pass-through the decision. +type PermissionRequestPayload struct { + ToolName string + ToolInput map[string]any + RiskLevel RiskLevel +} + +// PermissionDecision is PermissionRequestResult's outcome. +type PermissionDecision string + +const ( + // PermissionDecisionAllow promotes the call past the approval gate. + PermissionDecisionAllow PermissionDecision = "allow" + // PermissionDecisionDeny synthesizes a rejection without executing the tool. + PermissionDecisionDeny PermissionDecision = "deny" + // PermissionDecisionAskUser falls through to the normal human approval flow (default). + PermissionDecisionAskUser PermissionDecision = "ask_user" +) + +// PermissionRequestResult is a handler's decision for a gated tool call. +// Last-wins when multiple handlers disagree. +type PermissionRequestResult struct { + Decision PermissionDecision + Reason string +} + +// UserPromptSubmitPayload carries the extracted user prompt text for the +// current turn's input. +type UserPromptSubmitPayload struct { + Prompt string +} + +// UserPromptSubmitResult can mutate the prompt or reject it outright. +type UserPromptSubmitResult struct { + // MutatedPrompt replaces Prompt when non-nil (a pointer distinguishes + // "not set" from "explicitly set to the empty string"). + MutatedPrompt *string + Reject bool + RejectReason string +} + +// Rejected reports whether this result triggers the UserPromptSubmit short-circuit. +func (r UserPromptSubmitResult) Rejected() bool { return r.Reject || r.RejectReason != "" } + +// SessionStartPayload is delivered once per run, before the first model call. +type SessionStartPayload struct { + Config map[string]any +} + +// ModelCallUsage is the per-call usage summary handed to PostModelCall and +// folded into SessionEnd's SessionUsageTotals. +type ModelCallUsage struct { + InputTokens int64 + OutputTokens int64 + TotalTokens int64 + CachedTokens int64 + ReasoningTokens int64 + // Cost is nil when the response carried no cost figure. + Cost *float64 +} + +// SessionUsageTotals aggregates ModelCallUsage across every model call made +// during a run, plus a call count. +type SessionUsageTotals struct { + ModelCallUsage + ModelCalls int +} + +// SessionEndReason explains why the run ended. +type SessionEndReason string + +const ( + SessionEndReasonUser SessionEndReason = "user" + SessionEndReasonError SessionEndReason = "error" + SessionEndReasonMaxTurns SessionEndReason = "max_turns" + SessionEndReasonComplete SessionEndReason = "complete" +) + +// SessionEndPayload is delivered exactly once per run that reached +// SessionStart, carrying the aggregated usage totals when any model call was made. +type SessionEndPayload struct { + Reason SessionEndReason + TotalUsage *SessionUsageTotals +} + +// ModelCallTurnType classifies which kind of model request PostModelCall is reporting on. +type ModelCallTurnType string + +const ( + ModelCallTurnTypeInitial ModelCallTurnType = "initial" + ModelCallTurnTypeResume ModelCallTurnType = "resume" + ModelCallTurnTypeToolRound ModelCallTurnType = "tool_round" + ModelCallTurnTypeFinal ModelCallTurnType = "final" + ModelCallTurnTypeRetry ModelCallTurnType = "retry" +) + +// PostModelCallPayload is delivered once per materialized model response. +type PostModelCallPayload struct { + SessionID string + ResponseID string + Model string + DurationMs float64 + TurnType ModelCallTurnType + TurnNumber int + Usage *ModelCallUsage +} + +// EmptyHookResult is the result type for observation-only hooks +// (PostToolUse, PostToolUseFailure, SessionStart, SessionEnd, PostModelCall): +// handlers have no meaningful result to return. Stands in for upstream's +// `result: undefined` / `z.void()` built-in definitions. +type EmptyHookResult struct{} + +//#endregion diff --git a/hooks_types.go b/hooks_types.go new file mode 100644 index 0000000..1d4d1f3 --- /dev/null +++ b/hooks_types.go @@ -0,0 +1,121 @@ +package agent + +import "context" + +// LifecycleHookContext is provided to every lifecycle-hook handler invocation. +// +// Ctx is the idiomatic-Go stand-in for upstream's `AbortSignal`: it is +// canceled if the manager's AbortInflight is called while the emit is still +// running (idiomatic divergence: context.Context for cancellation, not +// AbortSignal — see upstreamer.md). Handlers that kick off background work +// via AsyncOutput should observe Ctx.Done() for cancellation. +type LifecycleHookContext struct { + Ctx context.Context + // HookName is the name of the hook currently emitting (useful for shared + // handlers registered against multiple hooks). + HookName string + // SessionID is the current session id. This is the single source for + // session identity in handlers — payloads deliberately do not repeat it. + // The engine threads it per emit (safe for a manager shared across + // concurrent runs); direct Emit callers get the manager-level default + // from SetSessionID unless they pass a per-emit override. + SessionID string +} + +// AsyncOutput signals fire-and-forget mode: the chain proceeds immediately +// without waiting for completion. Any background work the handler kicked off +// should be attached via Work so the manager can track it for Drain and +// enforce AsyncTimeout. +// +// Work is a channel (Go's stream-oriented stand-in for upstream's +// `work?: Promise`) that the handler's background goroutine should +// close (optionally sending a non-nil error first) when it finishes. +type AsyncOutput struct { + // Work is optional; nil means "no work to track". + Work <-chan error + // AsyncTimeout bounds how long the manager waits for Work before giving + // up and logging a warning. Zero means DefaultAsyncTimeout. + AsyncTimeout int64 // milliseconds +} + +// DefaultAsyncTimeout is the default number of milliseconds the manager +// waits for a handler's detached AsyncOutput.Work before abandoning the wait. +const DefaultAsyncTimeout int64 = 30_000 + +// HookHandlerResult is what a hook handler returns: either a synchronous +// result (HasResult true), a fire-and-forget AsyncOutput signal (Async +// non-nil), or neither (void/observation-only handlers). +type HookHandlerResult[R any] struct { + Result R + HasResult bool + Async *AsyncOutput +} + +// SyncResult wraps a synchronous handler result. +func SyncResult[R any](result R) HookHandlerResult[R] { + return HookHandlerResult[R]{Result: result, HasResult: true} +} + +// VoidResult signals a side-effect-only handler outcome (no result to collect). +func VoidResult[R any]() HookHandlerResult[R] { + return HookHandlerResult[R]{} +} + +// AsyncResult wraps a fire-and-forget AsyncOutput signal. +func AsyncResult[R any](async AsyncOutput) HookHandlerResult[R] { + return HookHandlerResult[R]{Async: &async} +} + +// HookHandler receives the payload and context for one hook invocation. +type HookHandler[P, R any] func(payload P, hctx LifecycleHookContext) (HookHandlerResult[R], error) + +// ToolMatcher filters tool-scoped hook invocation by tool name. Accepted +// dynamic values: nil (wildcard), string (exact match), *regexp.Regexp +// (MatchString), or func(string) bool (arbitrary predicate). See MatchesTool. +type ToolMatcher = any + +// HookEntry is one registered handler for a hook. +type HookEntry[P, R any] struct { + Handler HookHandler[P, R] + Matcher ToolMatcher + Filter func(P) bool +} + +// EmitResult is the result of emitting a hook through its handler chain. +type EmitResult[R, P any] struct { + // Results are the sync results returned by handlers that produced one. + Results []R + // Pending are handles to detached async handler work (see Drain). + Pending []<-chan struct{} + // FinalPayload is the payload after all mutation piping has been applied. + FinalPayload P + // Blocked is true if any handler triggered a block/reject short-circuit. + Blocked bool + // BlockReason is the first non-empty block/reject reason, if any. + BlockReason string + // Mutated is true if any handler's result actually piped a mutation into + // the payload (e.g. PreToolUse MutatedInput, UserPromptSubmit MutatedPrompt). + Mutated bool +} + +// HooksManagerOptions configures a HooksManager. +type HooksManagerOptions struct { + // ThrowOnHandlerError: if true, a handler error stops the chain and + // propagates the error. If false (default), the error is logged as a + // warning and execution continues. + ThrowOnHandlerError bool +} + +// HookDefinition describes a hook's payload/result Go types. Go has no +// runtime schema equivalent to Zod (see hooks_schemas.go); this exists so a +// HookRegistry can be introspected the way upstream's HookRegistry can, +// without carrying runtime validators. +type HookDefinition struct { + PayloadType string + ResultType string +} + +// HookRegistry maps custom hook names to their definitions. Provided for API +// parity with upstream's HookRegistry; Go's custom-hook registration (see On) +// is fully generic and does not require populating a HookRegistry up front. +type HookRegistry = map[string]HookDefinition diff --git a/model_result.go b/model_result.go index 10d5f89..27e2c6d 100644 --- a/model_result.go +++ b/model_result.go @@ -5,7 +5,10 @@ import ( "encoding/json" "errors" "fmt" + "log" + "strings" "sync" + "time" openrouter "github.com/OpenRouterTeam/go-sdk" "github.com/OpenRouterTeam/go-sdk/models/components" @@ -15,32 +18,58 @@ import ( type GetResponseOptions struct{ Refresh bool } +// DefaultFinalResponseDirective is appended as a final user message on the +// forced final turn (AllowFinalResponse defaulting to on, or explicitly +// true). Forbidding tools via ToolChoice=none alone is not enough: models +// that emit tool-call syntax as text (e.g. GLM) will attempt another call +// and leak it into content as unparsed text unless they are told this is the +// final turn. Pass a non-empty string to AllowFinalResponse to override the +// wording, or "" to append no message at all (legacy behavior). +const DefaultFinalResponseDirective = "You have reached the tool-use limit, and tools are no longer available. Do not attempt to call any more tools. Using the information you already have, write your final answer now." + +// sessionUsageAggregate accumulates ModelCallUsage across every model call in a run. +type sessionUsageAggregate struct { + modelCalls int + inputTokens int64 + outputTokens int64 + totalTokens int64 + cachedTokens int64 + reasoningTokens int64 + cost float64 + hasCost bool +} + type ModelResult struct { - ctx context.Context - client ResponseSender - input CallModelInput - req components.ResponsesRequest - store *ToolContextStore - once sync.Once - done chan struct{} - mu sync.RWMutex - resp components.OpenResponsesResult - state ConversationState - steps []StepResult - toolCalls []ParsedToolCall - err error - textStream *ReusableStream[string] - reasoningStream *ReusableStream[string] - toolStream *ReusableStream[ToolStreamEvent] - toolCallStream *ReusableStream[ParsedToolCall] - fullStream *ReusableStream[ResponseStreamEvent] - newMessages *ReusableStream[components.InputsUnion1] - cancel context.CancelFunc + ctx context.Context + client ResponseSender + input CallModelInput + req components.ResponsesRequest + store *ToolContextStore + once sync.Once + done chan struct{} + mu sync.RWMutex + resp components.OpenResponsesResult + state ConversationState + steps []StepResult + toolCalls []ParsedToolCall + err error + textStream *ReusableStream[string] + reasoningStream *ReusableStream[string] + toolStream *ReusableStream[ToolStreamEvent] + toolCallStream *ReusableStream[ParsedToolCall] + fullStream *ReusableStream[ResponseStreamEvent] + newMessages *ReusableStream[components.InputsUnion1] + cancel context.CancelFunc + hooksManager *HooksManager + sessionStartEmitted bool + sessionEndEmitted bool + sessionUsage sessionUsageAggregate + toolRoundsExecuted int } func newModelResult(ctx context.Context, client ResponseSender, input CallModelInput, req components.ResponsesRequest, state ConversationState) *ModelResult { ctx2, cancel := context.WithCancel(ctx) - return &ModelResult{ctx: ctx2, cancel: cancel, client: client, input: input, req: req, state: state, store: NewToolContextStore(input.Context), done: make(chan struct{}), textStream: NewReusableStream[string](), reasoningStream: NewReusableStream[string](), toolStream: NewReusableStream[ToolStreamEvent](), toolCallStream: NewReusableStream[ParsedToolCall](), fullStream: NewReusableStream[ResponseStreamEvent](), newMessages: NewReusableStream[components.InputsUnion1]()} + return &ModelResult{ctx: ctx2, cancel: cancel, client: client, input: input, req: req, state: state, store: NewToolContextStore(input.Context), done: make(chan struct{}), textStream: NewReusableStream[string](), reasoningStream: NewReusableStream[string](), toolStream: NewReusableStream[ToolStreamEvent](), toolCallStream: NewReusableStream[ParsedToolCall](), fullStream: NewReusableStream[ResponseStreamEvent](), newMessages: NewReusableStream[components.InputsUnion1](), hooksManager: ResolveHooks(input.Hooks)} } func NewModelResultFromResponse(resp components.OpenResponsesResult) *ModelResult { @@ -164,6 +193,17 @@ func (m *ModelResult) run() { m.fullStream.Complete(m.err) m.newMessages.Complete(m.err) }() + // Session teardown must run on every exit path — success, early return, + // or error — so fire-and-forget hook work from a run that fails before + // any tool ever executes (a "no-tools" path) is still drained. Never + // masks m.err: finishHooksSession only logs its own failures. + defer func() { + reason := SessionEndReasonComplete + if m.err != nil { + reason = SessionEndReasonError + } + m.finishHooksSession(reason) + }() if m.client == nil { return } @@ -173,13 +213,47 @@ func (m *ModelResult) run() { maxTurns = 5 } steps := []StepResult{} + isResume := false + forceResumeCount := 0 + // An approval/HITL resume-with-decisions bypasses SessionStart/ + // UserPromptSubmit entirely (upstream model-result.ts:2506-2515: "This + // path bypasses the SessionStart block below but still fires tool hooks + // [PreToolUse/PostToolUse] during the resume"). It is a continuation of + // the same logical session, not a fresh one — SessionEnd's own + // !sessionStartEmitted guard then makes it correctly a no-op too, while + // PostModelCall/PreToolUse/PostToolUse still fire during the resume via + // their own call sites below, and finishHooksSession's unconditional + // defer still drains any pending async hook work. if isAwaitingResume(m.state) && (len(m.input.ApproveToolCalls) > 0 || len(m.input.RejectToolCalls) > 0) { + if m.hooksManager != nil { + m.hooksManager.SetSessionID(m.state.ID) + } paused, err := m.prepareResumeRequest(&req) if err != nil { m.err = err return } if paused { + // The resume itself left calls pending (not every approved/ + // rejected decision resolved the round) — persist the updated + // state before returning, mirroring upstream's + // processApprovalDecisions, which calls saveStateSafely + // unconditionally regardless of whether the result still has + // calls pending. Without this, a StateAccessor-backed caller + // that reloads on the next CallModel call would see the stale + // pre-resume state and could re-execute an already-decided call. + if m.input.StateAccessor != nil { + if err := m.input.StateAccessor.Save(m.ctx, m.state); err != nil { + m.err = err + } + } + return + } + isResume = true + } else { + m.emitSessionStartOnce() + if err := m.maybeRunUserPromptSubmit(&req); err != nil { + m.err = err return } } @@ -191,6 +265,7 @@ func (m *ModelResult) run() { } } m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) + turnStartedAt := time.Now() res, err := m.client.SendResponse(m.ctx, req, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) if err != nil { m.err = err @@ -212,6 +287,15 @@ func (m *ModelResult) run() { m.err = err return } + turnType := ModelCallTurnTypeToolRound + if turn == 0 { + if isResume { + turnType = ModelCallTurnTypeResume + } else { + turnType = ModelCallTurnTypeInitial + } + } + m.emitPostModelCall(resp, turnStartedAt, turnType, turn+1) text := ExtractTextFromResponse(resp) if len(events) == 0 && text != "" { m.textStream.Push(text) @@ -253,80 +337,123 @@ func (m *ModelResult) run() { m.err = err return } else if stop { - if !allowFinalResponseEnabled(m.input.AllowFinalResponse) { - m.state.Status = ConversationStatusComplete - break - } - approved, pending, err := PartitionToolCalls(m.ctx, m.input.Tools, calls, BuildTurnContext(nil, turn, &req), m.input.Approval) + resume, err := m.runStopHook(&req, forceResumeCount) if err != nil { m.err = err return } - if len(pending) > 0 { - unsent, err := m.executeAutoApproveTools(approved, turn, &req) + if resume { + // A Stop-hook handler forced resumption (optionally after + // injecting an AppendPrompt message). Do NOT treat this as a + // halt: fall through to the same tool-execution + continue + // path used when stopWhen didn't fire at all, so the run + // proceeds with a fresh model call next iteration. + forceResumeCount++ + } else { + forceResumeCount = 0 + if !allowFinalResponseEnabled(m.input.AllowFinalResponse) { + m.state.Status = ConversationStatusComplete + break + } + approved, pending, err := PartitionToolCalls(m.ctx, m.input.Tools, calls, BuildTurnContext(nil, turn, &req), m.input.Approval) if err != nil { m.err = err return } - m.state.UnsentToolResults = append(m.state.UnsentToolResults, unsent...) - m.state.PendingToolCalls = pending - m.state.Status = ConversationStatusAwaitingApproval - break - } - outputs, err := m.executeToolCallsForTurn(approved, turn, &req) - if err != nil { - m.err = err - return - } - if m.state.Status == ConversationStatusInterrupted || m.state.Status == ConversationStatusAwaitingHITL { + var deniedOutputs []components.InputsUnion1 + if len(pending) > 0 { + gateApproved, stillPending, gateDenied, gateErr := m.applyPermissionRequestGate(pending) + if gateErr != nil { + m.err = gateErr + return + } + approved = append(approved, gateApproved...) + deniedOutputs = gateDenied + if len(stillPending) > 0 { + unsent, err := m.executeAutoApproveTools(approved, turn, &req) + if err != nil { + m.err = err + return + } + m.state.UnsentToolResults = append(m.state.UnsentToolResults, unsent...) + m.state.PendingToolCalls = stillPending + m.state.Status = ConversationStatusAwaitingApproval + break + } + } + outputs, err := m.executeToolCallsForTurn(approved, turn, &req) + if err != nil { + m.err = err + return + } + outputs = append(outputs, deniedOutputs...) + if isPausedStatus(m.state.Status) { + break + } + finalResp, err := m.sendFinalResponseRequest(req, resp, outputs, turn+1) + if err != nil { + m.err = err + return + } + m.mu.Lock() + m.resp = finalResp + m.steps = append(m.steps, StepResult{Text: ExtractTextFromResponse(finalResp), Response: finalResp, Usage: usagePtr(finalResp), FinishReason: string(finalResp.Status)}) + m.mu.Unlock() + finalItems, err := responseInputItemsWithError(finalResp) + if err != nil { + m.err = err + return + } + m.state = appendResponseItemsToState(m.state, finalResp, finalItems) + m.state.Status = ConversationStatusComplete break } - finalResp, err := m.sendFinalResponseRequest(req, resp, outputs, turn+1) - if err != nil { - m.err = err - return - } - m.mu.Lock() - m.resp = finalResp - m.steps = append(m.steps, StepResult{Text: ExtractTextFromResponse(finalResp), Response: finalResp, Usage: usagePtr(finalResp), FinishReason: string(finalResp.Status)}) - m.mu.Unlock() - finalItems, err := responseInputItemsWithError(finalResp) - if err != nil { - m.err = err - return - } - m.state = appendResponseItemsToState(m.state, finalResp, finalItems) - m.state.Status = ConversationStatusComplete - break } approved, pending, err := PartitionToolCalls(m.ctx, m.input.Tools, calls, BuildTurnContext(nil, turn, &req), m.input.Approval) if err != nil { m.err = err return } + var deniedOutputs []components.InputsUnion1 if len(pending) > 0 { - // Mixed approval turn (Run-2 punch-list item 14): execute the - // auto-approved calls and persist their outputs as unsent results - // *before* pausing for the approval-required calls, matching - // upstream handleApprovalCheck -> executeAutoApproveTools ordering. - unsent, err := m.executeAutoApproveTools(approved, turn, &req) - if err != nil { - m.err = err + gateApproved, stillPending, gateDenied, gateErr := m.applyPermissionRequestGate(pending) + if gateErr != nil { + m.err = gateErr return } - m.state.UnsentToolResults = append(m.state.UnsentToolResults, unsent...) - m.state.PendingToolCalls = pending - m.state.Status = ConversationStatusAwaitingApproval - break + approved = append(approved, gateApproved...) + deniedOutputs = gateDenied + if len(stillPending) > 0 { + // Mixed approval turn (Run-2 punch-list item 14): execute the + // auto-approved calls and persist their outputs as unsent results + // *before* pausing for the approval-required calls, matching + // upstream handleApprovalCheck -> executeAutoApproveTools ordering. + unsent, err := m.executeAutoApproveTools(approved, turn, &req) + if err != nil { + m.err = err + return + } + m.state.UnsentToolResults = append(m.state.UnsentToolResults, unsent...) + m.state.PendingToolCalls = stillPending + m.state.Status = ConversationStatusAwaitingApproval + break + } } outputs, err := m.executeToolCallsForTurn(approved, turn, &req) if err != nil { m.err = err return } - if m.state.Status == ConversationStatusInterrupted || m.state.Status == ConversationStatusAwaitingHITL { + outputs = append(outputs, deniedOutputs...) + if isPausedStatus(m.state.Status) { break } + if len(outputs) > 0 { + // A tool round with observable progress resets the consecutive + // forceResume counter so an earlier override doesn't count + // against a later, independent one (mirrors upstream). + forceResumeCount = 0 + } base := requestInputItems(req.Input) base = append(base, responseItems...) base = append(base, outputs...) @@ -336,6 +463,33 @@ func (m *ModelResult) run() { m.state.PreviousResponseID = &resp.ID } } + // Mini-class models intermittently return an empty final turn after a + // successful tool round (the tool call itself was the answer). Retry + // once so a completed run doesn't surface an empty answer when the model + // simply needs a nudge. StrictFinalResponse opts out of the retry + // (legacy behavior): unlike upstream, this never turns into a hard + // error — go-sdk's OpenResponsesResult carries a separate `OutputText` + // convenience field alongside `Output` items, so an empty `Output` array + // is not on its own a reliable "invalid response" signal the way it is + // upstream, where `output` is the sole content carrier. See + // upstreamer-changelog.md for this compatibility note. + if m.state.Status == ConversationStatusComplete && m.toolRoundsExecuted > 0 && !m.input.StrictFinalResponse && isEmptyFinalResponse(m.resp) { + retryResp, err := m.retryCurrentRequest(req, len(m.steps)+1) + if err != nil { + m.err = err + return + } + m.mu.Lock() + m.resp = retryResp + m.steps = append(m.steps, StepResult{Text: ExtractTextFromResponse(retryResp), Response: retryResp, Usage: usagePtr(retryResp), FinishReason: string(retryResp.Status)}) + m.mu.Unlock() + retryItems, err := responseInputItemsWithError(retryResp) + if err != nil { + m.err = err + return + } + m.state = appendResponseItemsToState(m.state, retryResp, retryItems) + } if m.input.StateAccessor != nil { if err := m.input.StateAccessor.Save(m.ctx, m.state); err != nil { m.err = err @@ -343,6 +497,21 @@ func (m *ModelResult) run() { } } +// isEmptyFinalResponse reports whether resp carries no assistant-visible +// content at all: no Output items and no text via ExtractTextFromResponse +// (which also checks the SDK's OutputText convenience field). +func isEmptyFinalResponse(resp components.OpenResponsesResult) bool { + return len(resp.Output) == 0 && ExtractTextFromResponse(resp) == "" +} + +// isPausedStatus reports whether status represents a mid-turn pause that +// should stop the loop without sending a (necessarily incomplete) follow-up +// request: an interruption, a HITL pause, or unresolved manual/client tool +// calls (upstream #64's awaiting_client_tools). +func isPausedStatus(status ConversationStatus) bool { + return status == ConversationStatusInterrupted || status == ConversationStatusAwaitingHITL || status == ConversationStatusAwaitingClientTools +} + func consumeCreateResponse(res *operations.CreateResponsesResponse, onEvent func(components.StreamEvents)) (components.OpenResponsesResult, []components.StreamEvents, error) { if res == nil { return components.OpenResponsesResult{}, nil, errors.New("nil response") @@ -385,17 +554,14 @@ func isAwaitingResume(state ConversationState) bool { return state.Status == ConversationStatusAwaitingApproval || state.Status == ConversationStatusAwaitingHITL } +// allowFinalResponseEnabled reports whether the forced final ("toolChoice: +// none") turn is enabled for v. Default-on: nil, bare true, and any string +// (including "") enable it; only explicit `false` opts out (upstream #68). func allowFinalResponseEnabled(v any) bool { - switch x := v.(type) { - case nil: - return false - case bool: - return x - case string: - return true - default: - return false + if b, ok := v.(bool); ok { + return b } + return true } func callKey(call ParsedToolCall) string { @@ -429,22 +595,33 @@ func (m *ModelResult) prepareResumeRequest(req *components.ResponsesRequest) (bo unsent = append(unsent, CreateRejectedResult(call, "Tool not found or not executable")) continue } - result, err := ExecuteTool(m.ctx, t, call, BuildToolExecuteContext(call, BuildTurnContext(nil, turn, req), m.store, func(v any) { - m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Event: v, Turn: turn}) - })) + source := ToolSourceClient + if IsMcpTool(t) { + source = ToolSourceMCP + } + execCtx := BuildToolExecuteContext(call, BuildTurnContext(nil, turn, req), m.store, func(v any) { + m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Source: source, Event: v, Turn: turn}) + }) + effectiveCall, result, blocked, err := m.runToolWithHooks(t, call, execCtx) if err != nil { return false, err } + m.toolRoundsExecuted++ + if blocked { + unsent = append(unsent, CreateRejectedResult(effectiveCall, result.Error.Error())) + m.toolStream.Push(ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Source: source, Error: result.Error.Error(), Turn: turn}) + continue + } if errors.Is(result.Error, ErrHITLPause) { - remaining = append(remaining, call) + remaining = append(remaining, effectiveCall) continue } if result.Error != nil { - unsent = append(unsent, CreateRejectedResult(call, result.Error.Error())) + unsent = append(unsent, CreateRejectedResult(effectiveCall, result.Error.Error())) } else { - unsent = append(unsent, CreateUnsentResult(call, result.Output)) + unsent = append(unsent, CreateUnsentResult(effectiveCall, result.Output)) } - m.toolStream.Push(ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Result: result.Output, Turn: turn}) + m.toolStream.Push(ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Source: source, Result: result.Output, Turn: turn}) default: remaining = append(remaining, call) } @@ -490,6 +667,50 @@ func (m *ModelResult) prepareResumeRequest(req *components.ResponsesRequest) (bo // // A HITL tool that pauses (returns ErrHITLPause) yields no unsent result for // that call this round, matching upstream's null return. +// applyPermissionRequestGate runs the PermissionRequest hook for each tool +// call that needs human approval, letting hooks short-circuit the approval +// flow in either direction: `allow` promotes the call past the gate (so the +// normal round executes it once, appended to approved), `deny` synthesizes a +// rejection (appended to deniedOutputs as a ready-to-send output item — +// there's no pause to persist it against, so it must be sent this round, not +// stashed as an UnsentToolResult), `ask_user` (the default, and always the +// decision when no HooksManager is configured) falls through to the human +// approval flow (left in stillPending, which the caller should pause on if +// non-empty). +func (m *ModelResult) applyPermissionRequestGate(pending []ParsedToolCall) (approved []ParsedToolCall, stillPending []ParsedToolCall, deniedOutputs []components.InputsUnion1, err error) { + if m.hooksManager == nil { + return nil, pending, nil, nil + } + var denied []UnsentToolResult + for _, call := range pending { + decision, reason, hookErr := m.emitPermissionRequest(call) + if hookErr != nil { + return nil, nil, nil, hookErr + } + switch decision { + case PermissionDecisionAllow: + approved = append(approved, call) + case PermissionDecisionDeny: + if reason == "" { + reason = "Denied by PermissionRequest hook" + } + denied = append(denied, CreateRejectedResult(call, reason)) + default: + stillPending = append(stillPending, call) + } + } + if len(denied) > 0 { + deniedOutputs, err = UnsentResultsToAPIFormatWithError(denied) + if err != nil { + return nil, nil, nil, err + } + for _, item := range deniedOutputs { + m.state = AppendToMessages(m.state, item) + } + } + return approved, stillPending, deniedOutputs, nil +} + func (m *ModelResult) executeAutoApproveTools(calls []ParsedToolCall, turn int, req *components.ResponsesRequest) ([]UnsentToolResult, error) { var results []UnsentToolResult for _, call := range calls { @@ -499,24 +720,33 @@ func (m *ModelResult) executeAutoApproveTools(calls []ParsedToolCall, turn int, // returns null and contributes no unsent result this round). continue } + source := ToolSourceClient + if IsMcpTool(t) { + source = ToolSourceMCP + } execCtx := BuildToolExecuteContext(call, BuildTurnContext(nil, turn, req), m.store, func(v any) { - m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Event: v, Turn: turn}) + m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Source: source, Event: v, Turn: turn}) }) - result, err := ExecuteTool(m.ctx, t, call, execCtx) + effectiveCall, result, blocked, err := m.runToolWithHooks(t, call, execCtx) if err != nil { return nil, err } + m.toolRoundsExecuted++ + if blocked { + results = append(results, CreateRejectedResult(effectiveCall, result.Error.Error())) + continue + } if errors.Is(result.Error, ErrHITLPause) { // HITL tool paused during auto-approve — no unsent result this round. continue } if result.Error != nil { - results = append(results, CreateRejectedResult(call, result.Error.Error())) + results = append(results, CreateRejectedResult(effectiveCall, result.Error.Error())) continue } - event := ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Result: result.Output, Turn: turn} + event := ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Source: source, Result: result.Output, Turn: turn} m.toolStream.Push(event) - results = append(results, CreateUnsentResult(call, result.Output)) + results = append(results, CreateUnsentResult(effectiveCall, result.Output)) } return results, nil } @@ -535,23 +765,44 @@ func (m *ModelResult) executeToolCallsForTurn(calls []ParsedToolCall, turn int, for _, call := range calls { t := FindToolByName(m.input.Tools, call.Name) if t == nil || IsManualTool(t) { + // Unresolved manual (client-executed) tool call: neither an + // `execute` fn nor an `OnToolCalled` hook. Persist as + // awaiting_client_tools (upstream #64) rather than the generic + // `interrupted` status, so callers can discriminate "the caller + // must execute this externally" from an arbitrary interruption. m.state.PendingToolCalls = append(m.state.PendingToolCalls, call) - m.state.Status = ConversationStatusInterrupted + m.state.Status = ConversationStatusAwaitingClientTools continue } + source := ToolSourceClient + if IsMcpTool(t) { + source = ToolSourceMCP + } execCtx := BuildToolExecuteContext(call, BuildTurnContext(nil, turn, req), m.store, func(v any) { - m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Event: v, Turn: turn}) + m.toolStream.Push(ToolStreamEvent{Type: "tool.preliminary", CallID: call.CallID, Name: call.Name, Source: source, Event: v, Turn: turn}) }) - result, err := ExecuteTool(m.ctx, t, call, execCtx) + effectiveCall, result, blocked, err := m.runToolWithHooks(t, call, execCtx) if err != nil { return nil, err } + m.toolRoundsExecuted++ + if blocked { + out, ferr := FormatToolOutputWithError(result) + if ferr != nil { + return nil, ferr + } + m.toolStream.Push(ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Source: source, Error: result.Error.Error(), Turn: turn}) + outputItem := components.CreateInputsUnion1FunctionCallOutputItem(out) + outputs = append(outputs, outputItem) + m.state = AppendToMessages(m.state, outputItem) + continue + } if errors.Is(result.Error, ErrHITLPause) { - m.state.PendingToolCalls = append(m.state.PendingToolCalls, call) + m.state.PendingToolCalls = append(m.state.PendingToolCalls, effectiveCall) m.state.Status = ConversationStatusAwaitingHITL break } - event := ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Result: result.Output, Turn: turn} + event := ToolStreamEvent{Type: "tool.result", CallID: call.CallID, Name: call.Name, Source: source, Result: result.Output, Turn: turn} if result.Error != nil { event.Error = result.Error.Error() } @@ -560,7 +811,7 @@ func (m *ModelResult) executeToolCallsForTurn(calls []ParsedToolCall, turn int, if err != nil { return nil, err } - m.toolStream.Push(ToolStreamEvent{Type: "tool.call.output", CallID: call.CallID, Name: call.Name, Output: result.Output, Error: event.Error, Turn: turn}) + m.toolStream.Push(ToolStreamEvent{Type: "tool.call.output", CallID: call.CallID, Name: call.Name, Source: source, Output: result.Output, Error: event.Error, Turn: turn}) outputItem := components.CreateInputsUnion1FunctionCallOutputItem(out) outputs = append(outputs, outputItem) // Persist this output to state immediately so it survives a later HITL @@ -570,9 +821,32 @@ func (m *ModelResult) executeToolCallsForTurn(calls []ParsedToolCall, turn int, return outputs, nil } +// finalResponseDirective resolves the AllowFinalResponse option to the +// directive text appended as a final user message: default-on (nil) and +// bare true get DefaultFinalResponseDirective; a string (including "") +// overrides the wording — "" means "append nothing". +func finalResponseDirective(v any) string { + if s, ok := v.(string); ok { + return s + } + return DefaultFinalResponseDirective +} + +// noneToolChoice is the API tool_choice value that forbids tool calls while +// leaving `tools` in the request — stripping `tools` entirely would bust the +// prompt-cache prefix. +func noneToolChoice() *components.OpenAIResponsesToolChoiceUnion { + choice := components.CreateOpenAIResponsesToolChoiceUnionOpenAIResponsesToolChoiceNone(components.OpenAIResponsesToolChoiceNoneNone) + return &choice +} + func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, resp components.OpenResponsesResult, outputs []components.InputsUnion1, turn int) (components.OpenResponsesResult, error) { finalReq := req - finalReq.Tools = nil + // Forbid tool calls without dropping `tools` — removing it would + // invalidate the prompt-cache prefix (upstream #68). + if len(finalReq.Tools) > 0 { + finalReq.ToolChoice = noneToolChoice() + } base := requestInputItems(req.Input) items, err := responseInputItemsWithError(resp) if err != nil { @@ -580,12 +854,13 @@ func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, } base = append(base, items...) base = append(base, outputs...) - if s, ok := m.input.AllowFinalResponse.(string); ok && s != "" { - msg := components.EasyInputMessage{Role: components.CreateEasyInputMessageRoleUnionEasyInputMessageRoleUser(components.EasyInputMessageRoleUserUser), Content: optionalnullable.From(openrouter.Pointer(components.CreateEasyInputMessageContentUnion2Str(s)))} + if directive := finalResponseDirective(m.input.AllowFinalResponse); directive != "" { + msg := components.EasyInputMessage{Role: components.CreateEasyInputMessageRoleUnionEasyInputMessageRoleUser(components.EasyInputMessageRoleUserUser), Content: optionalnullable.From(openrouter.Pointer(components.CreateEasyInputMessageContentUnion2Str(directive)))} base = append(base, components.CreateInputsUnion1EasyInputMessage(msg)) } finalReq.Input = openrouter.Pointer(components.CreateInputsUnionArrayOfInputsUnion1(base)) m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) + startedAt := time.Now() res, err := m.client.SendResponse(m.ctx, finalReq, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) if err != nil { return components.OpenResponsesResult{}, err @@ -599,6 +874,7 @@ func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, if err != nil { return components.OpenResponsesResult{}, err } + m.emitPostModelCall(finalResp, startedAt, ModelCallTurnTypeFinal, turn) text := ExtractTextFromResponse(finalResp) if len(events) == 0 && text != "" { m.textStream.Push(text) @@ -608,12 +884,418 @@ func (m *ModelResult) sendFinalResponseRequest(req components.ResponsesRequest, return finalResp, nil } +// retryCurrentRequest re-sends req (the same accumulated input) once, used +// when a follow-up after tool execution returned an empty output array. +// ToolChoice is forced to none when tools are present (mirroring +// sendFinalResponseRequest) so the retry coerces a text turn instead of +// risking a fresh (unexecuted) function_call. +func (m *ModelResult) retryCurrentRequest(req components.ResponsesRequest, turn int) (components.OpenResponsesResult, error) { + retryReq := req + if len(retryReq.Tools) > 0 { + retryReq.ToolChoice = noneToolChoice() + } + m.fullStream.Push(ResponseStreamEvent{Type: "turn.start", Turn: turn}) + startedAt := time.Now() + res, err := m.client.SendResponse(m.ctx, retryReq, m.input.MetadataLevel, operations.WithSetHeaders(map[string]string{"x-openrouter-callmodel": "true"})) + if err != nil { + return components.OpenResponsesResult{}, err + } + retryResp, events, err := consumeCreateResponse(res, func(ev components.StreamEvents) { + if ev.TextDeltaEvent != nil { + m.textStream.Push(ev.TextDeltaEvent.Delta) + } + m.fullStream.Push(ResponseStreamEvent{Type: "response.event", Turn: turn, Event: &ev}) + }) + if err != nil { + return components.OpenResponsesResult{}, err + } + m.emitPostModelCall(retryResp, startedAt, ModelCallTurnTypeRetry, turn) + text := ExtractTextFromResponse(retryResp) + if len(events) == 0 && text != "" { + m.textStream.Push(text) + } + m.fullStream.Push(ResponseStreamEvent{Type: "response.completed", Turn: turn, Response: &retryResp}) + m.fullStream.Push(ResponseStreamEvent{Type: "turn.end", Turn: turn}) + return retryResp, nil +} + func usagePtr(resp components.OpenResponsesResult) *components.Usage { if usage, ok := resp.Usage.GetOrZero(); ok { return &usage } return nil } + +// extractModelCallUsage converts an SDK Usage into the hooks ModelCallUsage +// shape, or nil when resp carried no usage at all. +func extractModelCallUsage(resp components.OpenResponsesResult) *ModelCallUsage { + usage := usagePtr(resp) + if usage == nil { + return nil + } + out := &ModelCallUsage{ + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + TotalTokens: usage.TotalTokens, + CachedTokens: usage.InputTokensDetails.CachedTokens, + ReasoningTokens: usage.OutputTokensDetails.ReasoningTokens, + } + if cost, ok := usage.Cost.GetOrZero(); ok { + out.Cost = &cost + } + return out +} + +// hookEmitOptions builds the per-emit EmitOptions for lifecycle hook emits. +// Threads this run's session identity into every emit so a HooksManager +// instance shared across concurrent runs never leaks one run's id into +// another's handlers (the manager-level SetSessionID default is a single +// mutable field and would be clobbered by the last run to start). +func (m *ModelResult) hookEmitOptions(toolName string) EmitOptions { + return EmitOptions{ToolName: toolName, SessionID: m.state.ID} +} + +// emitSessionStartOnce emits SessionStart exactly once per run. +func (m *ModelResult) emitSessionStartOnce() { + if m.hooksManager == nil || m.sessionStartEmitted { + return + } + m.sessionStartEmitted = true + m.hooksManager.SetSessionID(m.state.ID) + if _, err := m.hooksManager.EmitSessionStart(SessionStartPayload{}, m.hookEmitOptions("")); err != nil { + log.Printf("[SessionStart] hook error: %v", err) + } +} + +// emitSessionEndOnce emits SessionEnd exactly once, and only when a matching +// SessionStart actually fired. Safe to call from multiple teardown paths; +// never returns an error since teardown must not mask the run's own outcome. +func (m *ModelResult) emitSessionEndOnce(reason SessionEndReason) { + if m.hooksManager == nil || !m.sessionStartEmitted || m.sessionEndEmitted { + return + } + m.sessionEndEmitted = true + payload := SessionEndPayload{Reason: reason} + if m.sessionUsage.modelCalls > 0 { + totals := SessionUsageTotals{ + ModelCallUsage: ModelCallUsage{ + InputTokens: m.sessionUsage.inputTokens, + OutputTokens: m.sessionUsage.outputTokens, + TotalTokens: m.sessionUsage.totalTokens, + CachedTokens: m.sessionUsage.cachedTokens, + ReasoningTokens: m.sessionUsage.reasoningTokens, + }, + ModelCalls: m.sessionUsage.modelCalls, + } + if m.sessionUsage.hasCost { + cost := m.sessionUsage.cost + totals.Cost = &cost + } + payload.TotalUsage = &totals + } + if _, err := m.hooksManager.EmitSessionEnd(payload, m.hookEmitOptions("")); err != nil { + log.Printf("[SessionEnd] hook error: %v", err) + } +} + +// finishHooksSession emits SessionEnd (if not already emitted) and drains +// pending hook work. Never panics: teardown must not mask the run's own +// error. Call unconditionally on every exit path (success or error) so +// fire-and-forget hook work is never silently dropped — including no-tools +// paths that error before any tool ever runs. +func (m *ModelResult) finishHooksSession(reason SessionEndReason) { + if m.hooksManager == nil { + return + } + m.emitSessionEndOnce(reason) + m.hooksManager.Drain() +} + +// emitPostModelCall emits PostModelCall for a completed model response and +// folds its usage into the session aggregate. One emit per materialized response. +func (m *ModelResult) emitPostModelCall(resp components.OpenResponsesResult, startedAt time.Time, turnType ModelCallTurnType, turnNumber int) { + if m.hooksManager == nil { + return + } + usage := extractModelCallUsage(resp) + m.sessionUsage.modelCalls++ + if usage != nil { + m.sessionUsage.inputTokens += usage.InputTokens + m.sessionUsage.outputTokens += usage.OutputTokens + m.sessionUsage.totalTokens += usage.TotalTokens + m.sessionUsage.cachedTokens += usage.CachedTokens + m.sessionUsage.reasoningTokens += usage.ReasoningTokens + if usage.Cost != nil { + m.sessionUsage.cost += *usage.Cost + m.sessionUsage.hasCost = true + } + } + payload := PostModelCallPayload{ + SessionID: m.state.ID, + ResponseID: resp.ID, + Model: resp.Model, + DurationMs: float64(time.Since(startedAt).Milliseconds()), + TurnType: turnType, + TurnNumber: turnNumber, + Usage: usage, + } + if _, err := m.hooksManager.EmitPostModelCall(payload, m.hookEmitOptions("")); err != nil { + log.Printf("[PostModelCall] hook error: %v", err) + } +} + +// maxForceResumeOverrides caps consecutive Stop-hook forceResume overrides so +// a misbehaving handler cannot spin the loop forever. +const maxForceResumeOverrides = 3 + +// runStopHook emits the Stop hook when a stopWhen condition halts the loop, +// and decides whether the loop should resume. +// +// - AppendPrompt values from all handlers are concatenated (newline +// separated) and injected as a user message, independently of ForceResume. +// - ForceResume is honored if ANY handler returns it, capped at +// maxForceResumeOverrides consecutive overrides without tool progress. +// +// Returns true when the loop should resume, false to stop for real. No +// HooksManager means "no hook to consult" -> always stop. +func (m *ModelResult) runStopHook(req *components.ResponsesRequest, forceResumeCount int) (bool, error) { + if m.hooksManager == nil { + return false, nil + } + + stopResult, err := m.hooksManager.EmitStop(StopPayload{Reason: StopReasonMaxTurns}, m.hookEmitOptions("")) + if err != nil { + return false, fmt.Errorf("Stop hook: %w", err) + } + + shouldForceResume := false + var appendPrompts []string + for _, r := range stopResult.Results { + if r.ForceResume { + shouldForceResume = true + } + if r.AppendPrompt != "" { + appendPrompts = append(appendPrompts, r.AppendPrompt) + } + } + if len(appendPrompts) > 0 { + m.injectAppendPromptMessage(req, strings.Join(appendPrompts, "\n")) + } + + if !shouldForceResume { + return false, nil + } + if forceResumeCount >= maxForceResumeOverrides { + // Don't let the hook loop the engine forever. Log and stop. + log.Printf("[Stop hook] forceResume honored %d times without new progress; stopping to prevent an infinite loop.", maxForceResumeOverrides) + return false, nil + } + return true, nil +} + +// injectAppendPromptMessage appends prompt as a user message to both the +// in-memory conversation state (so progress is observable even without a +// StateAccessor — a bare ForceResume needs state to change to avoid looping) +// and the in-flight request's accumulated input (so it reaches the next +// model call). +func (m *ModelResult) injectAppendPromptMessage(req *components.ResponsesRequest, prompt string) { + msg := components.EasyInputMessage{Role: components.CreateEasyInputMessageRoleUnionEasyInputMessageRoleUser(components.EasyInputMessageRoleUserUser), Content: optionalnullable.From(openrouter.Pointer(components.CreateEasyInputMessageContentUnion2Str(prompt)))} + item := components.CreateInputsUnion1EasyInputMessage(msg) + m.state = AppendToMessages(m.state, item) + + base := requestInputItems(req.Input) + base = append(base, item) + req.Input = openrouter.Pointer(components.CreateInputsUnionArrayOfInputsUnion1(base)) +} + +// emitPermissionRequest emits the PermissionRequest hook before the SDK +// blocks for user approval. Returns the hook's collective decision: +// allow (proceed as if auto-approved), deny (synthesize a rejection without +// running the tool), or ask_user (fall through to the existing approval +// flow — the default, and what a nil HooksManager always returns). +// Last-wins when multiple handlers return conflicting decisions. +func (m *ModelResult) emitPermissionRequest(call ParsedToolCall) (PermissionDecision, string, error) { + if m.hooksManager == nil { + return PermissionDecisionAskUser, "", nil + } + // Raw-string arguments mean the model produced invalid JSON. Fail closed + // (fall through to the human approval flow) rather than emitting a + // malformed payload — mirrors the guard in runToolWithHooks. + if call.RawArgs != "" && call.Arguments == nil { + if _, ok := toolInputFromRawArgs(call.RawArgs); !ok { + return PermissionDecisionAskUser, "", nil + } + } + + // Derive risk level from the approval gate's shape: a callback (tool- or + // call-level requireApproval function) => high (caller actively decides + // per call), blanket true => medium, otherwise low. + t := FindToolByName(m.input.Tools, call.Name) + riskLevel := RiskLevelLow + if m.input.Approval != nil { + riskLevel = RiskLevelHigh + } else if t != nil { + if approvalTool, ok := t.(interface { + RequiresApproval(context.Context, ParsedToolCall, TurnContext) (bool, error) + }); ok { + if req, err := approvalTool.RequiresApproval(m.ctx, call, TurnContext{}); err == nil && req { + riskLevel = RiskLevelMedium + } + } + } + + emit, err := m.hooksManager.EmitPermissionRequest(PermissionRequestPayload{ToolName: call.Name, ToolInput: toolInputMap(call), RiskLevel: riskLevel}, m.hookEmitOptions(call.Name)) + if err != nil { + return PermissionDecisionAskUser, "", fmt.Errorf("PermissionRequest hook: %w", err) + } + if len(emit.Results) == 0 { + return PermissionDecisionAskUser, "", nil + } + last := emit.Results[len(emit.Results)-1] + return last.Decision, last.Reason, nil +} + +// toolInputFromRawArgs reports whether rawArgs is valid JSON, mirroring the +// "raw-string arguments" fail-closed guard shared by runToolWithHooks and +// emitPermissionRequest. +func toolInputFromRawArgs(rawArgs string) (map[string]any, bool) { + var decoded map[string]any + if err := json.Unmarshal([]byte(rawArgs), &decoded); err != nil { + return nil, false + } + return decoded, true +} + +// maybeRunUserPromptSubmit runs the UserPromptSubmit hook against the latest +// user-role string message in req's accumulated input (mirroring upstream's +// extractPromptAndApplier), mutating that message's content in place when a +// handler returns MutatedPrompt. A structured input with no plain-string +// user message (e.g. image content, or no user message at all) has nothing +// for the hook to inspect, so it is skipped without error — matching +// upstream's dev-only warn-and-skip behavior. +// +// Returns an error if any handler rejects the prompt. +func (m *ModelResult) maybeRunUserPromptSubmit(req *components.ResponsesRequest) error { + if m.hooksManager == nil || req.Input == nil { + return nil + } + items := requestInputItems(req.Input) + idx, prompt, ok := latestUserStringMessage(items) + if !ok { + return nil + } + + emit, err := m.hooksManager.EmitUserPromptSubmit(UserPromptSubmitPayload{Prompt: prompt}, m.hookEmitOptions("")) + if err != nil { + return fmt.Errorf("UserPromptSubmit hook: %w", err) + } + if emit.Blocked { + reason := emit.BlockReason + if reason == "" { + reason = "Prompt rejected by hook" + } + return errors.New(reason) + } + if !emit.Mutated { + return nil + } + mutated := emit.FinalPayload.Prompt + msg := *items[idx].EasyInputMessage + msg.Content = optionalnullable.From(openrouter.Pointer(components.CreateEasyInputMessageContentUnion2Str(mutated))) + items[idx] = components.CreateInputsUnion1EasyInputMessage(msg) + req.Input = openrouter.Pointer(components.CreateInputsUnionArrayOfInputsUnion1(items)) + return nil +} + +// latestUserStringMessage finds the last user-role EasyInputMessage in items +// whose content is a plain string, returning its index and text. +func latestUserStringMessage(items []components.InputsUnion1) (int, string, bool) { + for i := len(items) - 1; i >= 0; i-- { + msg := items[i].EasyInputMessage + if msg == nil || msg.Role.EasyInputMessageRoleUser == nil { + continue + } + content, ok := msg.Content.GetOrZero() + if !ok || content.Str == nil { + continue + } + return i, *content.Str, true + } + return -1, "", false +} + +// runToolWithHooks executes one tool call and emits the PreToolUse/ +// PostToolUse/PostToolUseFailure lifecycle hooks around it, so every +// execution path (auto-approve, manual approval, approved-on-resume) fires +// them consistently. Returns the (possibly input-mutated) call actually +// executed, the execution result, whether PreToolUse blocked execution (in +// which case result carries a synthetic rejection and the tool never ran), +// and a fatal (non-tool) error such as a request-encoding failure — this +// last one should abort the run exactly like a direct ExecuteTool error +// would, not be treated as a per-call rejection. +func (m *ModelResult) runToolWithHooks(t Tool, call ParsedToolCall, execCtx ToolExecuteContext) (effectiveCall ParsedToolCall, result ToolExecutionResult, blocked bool, fatalErr error) { + effectiveCall = call + if m.hooksManager == nil { + res, err := ExecuteTool(m.ctx, t, effectiveCall, execCtx) + if err != nil { + return effectiveCall, ToolExecutionResult{}, false, err + } + return effectiveCall, res, false, nil + } + + toolInput := toolInputMap(call) + preResult, err := m.hooksManager.EmitPreToolUse(PreToolUsePayload{ToolName: call.Name, ToolInput: toolInput}, m.hookEmitOptions(call.Name)) + if err != nil { + return effectiveCall, ToolExecutionResult{}, false, fmt.Errorf("PreToolUse hook: %w", err) + } + if preResult.Blocked { + reason := preResult.BlockReason + if reason == "" { + reason = "Blocked by PreToolUse hook" + } + return effectiveCall, ToolExecutionResult{CallID: call.CallID, Name: call.Name, Error: errors.New(reason)}, true, nil + } + if preResult.Mutated { + effectiveCall.Arguments = preResult.FinalPayload.ToolInput + effectiveCall.RawArgs = "" + } + + startedAt := time.Now() + execCtx.ToolCall = effectiveCall + res, err := ExecuteTool(m.ctx, t, effectiveCall, execCtx) + if err != nil { + return effectiveCall, ToolExecutionResult{}, false, err + } + durationMs := float64(time.Since(startedAt).Milliseconds()) + + if res.Error != nil { + if _, hookErr := m.hooksManager.EmitPostToolUseFailure(PostToolUseFailurePayload{ToolName: effectiveCall.Name, ToolInput: toolInputMap(effectiveCall), Error: res.Error}, m.hookEmitOptions(effectiveCall.Name)); hookErr != nil { + log.Printf("[PostToolUseFailure] hook error: %v", hookErr) + } + } else { + if _, hookErr := m.hooksManager.EmitPostToolUse(PostToolUsePayload{ToolName: effectiveCall.Name, ToolInput: toolInputMap(effectiveCall), ToolOutput: res.Output, DurationMs: durationMs}, m.hookEmitOptions(effectiveCall.Name)); hookErr != nil { + log.Printf("[PostToolUse] hook error: %v", hookErr) + } + } + return effectiveCall, res, false, nil +} + +// toolInputMap best-effort converts a ParsedToolCall's arguments to +// map[string]any for hook payloads, coercing null/missing arguments to an +// empty map (mirroring upstream's PreToolUse payload construction). +func toolInputMap(call ParsedToolCall) map[string]any { + if m, ok := call.Arguments.(map[string]any); ok { + return m + } + if call.RawArgs != "" { + var decoded map[string]any + if err := json.Unmarshal([]byte(call.RawArgs), &decoded); err == nil { + return decoded + } + } + return map[string]any{} +} + func (m *ModelResult) Text(ctx context.Context) (string, error) { m.ensure() return ExtractTextFromResponse(m.resp), m.err diff --git a/model_result_hooks_test.go b/model_result_hooks_test.go new file mode 100644 index 0000000..10c19ac --- /dev/null +++ b/model_result_hooks_test.go @@ -0,0 +1,864 @@ +package agent + +import ( + "context" + "testing" + + openrouter "github.com/OpenRouterTeam/go-sdk" + "github.com/OpenRouterTeam/go-sdk/models/components" + "github.com/OpenRouterTeam/go-sdk/models/operations" + "github.com/OpenRouterTeam/go-sdk/optionalnullable" +) + +func searchTool(t *testing.T) *TypedTool[sampleInput] { + t.Helper() + return MustNewTool(ToolConfig[sampleInput]{Name: "search", Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return "tool output", nil + }}) +} + +func twoTurnSender(first, second components.OpenResponsesResult) *fakeSender { + createdFirst := operations.CreateCreateResponsesResponseOpenResponsesResult(first) + createdSecond := operations.CreateCreateResponsesResponseOpenResponsesResult(second) + return &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFirst, &createdSecond}} +} + +// TestAllowFinalResponseDefaultOnOmitted verifies upstream #68: omitting +// AllowFinalResponse behaves like bare `true` (default-on), not like `false`. +func TestAllowFinalResponseDefaultOnOmitted(t *testing.T) { + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("final")} + sender := twoTurnSender(first, second) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{searchTool(t)}, + StopWhen: []StopCondition{StepCountIs(1)}, + // AllowFinalResponse intentionally omitted (nil). + }) + if err != nil { + t.Fatal(err) + } + text, err := result.Text(context.Background()) + if err != nil { + t.Fatal(err) + } + if text != "final" { + t.Fatalf("expected the default-on final turn to run, got text=%q", text) + } + if sender.calls != 2 { + t.Fatalf("expected initial + forced-final request even with AllowFinalResponse omitted, got %d calls", sender.calls) + } +} + +// TestAllowFinalResponseEmptyStringSuppressesDirective verifies `”` still +// enables the final turn (tool calls forbidden) but appends no directive message. +func TestAllowFinalResponseEmptyStringSuppressesDirective(t *testing.T) { + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("final")} + sender := twoTurnSender(first, second) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{searchTool(t)}, + StopWhen: []StopCondition{StepCountIs(1)}, AllowFinalResponse: "", + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + if sender.calls != 2 { + t.Fatalf("expected a forced-final request even for '' (calls forbidden, no message), got %d", sender.calls) + } + items := sender.requests[1].Input.ArrayOfInputsUnion1 + for _, item := range items { + if item.EasyInputMessage != nil { + content, ok := item.EasyInputMessage.Content.GetOrZero() + if ok && content.Str != nil && *content.Str == DefaultFinalResponseDirective { + t.Fatalf("'' should not append the default directive") + } + } + } +} + +// TestAllowFinalResponseFalseDisablesFinalTurn verifies explicit `false` +// stops the run on the halted tool-call turn with no forced final request. +func TestAllowFinalResponseFalseDisablesFinalTurn(t *testing.T) { + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("should not be requested")} + sender := twoTurnSender(first, second) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{searchTool(t)}, + StopWhen: []StopCondition{StepCountIs(1)}, AllowFinalResponse: false, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + if sender.calls != 1 { + t.Fatalf("AllowFinalResponse=false must not issue a forced-final request, got %d calls", sender.calls) + } +} + +// TestAllowFinalResponseCustomStringOverridesWording verifies a non-empty +// string replaces the default directive wording. +func TestAllowFinalResponseCustomStringOverridesWording(t *testing.T) { + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("final")} + sender := twoTurnSender(first, second) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{searchTool(t)}, + StopWhen: []StopCondition{StepCountIs(1)}, AllowFinalResponse: "please summarize", + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + items := sender.requests[1].Input.ArrayOfInputsUnion1 + var found bool + for _, item := range items { + if item.EasyInputMessage != nil { + content, ok := item.EasyInputMessage.Content.GetOrZero() + if ok && content.Str != nil && *content.Str == "please summarize" { + found = true + } + } + } + if !found { + t.Fatalf("expected custom directive string to be appended, got %#v", items) + } +} + +// TestAwaitingClientToolsStatusForUnresolvedManualTool covers upstream #64: +// an unresolved manual tool call (no execute fn, no OnToolCalled) pauses the +// loop with the distinct `awaiting_client_tools` status, and a regular tool +// in the same turn still executes and persists its output before the pause. +func TestAwaitingClientToolsStatusForUnresolvedManualTool(t *testing.T) { + manual := MustNewTool(ToolConfig[sampleInput]{Name: "manual_tool", Manual: true}) + regular := MustNewTool(ToolConfig[sampleInput]{Name: "auto", Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return map[string]any{"ok": true}, nil + }}) + autoCall := components.OutputFunctionCallItem{CallID: "call_auto", Name: "auto", Arguments: `{"query":"x"}`} + manualCall := components.OutputFunctionCallItem{CallID: "call_manual", Name: "manual_tool", Arguments: `{"query":"y"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{ + components.CreateOutputItemsFunctionCall(autoCall), + components.CreateOutputItemsFunctionCall(manualCall), + }} + createdFirst := operations.CreateCreateResponsesResponseOpenResponsesResult(first) + sender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFirst}} + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{manual, regular}, + }) + if err != nil { + t.Fatal(err) + } + state, err := result.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if state.Status != ConversationStatusAwaitingClientTools { + t.Fatalf("expected status awaiting_client_tools, got %q", state.Status) + } + if len(state.PendingToolCalls) != 1 || state.PendingToolCalls[0].Name != "manual_tool" { + t.Fatalf("expected the manual call to be pending, got %+v", state.PendingToolCalls) + } + if countFunctionCallOutputs(state.Messages) != 1 { + t.Fatalf("expected the regular tool's output to be persisted before the pause, messages=%+v", state.Messages) + } +} + +// TestHooksSessionAndPostModelCallLifecycle covers the required parity +// surface: SessionStart/SessionEnd fire exactly once, PostModelCall fires +// per model response with usage folded into SessionEnd's totals, and +// PreToolUse/PostToolUse fire around tool execution with a threaded session id. +func TestHooksSessionAndPostModelCallLifecycle(t *testing.T) { + tool := searchTool(t) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ + ID: "resp_1", Model: "openai/test", + Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}, + Usage: optionalUsage(10, 5), + } + second := components.OpenResponsesResult{ID: "resp_2", Model: "openai/test", OutputText: openrouter.String("done"), Usage: optionalUsage(3, 2)} + sender := twoTurnSender(first, second) + + manager := NewHooksManager() + var sessionStarts, sessionEnds, postModelCalls, preToolUses, postToolUses int + var sessionEndUsageCalls int + var sawSessionID string + manager.OnSessionStart(HookEntry[SessionStartPayload, EmptyHookResult]{ + Handler: func(payload SessionStartPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + sessionStarts++ + sawSessionID = hctx.SessionID + return VoidResult[EmptyHookResult](), nil + }, + }) + manager.OnSessionEnd(HookEntry[SessionEndPayload, EmptyHookResult]{ + Handler: func(payload SessionEndPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + sessionEnds++ + if payload.TotalUsage != nil { + sessionEndUsageCalls = payload.TotalUsage.ModelCalls + } + if hctx.SessionID != sawSessionID { + t.Errorf("SessionEnd session id %q should match SessionStart's %q", hctx.SessionID, sawSessionID) + } + return VoidResult[EmptyHookResult](), nil + }, + }) + manager.OnPostModelCall(HookEntry[PostModelCallPayload, EmptyHookResult]{ + Handler: func(payload PostModelCallPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + postModelCalls++ + return VoidResult[EmptyHookResult](), nil + }, + }) + manager.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + preToolUses++ + return VoidResult[PreToolUseResult](), nil + }, + }) + manager.OnPostToolUse(HookEntry[PostToolUsePayload, EmptyHookResult]{ + Handler: func(payload PostToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + postToolUses++ + return VoidResult[EmptyHookResult](), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, + StopWhen: []StopCondition{StepCountIs(1)}, AllowFinalResponse: true, + Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + + if sessionStarts != 1 { + t.Fatalf("expected exactly one SessionStart, got %d", sessionStarts) + } + if sessionEnds != 1 { + t.Fatalf("expected exactly one SessionEnd, got %d", sessionEnds) + } + if postModelCalls != 2 { + t.Fatalf("expected one PostModelCall per model response (initial + final), got %d", postModelCalls) + } + if preToolUses != 1 || postToolUses != 1 { + t.Fatalf("expected PreToolUse/PostToolUse to fire once around the tool execution, got pre=%d post=%d", preToolUses, postToolUses) + } + if sessionEndUsageCalls != 2 { + t.Fatalf("expected SessionEnd totalUsage.modelCalls to fold both model calls, got %d", sessionEndUsageCalls) + } + if sawSessionID == "" { + t.Fatalf("expected a non-empty session id to be threaded through hook emits") + } +} + +// TestHooksPreToolUseBlockSynthesizesRejection verifies a PreToolUse block +// prevents the tool from executing and synthesizes a rejected output instead. +func TestHooksPreToolUseBlockSynthesizesRejection(t *testing.T) { + var executed bool + tool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + executed = true + return "should not run", nil + }}) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "danger", Arguments: `{"query":"x"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + createdFirst := operations.CreateCreateResponsesResponseOpenResponsesResult(first) + sender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFirst}} + + manager := NewHooksManager() + manager.OnPreToolUse(HookEntry[PreToolUsePayload, PreToolUseResult]{ + Handler: func(payload PreToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[PreToolUseResult], error) { + return SyncResult(PreToolUseResult{BlockReason: "not allowed"}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + state, err := result.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if executed { + t.Fatalf("PreToolUse block must prevent tool execution") + } + if countFunctionCallOutputs(state.Messages) < 1 { + t.Fatalf("expected at least one synthesized rejected output, messages=%+v", state.Messages) + } +} + +func optionalUsage(input, output int64) optionalnullable.OptionalNullable[components.Usage] { + usage := components.Usage{InputTokens: input, OutputTokens: output, TotalTokens: input + output} + return optionalnullable.From(&usage) +} + +// TestHooksStopForceResumeContinuesLoopPastStopWhen covers upstream #7/#67's +// Stop hook: when stopWhen halts the loop mid-tool-call, a handler returning +// ForceResume must keep the run going (through CallModel, not just via a +// direct manager.EmitStop call) instead of forcing the final-response turn. +func TestHooksStopForceResumeContinuesLoopPastStopWhen(t *testing.T) { + tool := searchTool(t) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + sender := twoTurnSender(first, second) + + manager := NewHooksManager() + var stopCalls int + manager.OnStop(HookEntry[StopPayload, StopResult]{ + Handler: func(payload StopPayload, hctx LifecycleHookContext) (HookHandlerResult[StopResult], error) { + stopCalls++ + return SyncResult(StopResult{ForceResume: true}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, + StopWhen: []StopCondition{StepCountIs(1)}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + text, err := result.Text(context.Background()) + if err != nil { + t.Fatal(err) + } + if stopCalls == 0 { + t.Fatalf("expected the Stop hook to fire when stopWhen halted the loop") + } + // ForceResume means the run must NOT take the forced-final-response path + // (no directive message, tool executed normally, second turn is a + // genuine follow-up model call rather than a toolChoice:none coercion). + if text != "done" { + t.Fatalf("expected the forced-resume turn's real response text, got %q", text) + } + if sender.calls != 2 { + t.Fatalf("expected exactly two model calls (resumed past the stop), got %d", sender.calls) + } +} + +// TestHooksStopAppendPromptInjectsUserMessage verifies AppendPrompt is +// honored independently of ForceResume: the prompt lands in both state and +// the next request's input. +func TestHooksStopAppendPromptInjectsUserMessage(t *testing.T) { + tool := searchTool(t) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "search", Arguments: `{"query":"go"}`} + first := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + second := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + sender := twoTurnSender(first, second) + + manager := NewHooksManager() + manager.OnStop(HookEntry[StopPayload, StopResult]{ + Handler: func(payload StopPayload, hctx LifecycleHookContext) (HookHandlerResult[StopResult], error) { + return SyncResult(StopResult{ForceResume: true, AppendPrompt: "please wrap up"}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, + StopWhen: []StopCondition{StepCountIs(1)}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + found := false + for _, item := range requestInputItems(sender.requests[1].Input) { + if item.EasyInputMessage == nil { + continue + } + content, ok := item.EasyInputMessage.Content.GetOrZero() + if ok && content.Str != nil && *content.Str == "please wrap up" { + found = true + } + } + if !found { + t.Fatalf("expected AppendPrompt message to be injected into the next request") + } +} + +// TestHooksStopForceResumeCapsConsecutiveOverrides verifies the +// maxForceResumeOverrides cap: a Stop hook that always forces resume without +// any tool ever producing output eventually stops instead of looping forever +// (bounded, additionally, by MaxTurns either way). +func TestHooksStopForceResumeCapsConsecutiveOverrides(t *testing.T) { + manual := MustNewTool(ToolConfig[sampleInput]{Name: "manual_tool", Manual: true}) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "manual_tool", Arguments: `{"query":"go"}`} + resp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + created := operations.CreateCreateResponsesResponseOpenResponsesResult(resp) + sender := &fakeSender{responses: []*operations.CreateResponsesResponse{&created}} + + manager := NewHooksManager() + manager.OnStop(HookEntry[StopPayload, StopResult]{ + Handler: func(payload StopPayload, hctx LifecycleHookContext) (HookHandlerResult[StopResult], error) { + return SyncResult(StopResult{ForceResume: true}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{manual}, + StopWhen: []StopCondition{StepCountIs(1)}, MaxTurns: 50, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.State(context.Background()); err != nil { + t.Fatal(err) + } + // Manual tool calls never produce outputs, so forceResumeCount climbs on + // every stop; the run must terminate well before MaxTurns=50 turns. + if sender.calls > maxForceResumeOverrides+2 { + t.Fatalf("expected the forceResume cap to bound the loop, got %d model calls", sender.calls) + } +} + +// TestHooksPermissionRequestAllowBypassesApprovalGate verifies a +// PermissionRequest handler returning `allow` promotes an approval-gated +// call past the human-approval pause so it executes normally this round. +func TestHooksPermissionRequestAllowBypassesApprovalGate(t *testing.T) { + var executed bool + tool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + executed = true + return "done", nil + }}) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "danger", Arguments: `{"query":"x"}`} + resp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + final := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + sender := twoTurnSender(resp, final) + + manager := NewHooksManager() + var sawRisk RiskLevel + manager.OnPermissionRequest(HookEntry[PermissionRequestPayload, PermissionRequestResult]{ + Handler: func(payload PermissionRequestPayload, hctx LifecycleHookContext) (HookHandlerResult[PermissionRequestResult], error) { + sawRisk = payload.RiskLevel + return SyncResult(PermissionRequestResult{Decision: PermissionDecisionAllow}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + state, err := result.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if !executed { + t.Fatalf("PermissionRequest 'allow' should let the tool execute without pausing") + } + if state.Status == ConversationStatusAwaitingApproval { + t.Fatalf("run should not pause for approval when the hook allows the call") + } + if sawRisk == "" { + t.Fatalf("expected a non-empty risk level to be derived and passed to the hook") + } +} + +// TestHooksPermissionRequestDenySkipsExecutionWithoutPausing verifies a +// PermissionRequest handler returning `deny` synthesizes a rejected output +// (sent to the model this round) instead of executing the tool or pausing. +func TestHooksPermissionRequestDenySkipsExecutionWithoutPausing(t *testing.T) { + var executed bool + tool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + executed = true + return "done", nil + }}) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "danger", Arguments: `{"query":"x"}`} + resp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + final := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + sender := twoTurnSender(resp, final) + + manager := NewHooksManager() + manager.OnPermissionRequest(HookEntry[PermissionRequestPayload, PermissionRequestResult]{ + Handler: func(payload PermissionRequestPayload, hctx LifecycleHookContext) (HookHandlerResult[PermissionRequestResult], error) { + return SyncResult(PermissionRequestResult{Decision: PermissionDecisionDeny, Reason: "not today"}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + state, err := result.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if executed { + t.Fatalf("PermissionRequest 'deny' must prevent tool execution") + } + if state.Status == ConversationStatusAwaitingApproval { + t.Fatalf("run should not pause for approval when the hook denies the call") + } + if countFunctionCallOutputs(state.Messages) < 1 { + t.Fatalf("expected a synthesized rejected output to be sent, messages=%+v", state.Messages) + } + if sender.calls != 2 { + t.Fatalf("expected the run to continue to a second model call carrying the denial, got %d", sender.calls) + } +} + +// TestHooksPermissionRequestAskUserFallsThroughToApproval verifies the +// default decision (ask_user, and the behavior with no HooksManager at all) +// preserves the existing human-approval pause. +func TestHooksPermissionRequestAskUserFallsThroughToApproval(t *testing.T) { + tool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return "done", nil + }}) + call := components.OutputFunctionCallItem{CallID: "call_1", Name: "danger", Arguments: `{"query":"x"}`} + resp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{components.CreateOutputItemsFunctionCall(call)}} + created := operations.CreateCreateResponsesResponseOpenResponsesResult(resp) + sender := &fakeSender{responses: []*operations.CreateResponsesResponse{&created}} + + manager := NewHooksManager() + manager.OnPermissionRequest(HookEntry[PermissionRequestPayload, PermissionRequestResult]{ + Handler: func(payload PermissionRequestPayload, hctx LifecycleHookContext) (HookHandlerResult[PermissionRequestResult], error) { + return SyncResult(PermissionRequestResult{Decision: PermissionDecisionAskUser}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{tool}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + state, err := result.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if state.Status != ConversationStatusAwaitingApproval { + t.Fatalf("expected ask_user to fall through to the human approval pause, got %q", state.Status) + } +} + +// TestHooksUserPromptSubmitMutatesInitialInput verifies UserPromptSubmit can +// rewrite the initial string input before the first model request is sent. +func TestHooksUserPromptSubmitMutatesInitialInput(t *testing.T) { + resp := components.OpenResponsesResult{ID: "resp_1", OutputText: openrouter.String("done")} + created := operations.CreateCreateResponsesResponseOpenResponsesResult(resp) + sender := &fakeSender{responses: []*operations.CreateResponsesResponse{&created}} + + manager := NewHooksManager() + var sawPrompt string + manager.OnUserPromptSubmit(HookEntry[UserPromptSubmitPayload, UserPromptSubmitResult]{ + Handler: func(payload UserPromptSubmitPayload, hctx LifecycleHookContext) (HookHandlerResult[UserPromptSubmitResult], error) { + sawPrompt = payload.Prompt + mutated := "MUTATED: " + payload.Prompt + return SyncResult(UserPromptSubmitResult{MutatedPrompt: &mutated}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "original prompt", Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err != nil { + t.Fatal(err) + } + if sawPrompt != "original prompt" { + t.Fatalf("expected the hook to see the original prompt text, got %q", sawPrompt) + } + items := requestInputItems(sender.requests[0].Input) + var found bool + for _, item := range items { + if item.EasyInputMessage == nil { + continue + } + content, ok := item.EasyInputMessage.Content.GetOrZero() + if ok && content.Str != nil && *content.Str == "MUTATED: original prompt" { + found = true + } + } + if !found { + t.Fatalf("expected the mutated prompt to reach the model request, items=%#v", items) + } +} + +// TestHooksUserPromptSubmitRejectionAbortsRun verifies a UserPromptSubmit +// handler can reject the whole turn before any model request is sent. +func TestHooksUserPromptSubmitRejectionAbortsRun(t *testing.T) { + sender := &fakeSender{} + manager := NewHooksManager() + manager.OnUserPromptSubmit(HookEntry[UserPromptSubmitPayload, UserPromptSubmitResult]{ + Handler: func(payload UserPromptSubmitPayload, hctx LifecycleHookContext) (HookHandlerResult[UserPromptSubmitResult], error) { + return SyncResult(UserPromptSubmitResult{RejectReason: "blocked prompt"}), nil + }, + }) + + result, err := CallModel(context.Background(), sender, CallModelInput{ + Model: "openai/test", Input: "bad prompt", Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := result.Text(context.Background()); err == nil { + t.Fatalf("expected UserPromptSubmit rejection to surface as an error") + } + if sender.calls != 0 { + t.Fatalf("expected no model request to be sent after a UserPromptSubmit rejection, got %d calls", sender.calls) + } +} + +// TestPostModelCallTurnTypeDistinguishesResumeFromInitial verifies upstream's +// `continueWithUnsentResults` telemetry tag: the model call issued right +// after an approval resume is tagged "resume", not "initial". +func TestPostModelCallTurnTypeDistinguishesResumeFromInitial(t *testing.T) { + autoTool := MustNewTool(ToolConfig[sampleInput]{Name: "auto", Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return map[string]any{"ok": true}, nil + }}) + approveTool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return "done", nil + }}) + autoCall := components.OutputFunctionCallItem{CallID: "call_auto", Name: "auto", Arguments: `{"query":"a"}`} + dangerCall := components.OutputFunctionCallItem{CallID: "call_danger", Name: "danger", Arguments: `{"query":"b"}`} + pauseResp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{ + components.CreateOutputItemsFunctionCall(autoCall), + components.CreateOutputItemsFunctionCall(dangerCall), + }} + createdPause := operations.CreateCreateResponsesResponseOpenResponsesResult(pauseResp) + pauseSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdPause}} + + paused, err := CallModel(context.Background(), pauseSender, CallModelInput{Model: "openai/test", Input: "hi", Tools: []Tool{autoTool, approveTool}}) + if err != nil { + t.Fatal(err) + } + state, err := paused.State(context.Background()) + if err != nil { + t.Fatal(err) + } + + finalResp := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + createdFinal := operations.CreateCreateResponsesResponseOpenResponsesResult(finalResp) + resumeSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFinal}} + + manager := NewHooksManager() + var sawTurnTypes []ModelCallTurnType + manager.OnPostModelCall(HookEntry[PostModelCallPayload, EmptyHookResult]{ + Handler: func(payload PostModelCallPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + sawTurnTypes = append(sawTurnTypes, payload.TurnType) + return VoidResult[EmptyHookResult](), nil + }, + }) + + resumed, err := CallModel(context.Background(), resumeSender, CallModelInput{ + Model: "openai/test", Tools: []Tool{autoTool, approveTool}, State: &state, + ApproveToolCalls: []string{"call_danger"}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := resumed.Text(context.Background()); err != nil { + t.Fatal(err) + } + if len(sawTurnTypes) != 1 || sawTurnTypes[0] != ModelCallTurnTypeResume { + t.Fatalf("expected the post-resume model call to be tagged %q, got %v", ModelCallTurnTypeResume, sawTurnTypes) + } +} + +// TestHooksSessionStartEndDoNotRefireOnApprovalResume verifies upstream's +// resume design (model-result.ts:2506-2515): an approval/HITL +// resume-with-decisions is a continuation of the same logical session, not a +// fresh one. SessionStart/SessionEnd must not fire a second time on the +// resume call — only PreToolUse/PostToolUse/PostModelCall do. +func TestHooksSessionStartEndDoNotRefireOnApprovalResume(t *testing.T) { + autoTool := MustNewTool(ToolConfig[sampleInput]{Name: "auto", Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return map[string]any{"ok": true}, nil + }}) + approveTool := MustNewTool(ToolConfig[sampleInput]{Name: "danger", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + return "done", nil + }}) + autoCall := components.OutputFunctionCallItem{CallID: "call_auto", Name: "auto", Arguments: `{"query":"a"}`} + dangerCall := components.OutputFunctionCallItem{CallID: "call_danger", Name: "danger", Arguments: `{"query":"b"}`} + pauseResp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{ + components.CreateOutputItemsFunctionCall(autoCall), + components.CreateOutputItemsFunctionCall(dangerCall), + }} + createdPause := operations.CreateCreateResponsesResponseOpenResponsesResult(pauseResp) + pauseSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdPause}} + + paused, err := CallModel(context.Background(), pauseSender, CallModelInput{Model: "openai/test", Input: "hi", Tools: []Tool{autoTool, approveTool}}) + if err != nil { + t.Fatal(err) + } + state, err := paused.State(context.Background()) + if err != nil { + t.Fatal(err) + } + + finalResp := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + createdFinal := operations.CreateCreateResponsesResponseOpenResponsesResult(finalResp) + resumeSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFinal}} + + manager := NewHooksManager() + var sessionStarts, sessionEnds, postToolUses int + manager.OnSessionStart(HookEntry[SessionStartPayload, EmptyHookResult]{ + Handler: func(payload SessionStartPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + sessionStarts++ + return VoidResult[EmptyHookResult](), nil + }, + }) + manager.OnSessionEnd(HookEntry[SessionEndPayload, EmptyHookResult]{ + Handler: func(payload SessionEndPayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + sessionEnds++ + return VoidResult[EmptyHookResult](), nil + }, + }) + manager.OnPostToolUse(HookEntry[PostToolUsePayload, EmptyHookResult]{ + Handler: func(payload PostToolUsePayload, hctx LifecycleHookContext) (HookHandlerResult[EmptyHookResult], error) { + postToolUses++ + return VoidResult[EmptyHookResult](), nil + }, + }) + + resumed, err := CallModel(context.Background(), resumeSender, CallModelInput{ + Model: "openai/test", Tools: []Tool{autoTool, approveTool}, State: &state, + ApproveToolCalls: []string{"call_danger"}, Hooks: manager, + }) + if err != nil { + t.Fatal(err) + } + if _, err := resumed.Text(context.Background()); err != nil { + t.Fatal(err) + } + + if sessionStarts != 0 { + t.Fatalf("SessionStart must not re-fire on an approval-resume call, fired %d times", sessionStarts) + } + if sessionEnds != 0 { + t.Fatalf("SessionEnd must not re-fire on an approval-resume call, fired %d times", sessionEnds) + } + if postToolUses != 1 { + t.Fatalf("PostToolUse should still fire during the resume's tool execution, fired %d times", postToolUses) + } +} + +// memoryStateAccessor is a minimal in-memory StateAccessor test double. +type memoryStateAccessor struct { + state ConversationState + saves int +} + +func (a *memoryStateAccessor) Load(context.Context) (*ConversationState, error) { + s := a.state + return &s, nil +} +func (a *memoryStateAccessor) Save(_ context.Context, s ConversationState) error { + a.saves++ + a.state = s + return nil +} + +// TestStateAccessorPersistsWhenResumeRepauses covers a StateAccessor-backed +// approval flow where a resume itself leaves calls pending (not every +// pending call is decided in one resume). The updated state — including +// which calls already executed — must be persisted so a later CallModel +// call that reloads from the SAME accessor does not re-execute an +// already-approved, already-executed tool. +func TestStateAccessorPersistsWhenResumeRepauses(t *testing.T) { + var d1Executions, d2Executions int + danger1 := MustNewTool(ToolConfig[sampleInput]{Name: "danger1", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + d1Executions++ + return "d1 done", nil + }}) + danger2 := MustNewTool(ToolConfig[sampleInput]{Name: "danger2", RequireApproval: true, Execute: func(context.Context, sampleInput, ToolExecuteContext) (any, error) { + d2Executions++ + return "d2 done", nil + }}) + + call1 := components.OutputFunctionCallItem{CallID: "call_d1", Name: "danger1", Arguments: `{"query":"a"}`} + call2 := components.OutputFunctionCallItem{CallID: "call_d2", Name: "danger2", Arguments: `{"query":"b"}`} + pauseResp := components.OpenResponsesResult{ID: "resp_1", Output: []components.OutputItems{ + components.CreateOutputItemsFunctionCall(call1), + components.CreateOutputItemsFunctionCall(call2), + }} + createdPause := operations.CreateCreateResponsesResponseOpenResponsesResult(pauseResp) + pauseSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdPause}} + + accessor := &memoryStateAccessor{state: CreateInitialState()} + paused, err := CallModel(context.Background(), pauseSender, CallModelInput{ + Model: "openai/test", Input: "hi", Tools: []Tool{danger1, danger2}, StateAccessor: accessor, + }) + if err != nil { + t.Fatal(err) + } + if _, err := paused.State(context.Background()); err != nil { + t.Fatal(err) + } + if accessor.saves == 0 { + t.Fatalf("expected the initial pause to persist state via the accessor") + } + + // Resume, deciding only call_d1. call_d2 remains pending, so the resume + // itself re-pauses. + repauseSender := &fakeSender{} + savesBeforeRepause := accessor.saves + repaused, err := CallModel(context.Background(), repauseSender, CallModelInput{ + Model: "openai/test", Tools: []Tool{danger1, danger2}, StateAccessor: accessor, + ApproveToolCalls: []string{"call_d1"}, + }) + if err != nil { + t.Fatal(err) + } + repausedState, err := repaused.State(context.Background()) + if err != nil { + t.Fatal(err) + } + if repausedState.Status != ConversationStatusAwaitingApproval { + t.Fatalf("expected the resume to re-pause on the undecided call, got %q", repausedState.Status) + } + if accessor.saves == savesBeforeRepause { + t.Fatalf("expected the re-pausing resume to persist its updated state via the accessor") + } + if d1Executions != 1 { + t.Fatalf("danger1 should have executed exactly once during the first resume, got %d", d1Executions) + } + + // A THIRD CallModel call reloads from the SAME accessor (as a caller + // relying solely on StateAccessor would) and decides the remaining call. + // It must not re-execute danger1. + finalResp := components.OpenResponsesResult{ID: "resp_2", OutputText: openrouter.String("done")} + createdFinal := operations.CreateCreateResponsesResponseOpenResponsesResult(finalResp) + finalSender := &fakeSender{responses: []*operations.CreateResponsesResponse{&createdFinal}} + final, err := CallModel(context.Background(), finalSender, CallModelInput{ + Model: "openai/test", Tools: []Tool{danger1, danger2}, StateAccessor: accessor, + ApproveToolCalls: []string{"call_d2"}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := final.Text(context.Background()); err != nil { + t.Fatal(err) + } + if d1Executions != 1 { + t.Fatalf("danger1 must not re-execute on a later resume from the same accessor, got %d executions", d1Executions) + } + if d2Executions != 1 { + t.Fatalf("danger2 should have executed exactly once, got %d", d2Executions) + } +} diff --git a/model_result_test.go b/model_result_test.go index d8d8773..06c9e8f 100644 --- a/model_result_test.go +++ b/model_result_test.go @@ -143,13 +143,25 @@ func TestAllowFinalResponseSendsNoToolsRequestOnStop(t *testing.T) { if sender.calls != 2 { t.Fatalf("expected initial and final request, got %d", sender.calls) } - if len(sender.requests[1].Tools) != 0 { - t.Fatalf("final response request must remove tools") + // Upstream #68: tools stay in the final request (removing them would + // bust the prompt-cache prefix); tool calls are forbidden via + // ToolChoice=none instead. + if len(sender.requests[1].Tools) != 1 { + t.Fatalf("final response request must keep tools in place, got %d", len(sender.requests[1].Tools)) + } + if sender.requests[1].ToolChoice == nil || sender.requests[1].ToolChoice.OpenAIResponsesToolChoiceNone == nil { + t.Fatalf("final response request must forbid tool calls via ToolChoice=none, got %#v", sender.requests[1].ToolChoice) } items := sender.requests[1].Input.ArrayOfInputsUnion1 - if len(items) < 3 || items[0].EasyInputMessage == nil || (items[1].FunctionCallItem == nil && items[1].OutputFunctionCallItem == nil) || items[2].FunctionCallOutputItem == nil { + if len(items) < 4 || items[0].EasyInputMessage == nil || (items[1].FunctionCallItem == nil && items[1].OutputFunctionCallItem == nil) || items[2].FunctionCallOutputItem == nil { t.Fatalf("final request should preserve original input, function_call, and output, got %#v", items) } + // bare `true` appends the default final-answer directive (upstream #68). + directive := items[3].EasyInputMessage + content, ok := directive.Content.GetOrZero() + if directive == nil || !ok || content.Str == nil || *content.Str != DefaultFinalResponseDirective { + t.Fatalf("expected default final-answer directive to be appended, got %#v", items[3]) + } } func TestFollowUpRequestPreservesAccumulatedInputHistory(t *testing.T) { diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..469ee5e --- /dev/null +++ b/opencode.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permission": { + "external_directory": "deny" + } +} diff --git a/reusable_stream.go b/reusable_stream.go index 51d9f68..c759671 100644 --- a/reusable_stream.go +++ b/reusable_stream.go @@ -107,6 +107,15 @@ func (r *ReusableStream[T]) Snapshot() []T { func (r *ReusableStream[T]) Err() error { r.mu.Lock(); defer r.mu.Unlock(); return r.err } +// IsComplete reports whether the source has been fully read into the +// buffer. A fresh consumer created after this point replays the retained +// buffer without waiting on a producer. +func (r *ReusableStream[T]) IsComplete() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.done +} + // enqueue appends a value for delivery to this subscriber. func (s *subscriber[T]) enqueue(v T) { s.mu.Lock() diff --git a/scripts/upstream b/scripts/upstream new file mode 100755 index 0000000..ce561de --- /dev/null +++ b/scripts/upstream @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Upstreamer wrapper, adapted from mountgram/upstreamer (MIT) for in-repo downstream. +# +# Difference from upstream tool: mountgram/upstreamer writes generated output to +# codebases//downstream/ inside the upstreamer repo. Here THIS repo is the +# downstream, so output is written in place at the repo root and reviewed as a PR. +# +# Usage: +# scripts/upstream # sync if upstream changed +# scripts/upstream --force # re-run even if unchanged (after editing the contract) +# scripts/upstream --ref v1.2.3 # port a specific upstream ref instead of origin/HEAD +# scripts/upstream -- --print-logs # pass extra args through to opencode +set -euo pipefail + +cd "$(dirname "$0")/.." +repo_root="$PWD" + +contract=".upstreamer/upstreamer.md" +state_file=".upstreamer/state.yaml" +eval_file=".upstreamer/eval.md" +eval_report_file=".upstreamer/eval-report.md" +verifier=".upstreamer/scripts/verify.sh" +env_file=".upstreamer/port.env" +work_dir="tmp/upstreamer" +upstream_dir="$work_dir/upstream" +log_dir=".upstreamer/logs" + +force=0 +ref="" +while [ "$#" -gt 0 ]; do + case "${1:-}" in + --force) force=1; shift ;; + --ref) ref="${2:-}"; shift 2 ;; + -h|--help) sed -n '4,12p' "$0"; exit 0 ;; + --) shift; break ;; + *) break ;; + esac +done + +[ -f "$contract" ] || { echo "ERROR: missing contract: $contract" >&2; exit 2; } + +# Local secrets/model choice. Never committed; CI supplies the same names as env. +# shellcheck disable=SC1090 +if [ -f "$env_file" ]; then set -a; . "./$env_file"; set +a; fi + +read_frontmatter_field() { + local field="$1" line line_number=0 value + while IFS= read -r line; do + line_number=$((line_number + 1)) + if [ "$line_number" -eq 1 ]; then [ "$line" = "---" ] || return 0; continue; fi + [ "$line" = "---" ] && return 0 + case "$line" in + "$field":*) + value="${line#"$field":}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + printf '%s\n' "$value"; return 0 ;; + esac + done < "$contract" +} + +upstream_repo="$(read_frontmatter_field upstream)" +frontmatter_model="$(read_frontmatter_field model)" +[ -n "$upstream_repo" ] || { echo "ERROR: contract has no 'upstream:' field" >&2; exit 2; } + +# Model precedence: OPENCODE_MODEL env (or port.env) > contract frontmatter. +model="${OPENCODE_MODEL:-$frontmatter_model}" +model_args=() +[ -n "$model" ] && model_args=(--model "$model") + +# opencode reads credentials from auth.json. Seed it from OPENROUTER_API_KEY so +# headless CI and local runs work without the interactive /connect flow. +# +# An explicitly-provided key always wins: if you put a key in port.env, that is +# the key that gets used, even when an openrouter credential already exists from +# a previous `opencode /connect`. Silently preferring the stored one makes +# "why is it still using the old key" nearly impossible to debug. +# +# Other providers in auth.json are preserved — only the openrouter entry is +# replaced. +if [ -n "${OPENROUTER_API_KEY:-}" ]; then + auth_dir="${XDG_DATA_HOME:-$HOME/.local/share}/opencode" + auth_file="$auth_dir/auth.json" + mkdir -p "$auth_dir" + if OPENROUTER_API_KEY="$OPENROUTER_API_KEY" AUTH_FILE="$auth_file" python3 - <<'PYAUTH' +import json, os, pathlib +path = pathlib.Path(os.environ["AUTH_FILE"]) +try: + data = json.loads(path.read_text()) + if not isinstance(data, dict): + data = {} +except Exception: + data = {} +data["openrouter"] = {"type": "api", "key": os.environ["OPENROUTER_API_KEY"]} +path.write_text(json.dumps(data, indent=2) + "\n") +path.chmod(0o600) +PYAUTH + then + echo "auth: openrouter credential set from OPENROUTER_API_KEY" >&2 + else + echo "WARNING: could not write $auth_file; falling back to whatever opencode has stored" >&2 + fi +fi + +mkdir -p "$work_dir" "$log_dir" +export TMPDIR="$repo_root/$work_dir" +log_file="$log_dir/$(date -u +%Y%m%dT%H%M%SZ).log" + +last_upstream_commit="" +if [ -f "$state_file" ]; then + last_upstream_commit="$(awk -F: '$1 == "upstream_commit" { gsub(/^[[:space:]]+|[[:space:]]+$/, "", $2); print $2; exit }' "$state_file")" +fi + +upstream_url="$upstream_repo" +case "$upstream_url" in + http://*|https://*|git@*) ;; + *) upstream_url="https://github.com/$upstream_url.git" ;; +esac + +if [ -d "$upstream_dir/.git" ]; then + git -C "$upstream_dir" remote set-url origin "$upstream_url" + git -C "$upstream_dir" fetch --tags --force origin +else + rm -rf "$upstream_dir" + git clone "$upstream_url" "$upstream_dir" +fi + +if [ -n "$ref" ]; then + target_commit="$(git -C "$upstream_dir" rev-parse "$ref^{commit}")" +else + target_commit="$(git -C "$upstream_dir" rev-parse origin/HEAD 2>/dev/null || git -C "$upstream_dir" rev-parse origin/main)" +fi +git -C "$upstream_dir" checkout -q --detach "$target_commit" + +echo "upstream: $upstream_repo" >&2 +echo "target: $target_commit${ref:+ ($ref)}" >&2 +echo "last port: ${last_upstream_commit:-none}" >&2 +echo "model: ${model:-}" >&2 + +if [ "$force" -eq 0 ] && [ "$target_commit" = "$last_upstream_commit" ]; then + { + echo "Run summary" + echo "===========" + echo "Upstream changes since last run: none." + echo "Downstream changes made: none." + echo "Upstream commit: $target_commit" + if [ -x "$verifier" ]; then + echo; echo "Running verifier: $verifier" + "$verifier" + fi + } 2>&1 | tee "$log_file" + exit "${PIPESTATUS[0]}" +fi + +command -v opencode >/dev/null 2>&1 || { echo "ERROR: opencode not installed" >&2; exit 127; } + +prompt_file="$work_dir/prompt.txt" +cat > "$prompt_file" <&2 + +if [ -n "${UPSTREAMER_TIMEOUT_SECONDS:-}" ]; then + perl -e 'alarm shift; exec @ARGV' "$UPSTREAMER_TIMEOUT_SECONDS" \ + opencode "${model_args[@]}" "$@" run "$prompt" 2>&1 | tee "$log_file" +else + opencode "${model_args[@]}" "$@" run "$prompt" 2>&1 | tee "$log_file" +fi diff --git a/tool.go b/tool.go index 8bff839..76f9154 100644 --- a/tool.go +++ b/tool.go @@ -247,3 +247,43 @@ func deepCopyJSONValue(v any) any { return x } } + +// mcpBrand wraps a Tool with the additive MCP marker (see MarkMcp/IsMcpTool). +// Non-mutating: it forwards every Tool method to the wrapped tool unchanged, +// so runtime behavior and wire shape (ToAPITool) are identical to the +// unmarked tool. Execute is forwarded explicitly (rather than inherited via +// embedding, which only promotes the narrow Tool interface) so a marked +// executable tool keeps executing normally. +type mcpBrand struct{ Tool } + +func (b mcpBrand) isMcpTool() {} + +func (b mcpBrand) Execute(ctx context.Context, raw json.RawMessage, execCtx ToolExecuteContext) (ToolExecutionResult, error) { + exec, ok := b.Tool.(ToolWithExecute) + if !ok { + return ToolExecutionResult{CallID: execCtx.ToolCall.CallID, Name: b.Tool.ToolName(), Error: ErrManualTool}, nil + } + return exec.Execute(ctx, raw, execCtx) +} + +// mcpMarked is implemented only by mcpBrand; IsMcpTool structurally checks +// for it rather than requiring a cast to a concrete type. +type mcpMarked interface{ isMcpTool() } + +// MarkMcp adds the additive MCP brand to an already-built client tool. The +// tool's runtime behavior and wire shape are unchanged; only IsMcpTool's +// classification (and downstream Source discrimination on +// ToolExecutionResult/ToolStreamEvent) now identifies it as MCP-originated. +// Intended for use by an MCP integration package that wraps remote tools. +func MarkMcp(t Tool) Tool { + return mcpBrand{Tool: t} +} + +// IsMcpTool reports whether tool carries the additive MCP brand (see MarkMcp). +func IsMcpTool(t Tool) bool { + if t == nil { + return false + } + _, ok := t.(mcpMarked) + return ok +} diff --git a/tool_executor.go b/tool_executor.go index c4ef4c3..7cfb387 100644 --- a/tool_executor.go +++ b/tool_executor.go @@ -36,9 +36,13 @@ func ParseToolArguments(raw string) (json.RawMessage, any, error) { } func ExecuteTool(ctx context.Context, t Tool, call ParsedToolCall, execCtx ToolExecuteContext) (ToolExecutionResult, error) { + source := ToolSourceClient + if IsMcpTool(t) { + source = ToolSourceMCP + } exec, ok := t.(ToolWithExecute) if !ok { - return ToolExecutionResult{CallID: call.CallID, Name: call.Name, Error: ErrManualTool}, nil + return ToolExecutionResult{CallID: call.CallID, Name: call.Name, Source: source, Error: ErrManualTool}, nil } raw := json.RawMessage("{}") if call.RawArgs != "" { @@ -51,7 +55,12 @@ func ExecuteTool(ctx context.Context, t Tool, call ParsedToolCall, execCtx ToolE raw = b } execCtx.ToolCall = call - return exec.Execute(ctx, raw, execCtx) + result, err := exec.Execute(ctx, raw, execCtx) + if err != nil { + return result, err + } + result.Source = source + return result, nil } func SanitizeSchema(schema map[string]any) map[string]any { diff --git a/tool_orchestrator.go b/tool_orchestrator.go index 60eed72..1cdb04a 100644 --- a/tool_orchestrator.go +++ b/tool_orchestrator.go @@ -1,12 +1,21 @@ package agent -import "context" +import ( + "context" + "fmt" +) func ExecuteToolLoop(ctx context.Context, tools []Tool, calls []ParsedToolCall, store *ToolContextStore) ([]ToolExecutionResult, error) { results := make([]ToolExecutionResult, 0, len(calls)) for _, call := range calls { t := FindToolByName(tools, call.Name) if t == nil { + results = append(results, ToolExecutionResult{ + CallID: call.CallID, + Name: call.Name, + Source: ToolSourceClient, + Error: fmt.Errorf("tool %q not found in tool definitions", call.Name), + }) continue } res, err := ExecuteTool(ctx, t, call, BuildToolExecuteContext(call, TurnContext{}, store, nil)) diff --git a/tool_types.go b/tool_types.go index e2e1abd..b17ebd0 100644 --- a/tool_types.go +++ b/tool_types.go @@ -65,12 +65,26 @@ type ToolPreliminaryResultEvent struct { Event any `json:"event"` } +// ToolSource discriminates a tool result's origin. "mcp" identifies a tool +// wrapped from a remote MCP server (see McpBranded/MarkMcp/IsMcpTool); +// "client" is every locally-defined tool. Mirrors upstream's `source` field +// added to ToolExecutionResult/ToolResultEvent so a consumer can identify +// MCP-originated (dynamically typed) results without that collapsing every +// other tool's result to `unknown`. +type ToolSource string + +const ( + ToolSourceClient ToolSource = "client" + ToolSourceMCP ToolSource = "mcp" +) + type ToolResultEvent struct { - Type string `json:"type"` - CallID string `json:"call_id"` - Name string `json:"name"` - Result any `json:"result,omitempty"` - Error string `json:"error,omitempty"` + Type string `json:"type"` + CallID string `json:"call_id"` + Name string `json:"name"` + Source ToolSource `json:"source,omitempty"` + Result any `json:"result,omitempty"` + Error string `json:"error,omitempty"` } type TurnStartEvent struct { @@ -94,14 +108,15 @@ type ResponseStreamEvent struct { type EnhancedResponseStreamEvent = ResponseStreamEvent type ToolStreamEvent struct { - Type string `json:"type"` - CallID string `json:"call_id,omitempty"` - Name string `json:"name,omitempty"` - Event any `json:"event,omitempty"` - Result any `json:"result,omitempty"` - Output any `json:"output,omitempty"` - Error string `json:"error,omitempty"` - Turn int `json:"turn,omitempty"` + Type string `json:"type"` + CallID string `json:"call_id,omitempty"` + Name string `json:"name,omitempty"` + Source ToolSource `json:"source,omitempty"` + Event any `json:"event,omitempty"` + Result any `json:"result,omitempty"` + Output any `json:"output,omitempty"` + Error string `json:"error,omitempty"` + Turn int `json:"turn,omitempty"` } type ChatStreamEvent = ResponseStreamEvent @@ -120,6 +135,9 @@ type TypedToolCallUnion = ParsedToolCall type ToolExecutionResult struct { CallID string Name string + // Source is "mcp" for a tool marked via MarkMcp, "client" otherwise. + // Zero value ("") means the executor path did not set it (treat as client). + Source ToolSource Output any Error error Events []any @@ -165,14 +183,35 @@ type TurnContext struct { type ConversationStatus string const ( - ConversationStatusComplete ConversationStatus = "complete" - ConversationStatusInterrupted ConversationStatus = "interrupted" + ConversationStatusComplete ConversationStatus = "complete" + ConversationStatusInterrupted ConversationStatus = "interrupted" + // ConversationStatusAwaitingApproval means one or more tool calls need + // human approval before executing (see ApproveToolCalls/RejectToolCalls). ConversationStatusAwaitingApproval ConversationStatus = "awaiting_approval" - ConversationStatusAwaitingHITL ConversationStatus = "awaiting_hitl" - ConversationStatusInProgress ConversationStatus = "in_progress" + // ConversationStatusAwaitingHITL means a HITL tool returned nil from its + // OnToolCalled hook, pausing execution so the caller can supply an output. + ConversationStatusAwaitingHITL ConversationStatus = "awaiting_hitl" + // ConversationStatusAwaitingClientTools means one or more manual + // (execute:false / no OnToolCalled) tool calls are unresolved; the loop + // stopped so the caller can execute them client-side and continue. + // Distinct from ConversationStatusAwaitingHITL — HITL tools have an + // OnToolCalled hook, manual tools do not (upstream #64). + ConversationStatusAwaitingClientTools ConversationStatus = "awaiting_client_tools" + ConversationStatusInProgress ConversationStatus = "in_progress" ) +// ConversationStateVersion is the current supported ConversationState +// serialization-contract version (upstream #66). Bump this, add a migration +// branch in DeserializeConversationState, and update the supported-versions +// list when the wire shape changes. +const ConversationStateVersion = 1 + type ConversationState struct { + // Version is the serialization-contract version for this state blob. + // Zero (the Go zero value) is treated as version 1 by + // DeserializeConversationState/SerializeConversationState, matching + // upstream's "absence means v1" legacy-blob policy. + Version int ID string Messages []components.InputsUnion1 PreviousResponseID *string diff --git a/upstreamer-changelog.md b/upstreamer-changelog.md index e94cb6c..15a6655 100644 --- a/upstreamer-changelog.md +++ b/upstreamer-changelog.md @@ -1,5 +1,23 @@ # go-agent Changelog +## Lifecycle Hooks, Versioned State, And 0.8.0 Parity (ported from `@openrouter/agent@0.8.0`) + +- Added `HooksManager` (`NewHooksManager`), a typed lifecycle-hook system with the nine built-in hooks — `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `UserPromptSubmit`, `Stop`, `PermissionRequest`, `SessionStart`, `SessionEnd`, and `PostModelCall` — plus fully custom hooks via the generic `On`/`Emit` functions. Register built-ins with the typed `OnXxx`/`EmitXxx` methods (`manager.OnPreToolUse(...)`, etc.). +- `CallModelInput.Hooks` accepts a `*HooksManager` or an `InlineHookConfig` (via `ResolveHooks`) to install hooks on a run. +- `PreToolUse`/`PostToolUse`/`PostToolUseFailure` now fire around every client-tool execution path (auto-approve, manual approval, approved-on-resume) — a `PreToolUse` block synthesizes a rejected tool result without running the tool; a mutated input is used for the actual call. +- `PermissionRequest` now fires for every approval-gated tool call before the human-approval pause: `allow` promotes the call past the gate and executes it this round, `deny` synthesizes a rejected output without pausing or executing, and `ask_user` (the default, and the behavior with no hooks installed) falls through to the existing approval pause. Risk level (`low`/`medium`/`high`) is derived from the approval gate's shape, matching upstream. +- `Stop` now fires whenever a `StopWhen` condition halts the loop mid-tool-call: a handler's `ForceResume` keeps the run going instead of taking the forced-final-response turn, and `AppendPrompt` injects a user message (independently of `ForceResume`) into both the conversation state and the next request. Consecutive overrides without tool progress are capped (mirroring upstream's `MAX_FORCE_RESUME_OVERRIDES`) so a misbehaving handler can't spin the loop forever. +- `UserPromptSubmit` now fires once per run against the latest user-role text message in the accumulated input: a handler can mutate that message's content before the first request is sent, or reject the prompt outright (surfaced as a `CallModel` error before any model call is made). +- `PostModelCall` fires once per materialized model response (initial, resume, tool-round, final, and retry turns — including the `resume` tag for the model call issued right after processing an approval/HITL resume) with per-call usage (`ModelCallUsage`); `SessionStart`/`SessionEnd` fire once per *non-resuming* `CallModel` call, with `SessionEnd` carrying aggregated `SessionUsageTotals` across that call's model calls. An approval/HITL resume call (state `awaiting_approval`/`awaiting_hitl` plus `ApproveToolCalls`/`RejectToolCalls`) is treated as a continuation of the same logical session and does **not** get its own `SessionStart`/`SessionEnd` pair — only `PreToolUse`/`PostToolUse`/`PostModelCall`/`Stop`/`PermissionRequest` fire during it, matching upstream. Session identity is threaded per-emit (`EmitOptions.SessionID`), so one `HooksManager` is safe to share across concurrent `CallModel` runs. +- Hooks are drained (`HooksManager.Drain`) on every `CallModel` exit path, including a no-tools stream error before any tool ever runs, so fire-and-forget handler work set up via `AsyncResult`/`AsyncOutput` is never silently dropped. +- Added a versioned `ConversationState` serialization contract: `SerializeConversationState` / `DeserializeConversationState`, `ConversationStateVersion`, `*UnsupportedStateVersionError` for a future/incompatible version, and `*InvalidStateError` for malformed or incomplete state JSON. Version-less legacy blobs are accepted and normalized to version 1. +- Unresolved manual (client-executed) tool calls now pause a run with the distinct `ConversationStatusAwaitingClientTools` status (previously reported as the generic `interrupted` status), so callers can tell "you must execute this tool call yourself" apart from an arbitrary interruption or a HITL pause. +- `AllowFinalResponse` is now **default-on**: omitting it behaves like bare `true`. When a stop condition halts the loop mid-tool-call, go-agent executes the pending tool calls and issues one more request with `tool_choice: "none"` — keeping `tools` in the request (rather than stripping them) so the prompt-cache prefix survives — and appends `DefaultFinalResponseDirective` as a final user message so models that emit tool-call syntax as text don't leak an unparsed call into the answer. A non-empty string still overrides the wording; `""` forbids further tool calls without appending a message; `false` still disables the forced final turn entirely. +- Added `CallModelInput.StrictFinalResponse`. A completed run that ends with a genuinely empty response after at least one tool round is retried once (mirroring the model needing a one-turn nudge); `StrictFinalResponse: true` skips that retry. +- Added MCP-source discrimination: `MarkMcp(tool)` / `IsMcpTool(tool)`, and a `Source` field (`ToolSourceClient` / `ToolSourceMCP`) on `ToolExecutionResult` and `ToolStreamEvent`/`ToolResultEvent`, so a tool wrapped from a remote MCP server can be identified without changing its execution or wire shape. +- Added `ReusableStream[T].IsComplete()`, mirroring the source-exhausted flag on the underlying fan-out stream. +- Fixed a `CallModelInput.StateAccessor` persistence bug: an approval/HITL resume that itself left calls pending (not every decision resolved the round, so the resume re-paused) never saved the updated state back to the accessor. A caller relying solely on `StateAccessor.Load`/`Save` (rather than manually re-threading `ModelResult.State()`) could reload stale state on the next `CallModel` call and re-execute an already-approved tool. The resume path now saves before returning on that pause, matching the save that already happened at the end of a fully-resolved run. + ## Initial Go Port - Ported the OpenRouter agent toolkit to a standalone Go module with package name `agent` and module path `github.com/OpenRouterTeam/go-agent`. @@ -51,6 +69,22 @@ | `approveToolCalls` / `rejectToolCalls` | `ApproveToolCalls` / `RejectToolCalls` on `CallModelInput` | | `allowFinalResponse` | `AllowFinalResponse` on `CallModelInput` | | HITL `onResponseReceived` | `OnResponseReceived` on `ToolConfig[T]` | +| `new HooksManager(custom, opts)` | `NewHooksManager(opts...)` (custom hooks: use `On[P, R](manager, name, entry)`, no upfront registry needed) | +| `manager.on(hookName, entry)` / `manager.off(hookName, handler)` | `manager.OnXxx(entry)` (built-ins) / `On[P, R](manager, name, entry)` (custom); `Off[P, R](manager, name, handler)` | +| `manager.emit(hookName, payload, ctx)` | `manager.EmitXxx(payload, opts)` (built-ins) / `Emit[P, R](manager, name, payload, opts)` (custom) | +| `manager.setSessionId(id)` / `manager.hasHandlers(name)` | `manager.SetSessionID(id)` / `manager.HasHandlers(name)` | +| `manager.drain()` / `manager.abortInflight()` | `manager.Drain()` / `manager.AbortInflight()` | +| `resolveHooks(hooks)` | `ResolveHooks(hooks any) *HooksManager` (accepts `*HooksManager` or `InlineHookConfig`) | +| `isAsyncOutput(value)` | not ported as a standalone predicate — `HookHandlerResult[R].Async` is the typed signal (see `AsyncResult`/`VoidResult`/`SyncResult`) | +| `matchesTool(matcher, toolName)` | `MatchesTool(matcher ToolMatcher, toolName string) bool` | +| `CONVERSATION_STATE_VERSION` | `ConversationStateVersion` | +| `serializeConversationState` / `deserializeConversationState` | `SerializeConversationState(state) (string, error)` / `DeserializeConversationState(json) (ConversationState, error)` | +| `UnsupportedStateVersionError` / `InvalidStateError` | `*UnsupportedStateVersionError` / `*InvalidStateError` | +| `strictFinalResponse` | `StrictFinalResponse` on `CallModelInput` | +| `DEFAULT_FINAL_RESPONSE_DIRECTIVE` | `DefaultFinalResponseDirective` | +| `markMcp(tool)` / `isMcpTool(tool)` | `MarkMcp(tool) Tool` / `IsMcpTool(tool) bool` | +| tool result / stream event `source` | `Source ToolSource` (`ToolSourceClient` / `ToolSourceMCP`) on `ToolExecutionResult`, `ToolStreamEvent`, `ToolResultEvent` | +| `awaiting_client_tools` | `ConversationStatusAwaitingClientTools` | ## Compatibility Notes @@ -60,3 +94,11 @@ - JavaScript package infrastructure, TypeScript source, Vitest setup, and monorepo release files are intentionally omitted from this Go module. - `service_tier` is set to `auto` on every request to work around an upstream Go SDK serialization defect; callers may override it on the request as usual. - The unsupported-content helpers (`HasUnsupportedContent`, `ExtractUnsupportedContent`, `GetUnsupportedContentSummary`) operate on a `ClaudeMessage`, matching the upstream surface. A separate best-effort helper, `ScanUnsupportedContent(any)`, recursively finds carriers in arbitrary values (e.g. converted input-item slices) and is not part of the upstream API. +- Hooks have no runtime schema layer: upstream validates hook payloads/results against Zod schemas at runtime; go-agent's hook payload/result shapes are plain compile-time-typed Go structs (the type system is the validation layer), consistent with the existing "struct tags replace Zod" divergence for tool schemas. A registered handler whose declared type doesn't match the hook it's registered under is skipped with a logged warning rather than causing a runtime schema-validation error. +- `PreToolUseResult.Block`/`BlockReason` and `UserPromptSubmitResult.Reject`/`RejectReason` split upstream's `boolean | string` union field into two Go fields; either one set triggers the same block/reject short-circuit as upstream (`=== true` or a non-empty string). +- A handler's upstream fire-and-forget `AsyncOutput.work?: Promise` becomes `AsyncOutput.Work <-chan error` in Go (channels are this port's stream/async primitive, per the existing "channels for streams" divergence); a handler kicks off a goroutine and closes the channel (optionally sending an error first) when its detached work finishes. +- `HooksManager.AbortInflight` cancels each in-flight emit's `context.Context` (threaded through `LifecycleHookContext.Ctx`) rather than firing an `AbortSignal`, per the existing "`context.Context` for cancellation" divergence. +- `HooksManager.Off` is a best-effort match on the handler function's code pointer via `reflect`, since Go function values are not comparable the way upstream compares handler references; the unsubscribe function returned by `On`/`OnXxx` is the precise removal path. +- go-sdk's `OpenResponsesResult` carries a separate `OutputText` convenience field alongside `Output` items (upstream's `output` is the sole content carrier), so go-agent does not port upstream's hard "empty final response is invalid" error — an empty `Output` array is not on its own a reliable invalidity signal in the Go SDK's response shape. go-agent still retries once when a completed run's final response has neither `Output` items nor extractable text after at least one tool round (`StrictFinalResponse` opts out of the retry), but it never turns that into a hard error the way upstream's `validateFinalResponse` does. +- Go's `NewTool[T]` already carries a single input-type parameter without upstream's separate context-schema type parameter; `ToolExecuteContext.Context`/`.Shared` are `map[string]any` by design (idiomatic divergence #5, generics where TypeScript used conditional types), so upstream's contextSchema-type-inference fix (upstream #65, a TypeScript-conditional-type-only change with no runtime behavior) has no Go analog to port. +- `CallModelInput.StateAccessor` persistence is guaranteed at every documented pause boundary (`awaiting_approval`, `awaiting_hitl`, `awaiting_client_tools`, and a resume that itself re-pauses) and at normal completion, matching upstream's `saveStateSafely` call sites for those cases. It is **not** yet checkpointed after every individual turn/tool-round the way upstream's `saveResponseToState`/`saveToolResultsToState` are: a genuine mid-run turn-level error (e.g. a transport failure) after an earlier turn in the *same* run already executed a tool does not persist that turn's work to the accessor before returning the error. This predates the 0.8.0 delta. Callers who need per-turn durability today should re-thread `ModelResult.State()` themselves after each call rather than relying solely on `StateAccessor`; per-turn incremental accessor saves are tracked as a follow-up.