Skip to content

feat(decisioning): let a raised AdcpError reject a request with every error (#1780) - #1804

Merged
bokelley merged 1 commit into
nextfrom
claude/issue-1780-multi-error-raise
Nov 2, 2026
Merged

bokelley merged 1 commit into
nextfrom
claude/issue-1780-multi-error-raise

Conversation

@bokelley

@bokelley bokelley commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A seller that validates a whole create_media_buy / update_media_buy request finds several problems at once, but a decisioning platform can only report them by raising AdcpError, which carries one code, message and field. build_mcp_error_result and the A2A failed-task builder wrote errors: [<that one error>], even when the caller put the whole list in details["errors"] (the slot AdcpError's docstring named for multi-error preflight). The issue's repro printed 1 [None]. Sellers had to lift the list in a response enhancer themselves.

Change

  • AdcpError(..., errors=[...]): entries are AdcpErrors, generated Error models, or wire mappings with a string code and message. The raised error still fills the envelope adcp_error, and every entry goes into the payload errors[]. An entry without recovery takes the raised error's. Entry details go through sanitize_error_details like to_wire(). A malformed entry raises at construction (TypeError / ValueError).
  • AdcpError.from_errors([...]) builds the raise from the list, with the first entry filling adcp_error, so adcp_error == errors[0]. It returns a base AdcpError even when the first entry is a subclass.
  • AdcpError.errors_to_wire() returns the payload list: every entry, or [to_wire()] when there is no list.
  • The details["errors"] slot is lifted. When errors= is not passed and every details["errors"] entry is a mapping with a string code and message, those entries fill errors[]. Any other shape (strings, {"path", "msg"} dicts, an empty list) is left alone, as before. adcp_error.details is unchanged.
  • build_mcp_error_result, the A2A executor's failed-task DataPart and translate_error(..., "a2a") write the whole list. A single-error raise still mirrors adcp_error into errors[] (Raised AdcpError only fills adcp_error, so storyboards that read errors[0] fail #1558/fix(server): mirror a raised AdcpError into the payload errors array #1592), so existing output is unchanged.
  • Docs: a short "Rejecting a request with every problem" section in docs/handler-authoring.md.
raise AdcpError.from_errors([
    AdcpError("BUDGET_TOO_LOW", message="budget below minimum", recovery="correctable", field="packages[0].budget"),
    AdcpError("INVALID_REQUEST", message="end_time before start_time", recovery="correctable", field="end_time"),
])

Not covered: an async TaskHandoff failure still records one error through registry.fail(task_id, exc.to_wire()), so tasks/get and the failure webhook report errors: [adcp_error]. Carrying the list there means a task-registry storage change, so it's left as a follow-up.

Adopter note: this change is additive. A seller whose response enhancer already lifts details.errors into errors[] (as the Scope3 sales agent's project_wire_errors does) gets the list from the SDK now. An enhancer that replaces errors keeps working; one that appends would duplicate the entries and should be removed.

Tests

tests/test_adcp_error_multi.py (28 tests) covers:

  • construction, from_errors with all three entry types, and the subclass entry;
  • malformed entries, sanitization, and copies;
  • the details["errors"] lift, including the shapes it must leave alone;
  • the issue's repro through build_mcp_error_result;
  • context echo and response enhancer;
  • end to end through _register_tool;
  • translate_error A2A data, and the A2A executor's failed task.

Locally: make lint and make typecheck-all pass. The targeted error suites (test_adcp_error_multi, test_mcp_structured_error, test_a2a_structured_error, test_translate, test_decisioning_types, 149 tests) pass. I stopped the full local run partway because the sandbox ran out of memory, so CI's matrix is the full-suite gate.

A matching non-breaking PR against main (SDK 9) follows.

Closes #1780

🤖 Generated with Claude Code


Open workspace in Conductor

… error (#1780)

A seller that validates a whole request could only raise one AdcpError,
so the payload errors[] carried one entry even when the caller listed
every problem in details["errors"].

AdcpError takes errors=[...] (AdcpError, generated Error or wire mapping
entries) and AdcpError.from_errors([...]) builds the raise from the list,
the first entry filling adcp_error. build_mcp_error_result, the A2A
failed-task builder and translate_error write the whole list to errors[];
adcp_error is unchanged. A details["errors"] list whose entries carry a
string code and message is lifted into errors[] as well. An entry
without recovery takes the raised error's; entry details are sanitized
like to_wire().

A single-error raise still mirrors adcp_error into errors[] (#1558).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon verdict: Approve

Approve — additive multi-error support on AdcpError, no blocking findings.

Checked: feat(decisioning): is the correct semver signal — no public export removed/renamed, no required↔optional flip, no response-shape break; buyers still deserialize errors[] as a list. _error_entry_to_wire sanitizes entry details and refuses malformed entries (TypeError/ValueError) consistent with to_wire. from_errors recovery-default propagation is consistent between the first computation and __init__ re-normalization. The details["errors"] auto-lift (types.py L174-187) is gated on well-formed string code+message entries and leaves other shapes alone; the documented behavior change is handled in the PR body. Both A2A failed-task and MCP paths route through _payload_errors, single-error still mirrors (#1558). tests/test_adcp_error_multi.py:462 reproduces the issue end-to-end through build_mcp_error_result; 28 tests cover construction, sanitization, both transports, and _register_tool.

high_risk is true (modified files under decisioning/** and server/**), but with zero medium-or-higher findings these modifications are presumed safe (rows 4-5 do not fire). No gated paths, no no-auto-approve team match, no prior decision. Falls through to row 9.

@bokelley
bokelley merged commit 0439d67 into next Nov 2, 2026
41 checks passed
@bokelley
bokelley deleted the claude/issue-1780-multi-error-raise branch November 2, 2026 02:46
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