Skip to content

Commit bbd7cfd

Browse files
committed
fix(aws-strands): unify the terminal error codes and message text across both bridges
The Python and TypeScript AWS Strands bridges had drifted apart on the terminal RUN_ERROR frames they emit. The same failure could arrive under two different codes depending on which bridge served the run, and codes shared by both could carry different sentences. Clients match a terminal frame on its code and its message literally, so that drift is a wire contract problem rather than a cosmetic one. Two behaviour differences are resolved in favour of the fail-loud choice. Media conversion that yields nothing now aborts the run rather than replaying a prompt the model cannot read. A fault raised by adapter code is separated from a fault arriving from outside the adapter, so ADAPTER_BUG is only claimed for this adapter's own defects. The codes each bridge can emit are pinned by a checked-in contract at integrations/aws-strands/error-codes.json, with the exact text of every shared message and a stated reason for each code that is legitimately one-sided. Both bridges assert against that contract by reading their own source: a static extractor per language derives the codes and message templates, and every recognised emission site must end up recorded, counted as unresolved, or delegated to a caller. The unresolved counts are pinned, so a site the extractor stops understanding cannot pass silently. Message templates are pinned as the literal text, whitespace included, since a client matching a sentence matches every character of it. Each extractor is accompanied by an independent reading of the same source, written to a blunter rule and deliberately not sharing the extractor's own recogniser, so the accounting is a demand rather than an echo. Both guards are demonstrated to fail rather than asserted to. Each carries a mutation harness that perturbs a copy of the contract and requires the guard to redden, and the accompanying corpus pins one reading per emission shape so a change in how the source is read shows up as a diff. The interrupt protocol's four typed error paths are kept as they are and listed with their reason, rather than renamed to gain symmetry.
1 parent 7b24f6d commit bbd7cfd

25 files changed

Lines changed: 3086 additions & 243 deletions

.github/workflows/unit-python-sdk.yml

Lines changed: 44 additions & 37 deletions
Large diffs are not rendered by default.

integrations/aws-strands/ARCHITECTURE.md

Lines changed: 26 additions & 21 deletions
Large diffs are not rendered by default.
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
{
2+
"about": [
3+
"Every RUN_ERROR code the two AWS Strands bridges can emit, and the message text that goes with each one.",
4+
"Clients and mock harnesses match both the code and the message literally, so this file is a wire contract and not documentation.",
5+
"One table, two runtimes. Both suites read this file: Python through tests/error_code_table.py, TypeScript through src/__tests__/error-code-table.ts. Each drives its own bridge to the terminal paths below and asserts the emitted RUN_ERROR against the entry here, so a code marked shared carries the same text on both sides because both sides are matched against this one copy of it.",
6+
"Message templates render every interpolated value as {} so the two languages can be compared without their interpolation syntax getting in the way, and are otherwise the literal text exactly, whitespace included: a run of spaces or a newline a bridge puts on the wire is text a client matching literally has to match, and a bridge that pads or wraps its sentence differently from the other one has diverged.",
7+
"'messages' must hold identical text on every side listed in 'sides'. 'sideOnlyMessages' carries text one side alone can produce, and needs a note saying why.",
8+
"Known limit: this is data, not a reading of either source. Adding a code to one bridge without adding it here fails nothing. See the error-code contract section of ARCHITECTURE.md."
9+
],
10+
"codes": [
11+
{
12+
"code": "ADAPTER_BUG",
13+
"sides": ["python", "typescript"],
14+
"messages": ["{}"],
15+
"note": "Reported for the escaping exceptions that point at this adapter's own code rather than at the provider or the SDK. The trigger sets are each language's equivalents, not the same names: TypeError and NameError in Python, TypeError and ReferenceError in TypeScript, plus AttributeError in Python for the property access TypeScript already reports as a TypeError. Those types are also what code outside this adapter raises, so each bridge withholds the claim where it knows the fault came from elsewhere: a failure arriving from inside the Strands call, and a tool result JSON cannot carry. Both bridges are exposed to the second one, through json.dumps in Python and through JSON.stringify in TypeScript, but not over the same values: JSON.stringify drops functions, undefined and symbols rather than failing over them, and throws only over a BigInt or a structure that refers back to itself, where json.dumps has no undefined or symbol to drop and raises a TypeError over a function. Adapter code that runs inside the Strands call, a registered hook or a proxy tool, raises past that boundary, so it reports STRANDS_ERROR in Python and on the TypeScript orchestrator path, and STRANDS_FORCE_STOP on the TypeScript single-agent path, where every throw out of agent.stream() is recorded as the forced stop and never reaches the classifier."
16+
},
17+
{
18+
"code": "CONTINUATION_TOOL_NAME_UNRESOLVED",
19+
"sides": ["python", "typescript"],
20+
"messages": [
21+
"Cannot name the tool behind continuation tool result(s) {}: absent from the input messages and from the native session history"
22+
]
23+
},
24+
{
25+
"code": "ENCODING_ERROR",
26+
"sides": ["python", "typescript"],
27+
"messages": ["Encoding error: {}"]
28+
},
29+
{
30+
"code": "FRONTEND_TOOL_IDENTITY_ERROR",
31+
"sides": ["python", "typescript"],
32+
"messages": ["{}"],
33+
"note": "Reports a frontend call that cannot be correlated through Strands' native tool-use id. The template is bare because the message is the raised identity exception's own text, so what a client reads here is not pinned by this file at all. The three sentences that exception is constructed with are worded the same on both sides, and differ only in how they quote the offending value: Python interpolates it with !r where TypeScript wraps it in single quotes, which render alike for any ordinary tool name or native tool-use id and diverge only for a value that itself contains a quote."
34+
},
35+
{
36+
"code": "FRONTEND_TOOL_NOT_REGISTERED",
37+
"sides": ["python"],
38+
"messages": [
39+
"Cannot resume the frontend tool calls waiting on this thread: {}."
40+
],
41+
"note": "Python only. Refuses a resume whose parked frontend tool calls cannot be matched to a registered tool, and is part of the durable frontend-result recovery path that has no TypeScript counterpart yet."
42+
},
43+
{
44+
"code": "FRONTEND_TOOL_RESULT_CONFLICT",
45+
"sides": ["python"],
46+
"messages": [
47+
"A different result is already recorded for frontend tool call {}.",
48+
"A different result is already recorded for this frontend tool call."
49+
],
50+
"note": "Python only. Part of the durable frontend-result recovery path."
51+
},
52+
{
53+
"code": "FRONTEND_TOOL_RESULT_DUPLICATE",
54+
"sides": ["python"],
55+
"messages": ["Duplicate frontend tool result: {}"],
56+
"note": "Python only. Part of the durable frontend-result recovery path."
57+
},
58+
{
59+
"code": "FRONTEND_TOOL_WAIT_STATE_ERROR",
60+
"sides": ["python"],
61+
"messages": ["{}"],
62+
"note": "Python only. Part of the durable frontend-result recovery path."
63+
},
64+
{
65+
"code": "INTERRUPT_EXPIRED",
66+
"sides": ["python", "typescript"],
67+
"messages": ["Interrupt '{}' has expired."]
68+
},
69+
{
70+
"code": "INTERRUPT_RECONCILIATION_ERROR",
71+
"sides": ["python", "typescript"],
72+
"messages": ["Active interrupt tool result reconciliation failed"]
73+
},
74+
{
75+
"code": "INTERRUPT_RESUME_ERROR",
76+
"sides": ["python"],
77+
"messages": [
78+
"A submitted resume must contain at least one entry",
79+
"Cannot resume without an active native interrupt checkpoint",
80+
"Interrupt '{}' carries an expiry that is not a timestamp: {}",
81+
"Resume contains duplicate interrupt id: {}",
82+
"Resume entries must contain a non-blank interrupt id",
83+
"Resume references an interrupt that is not open: {}"
84+
],
85+
"note": "Python only. One of the four typed interrupt-protocol failures. It also covers the resume entries TypeScript rejects under UNKNOWN_INTERRUPT_ID, so the two bridges answer an unknown interrupt id with different codes. The unreadable-expiry message is Python-only in behaviour as well: TypeScript reads expiresAt through the Date constructor, which turns an unparseable value into an Invalid Date that compares false, so that bridge lets the resume through where this one refuses it."
86+
},
87+
{
88+
"code": "INTERRUPT_SESSION_CAPABILITY_ERROR",
89+
"sides": ["python", "typescript"],
90+
"messages": [],
91+
"sideOnlyMessages": {
92+
"python": [
93+
"Mixed frontend-proxy/native interrupt state requires session_id, a stable agent_id, and a session_repository exposing list_messages() and update_message()"
94+
],
95+
"typescript": [
96+
"Mixed frontend-proxy/native interrupt state requires a session manager exposing saveSnapshot() and an agent exposing messages"
97+
]
98+
},
99+
"note": "Both bridges refuse the same checkpoint, and each sentence names the capability its own SDK's session API actually has. Python's reconciliation rewrites persisted messages one at a time, so its probe reads session_id, a stable agent_id and a session_repository exposing list_messages() and update_message(); TypeScript's writes a whole-agent snapshot, so its probe reads a session manager exposing saveSnapshot() and an agent exposing messages. Sharing one sentence would name an API the reader's SDK does not have, which is the reasoning SESSION_MANAGER_INVALID_TYPE already carries. The TypeScript gate asks for one thing its sentence does not name, a readable and writable agent app state, so the two texts are not a summary of the same check either."
100+
},
101+
{
102+
"code": "INTERRUPT_SESSION_REQUIRED",
103+
"sides": ["python", "typescript"],
104+
"messages": [
105+
"A SessionManager is required for a mixed frontend-proxy/native interrupt checkpoint"
106+
]
107+
},
108+
{
109+
"code": "INVALID_PAYLOAD",
110+
"sides": ["python", "typescript"],
111+
"messages": [
112+
"Invalid payload for interrupt '{}': expected an object.",
113+
"Invalid payload for interrupt '{}': missing required keys: {}.",
114+
"Invalid payload for interrupt '{}': {}"
115+
]
116+
},
117+
{
118+
"code": "MEDIA_RESOLUTION_FAILED",
119+
"sides": ["python", "typescript"],
120+
"messages": [
121+
"All media content blocks failed conversion and no text fallback is available"
122+
]
123+
},
124+
{
125+
"code": "PARTIAL_RESUME",
126+
"sides": ["python", "typescript"],
127+
"messages": [
128+
"Partial resume: missing interrupt IDs: {}. All open interrupts must be addressed."
129+
]
130+
},
131+
{
132+
"code": "PENDING_INTERRUPTS",
133+
"sides": ["python", "typescript"],
134+
"messages": [
135+
"Thread has pending interrupts. Include resume[] to address them."
136+
]
137+
},
138+
{
139+
"code": "SEED_BUILD_ERROR",
140+
"sides": ["typescript"],
141+
"messages": ["Failed to build conversation seed: {}"],
142+
"note": "TypeScript only. Python seeds a thread's history inside the run rather than through a separate preflight build, so it has no point at which this can fail on its own."
143+
},
144+
{
145+
"code": "SESSION_MANAGER_ERROR",
146+
"sides": ["python", "typescript"],
147+
"messages": ["Failed to initialize session manager: {}"]
148+
},
149+
{
150+
"code": "SESSION_MANAGER_INVALID_TYPE",
151+
"sides": ["python", "typescript"],
152+
"messages": [],
153+
"sideOnlyMessages": {
154+
"python": [
155+
"session_manager_provider returned {}; expected a SessionManager instance"
156+
],
157+
"typescript": [
158+
"sessionManagerProvider returned {}; expected a SessionManager instance"
159+
]
160+
},
161+
"note": "The message names the configuration option that returned the wrong value, and that option is spelled session_manager_provider in Python and sessionManagerProvider in TypeScript. Sharing one spelling would point a developer at an option their SDK does not have."
162+
},
163+
{
164+
"code": "STRANDS_ERROR",
165+
"sides": ["python", "typescript"],
166+
"messages": ["{}"]
167+
},
168+
{
169+
"code": "STRANDS_FORCE_STOP",
170+
"sides": ["python", "typescript"],
171+
"messages": ["{}"]
172+
},
173+
{
174+
"code": "THREAD_AGENT_CONFIG_ERROR",
175+
"sides": ["typescript"],
176+
"messages": ["Failed to build per-thread agent config: {}"],
177+
"note": "Reported when the caller's per-thread agent hook throws. The hook is genuinely not the same option on the two sides: TypeScript spells it threadAgentConfig and it returns a partial AgentConfig, Python spells it thread_agent_kwargs and it returns keyword arguments for StrandsAgentCore, so each bridge names the option a developer of that SDK actually has and the sentence is per side, on the reasoning SESSION_MANAGER_INVALID_TYPE already carries. The CODE differing is a separate matter and is not defended here: the Python half is THREAD_AGENT_KWARGS_ERROR, the two are one failure under two code names, and unifying them is follow-up work for whoever owns the per-thread agent provider."
178+
},
179+
{
180+
"code": "THREAD_AGENT_KWARGS_ERROR",
181+
"sides": ["python"],
182+
"messages": ["Failed to build per-thread agent kwargs: {}"],
183+
"note": "The Python half of THREAD_AGENT_CONFIG_ERROR. See that entry for why the option is named differently on each side, and for the note that unifying the two code names is follow-up work."
184+
},
185+
{
186+
"code": "THREAD_BUSY",
187+
"sides": ["python", "typescript"],
188+
"messages": [
189+
"Another run is already in progress on {}. Wait for RUN_FINISHED before starting another."
190+
],
191+
"sideOnlyMessages": {
192+
"python": [
193+
"This orchestrator is paused at an interrupt on thread \"{}\". Answer that interrupt before starting another run."
194+
]
195+
},
196+
"note": "Only Python parks an orchestrator at an interrupt across runs, so only Python can refuse a turn for that reason, and the refusal is its own sentence because no run is in flight then and answering the interrupt, not RUN_FINISHED, is the way out. The shared template is the same text on both sides but not the same range of renderings: the scope it interpolates is a thread on either side, and on Python also the whole orchestrator when one instance is shared by every thread, which TypeScript guards per thread only."
197+
},
198+
{
199+
"code": "UNKNOWN_INTERRUPT_ID",
200+
"sides": ["python", "typescript"],
201+
"messages": ["No pending interrupts for this thread."],
202+
"sideOnlyMessages": {
203+
"typescript": [
204+
"This agent did not issue any interrupts to resume: {}. Resume entries must reference an outstanding interruptId."
205+
]
206+
},
207+
"note": "Python rejects a resume entry naming an interrupt it never issued in its resume preflight, under INTERRUPT_RESUME_ERROR, so it never produces this second message."
208+
}
209+
],
210+
"sharedMessageConstants": {
211+
"forceStopFallback": "The Strands agent stopped unexpectedly."
212+
}
213+
}

0 commit comments

Comments
 (0)