Skip to content

feat(server): auto-unwrap thrown adcpError envelopes in dispatcher#743

Merged
bokelley merged 2 commits into
mainfrom
sdk-auto-unwrap-thrown-adcp-error
Apr 21, 2026
Merged

feat(server): auto-unwrap thrown adcpError envelopes in dispatcher#743
bokelley merged 2 commits into
mainfrom
sdk-auto-unwrap-thrown-adcp-error

Conversation

@bokelley

Copy link
Copy Markdown
Contributor

Summary

Handlers that write `throw adcpError(...)` instead of `return adcpError(...)` used to surface as:

`SERVICE_UNAVAILABLE: Tool X handler threw: [object Object]`

because the thrown value is a plain object, not an `Error` — so `err.message` is undefined and `String(err)` yields the `[object Object]` literal.

The dispatcher now detects the envelope shape (`isError: true` + `content: [...]` + `structuredContent.adcp_error.code`) and returns it directly, preserving the typed code / field / suggestion as if the handler had written `return`. A `logger.warn` still fires so agent authors see they should switch to `return`, but buyers stop paying for the mistake.

Why this lives in the SDK, not the skill corpus: matrix v8 showed this pattern persisting across fresh-Claude builds even when every skill example uses `return`. Closing the class of bugs at the dispatcher is a one-time fix vs. an N-skill ongoing drift tax.

Behavior

  • Idempotency claims are released on unwrap (same as any thrown path) so retries proceed normally.
  • Non-envelope throws (`TypeError`, strings, custom errors) still surface as `SERVICE_UNAVAILABLE` with the underlying cause in `details.reason` — the handler-throw disclosure from PR feat(server): default exposeErrorDetails to true outside NODE_ENV=production #735 is unchanged.
  • The shape check requires the full three-layer envelope (`isError`, `content`, `structuredContent.adcp_error.code`) to avoid accidentally unwrapping non-envelope throws that happen to carry a `structuredContent` field.

Test plan

  • Two new tests in `test/server-state-store-extensions.test.js`:
    • Unwraps a thrown `adcpError` envelope and preserves `code` / `message` / `field`
    • Falls back to `SERVICE_UNAVAILABLE` for thrown `TypeError` (guard for non-envelope throws)
  • `npm run typecheck` clean
  • All 39 tests in the file pass
  • Matrix v9 after merge — expected to drop the `[object Object]` cause-3 hits to 0

Handlers that `throw adcpError(...)` instead of `return adcpError(...)`
used to surface as `SERVICE_UNAVAILABLE: Tool X handler threw:
[object Object]` — the thrown value is a plain object, not an Error,
so err.message is undefined and String(err) yields the literal.

The dispatcher now detects the envelope shape (isError + content +
structuredContent.adcp_error.code) and returns it directly, preserving
the typed code / field / suggestion as if the handler had used `return`.

Idempotency claims are released on unwrap (same as any thrown path) so
retries proceed normally. Non-envelope throws (TypeError, strings,
custom errors) still go through the SERVICE_UNAVAILABLE path with the
underlying cause exposed when exposeErrorDetails is on.

Driver: matrix v8 showed this pattern persisting across fresh-Claude
builds even when skill examples use `return`. Fixing it at the
dispatcher closes the class of bugs instead of chasing it through
every skill-corpus update.
Code review: assert message:string in isThrownAdcpError so malformed
envelopes fall through to SERVICE_UNAVAILABLE instead of producing a
half-formed response.

Security review: log message + stack on unwrap so forensic review sees
what was thrown — previously only the code was captured, which made
the "dependency trojaned an envelope" attack model invisible.

Follow-up filed as #744 for the broader question of whether
recovery:terminal envelopes should cache-on-error instead of release —
applies to both return and throw paths, not caused by this PR.
@bokelley bokelley merged commit 0e7c1c9 into main Apr 21, 2026
12 checks passed
bokelley added a commit that referenced this pull request Apr 21, 2026
Addresses the audit question in #744: should `recovery: terminal`
errors cache on replay, or keep release-all-errors? Audit conclusion:
keep release-all-errors (most AdCP terminals are state-dependent;
caching would return stale errors post-remediation). Document the
handler-author implication that #743 widened the surface for.

- skills/build-seller-agent: new "mutate last" paragraph in the
  idempotency section, with broken-vs-correct budget-approval example
  and guidance for partial-write paths.
- skills/build-creative-agent: swap the now-stale
  "throw surfaces as SERVICE_UNAVAILABLE" rationale (invalidated by
  #743's auto-unwrap) for the still-true claim-release rationale.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot mentioned this pull request Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant