From 5e3df3cc99c0b49d7df5c4ac0ce562942c0112e6 Mon Sep 17 00:00:00 2001 From: Colin Bullock Date: Wed, 13 May 2026 09:47:03 -0700 Subject: [PATCH 1/2] fix: surface MaxTokensError when max_tokens truncates tool input JSON --- strands-ts/src/models/__tests__/model.test.ts | 28 +++++++++++++++++-- strands-ts/src/models/model.ts | 14 ++++++++-- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/strands-ts/src/models/__tests__/model.test.ts b/strands-ts/src/models/__tests__/model.test.ts index 3530e68df..276ef2734 100644 --- a/strands-ts/src/models/__tests__/model.test.ts +++ b/strands-ts/src/models/__tests__/model.test.ts @@ -323,16 +323,16 @@ describe('Model', () => { ) }) - it('preserves SyntaxError instead of overwriting with MaxTokensError when tool input JSON is malformed', async () => { + it('throws MaxTokenError when contentBlockStop arrives with truncated tool input JSON and stopReason is maxTokens', async () => { const provider = new TestModelProvider(async function* () { yield { type: 'modelMessageStartEvent', role: 'assistant' } yield { type: 'modelContentBlockStartEvent', - start: { type: 'toolUseStart', toolUseId: 'tool1', name: 'get_weather' }, + start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' }, } yield { type: 'modelContentBlockDeltaEvent', - delta: { type: 'toolUseInputDelta', input: '{invalid json' }, + delta: { type: 'toolUseInputDelta', input: '{"field": "value"' }, } yield { type: 'modelContentBlockStopEvent' } yield { type: 'modelMessageStopEvent', stopReason: 'maxTokens' } @@ -340,6 +340,28 @@ describe('Model', () => { const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] + await expect(async () => await collectGenerator(provider.streamAggregated(messages))).rejects.toThrow( + MaxTokensError + ) + }) + + it('surfaces SyntaxError as cause when tool input JSON is malformed and stopReason is not maxTokens', async () => { + const provider = new TestModelProvider(async function* () { + yield { type: 'modelMessageStartEvent', role: 'assistant' } + yield { + type: 'modelContentBlockStartEvent', + start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' }, + } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'toolUseInputDelta', input: '{invalid json' }, + } + yield { type: 'modelContentBlockStopEvent' } + yield { type: 'modelMessageStopEvent', stopReason: 'toolUse' } + }) + + const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] + try { await collectGenerator(provider.streamAggregated(messages)) expect.fail('Expected error to be thrown') diff --git a/strands-ts/src/models/model.ts b/strands-ts/src/models/model.ts index 892c46e97..75b141bcf 100644 --- a/strands-ts/src/models/model.ts +++ b/strands-ts/src/models/model.ts @@ -349,6 +349,7 @@ export abstract class Model { let finalStopReason: StopReason | null = null let metadata: ModelMetadataEvent | undefined = undefined let redactionMessage: string | undefined = undefined + let deferredToolInputParseError: SyntaxError | undefined = undefined for await (const event_data of this.stream(messages, options)) { const event = this._convert_to_class_event(event_data) @@ -425,8 +426,8 @@ export abstract class Model { yield block } catch (e: unknown) { if (e instanceof SyntaxError) { - logger.error('unable to parse JSON string', e) - throw e + logger.error('unable to parse tool input JSON', e) + deferredToolInputParseError = e } } break @@ -471,7 +472,10 @@ export abstract class Model { if (!stoppedMessage || !finalStopReason) { // If we exit the loop without completing a message or stop reason, throw an error - throw new ModelError('Stream ended without completing a message') + throw new ModelError( + 'Stream ended without completing a message', + deferredToolInputParseError ? { cause: deferredToolInputParseError } : undefined + ) } // Attach metadata after redaction so it applies to the final message. @@ -495,6 +499,10 @@ export abstract class Model { ) } + if (deferredToolInputParseError !== undefined) { + throw deferredToolInputParseError + } + // Return the final message with stop reason and optional metadata const result: StreamAggregatedResult = { message: stoppedMessage, From 646316ece8fcd6e8c9c9b16b904e88d5aa1fa3e3 Mon Sep 17 00:00:00 2001 From: Colin Bullock Date: Wed, 20 May 2026 08:54:24 -0700 Subject: [PATCH 2/2] Address feedback --- strands-ts/src/models/__tests__/model.test.ts | 28 +++++++++++++++++++ strands-ts/src/models/model.ts | 2 +- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/strands-ts/src/models/__tests__/model.test.ts b/strands-ts/src/models/__tests__/model.test.ts index 276ef2734..0d39ce7dd 100644 --- a/strands-ts/src/models/__tests__/model.test.ts +++ b/strands-ts/src/models/__tests__/model.test.ts @@ -368,6 +368,34 @@ describe('Model', () => { } catch (error) { expect(error).toBeInstanceOf(ModelError) expect(error).not.toBeInstanceOf(MaxTokensError) + expect((error as ModelError).message).toBe('unable to parse tool input JSON') + expect((error as ModelError).cause).toBeInstanceOf(SyntaxError) + } + }) + + it('attaches SyntaxError as cause when stream ends without a stop event after malformed tool input', async () => { + const provider = new TestModelProvider(async function* () { + yield { type: 'modelMessageStartEvent', role: 'assistant' } + yield { + type: 'modelContentBlockStartEvent', + start: { type: 'toolUseStart', toolUseId: 't', name: 'tool' }, + } + yield { + type: 'modelContentBlockDeltaEvent', + delta: { type: 'toolUseInputDelta', input: '{invalid json' }, + } + yield { type: 'modelContentBlockStopEvent' } + }) + + const messages = [new Message({ role: 'user', content: [new TextBlock('Hi')] })] + + try { + await collectGenerator(provider.streamAggregated(messages)) + expect.fail('Expected error to be thrown') + } catch (error) { + expect(error).toBeInstanceOf(ModelError) + expect(error).not.toBeInstanceOf(MaxTokensError) + expect((error as ModelError).message).toBe('Stream ended without completing a message') expect((error as ModelError).cause).toBeInstanceOf(SyntaxError) } }) diff --git a/strands-ts/src/models/model.ts b/strands-ts/src/models/model.ts index 75b141bcf..76f8a744a 100644 --- a/strands-ts/src/models/model.ts +++ b/strands-ts/src/models/model.ts @@ -500,7 +500,7 @@ export abstract class Model { } if (deferredToolInputParseError !== undefined) { - throw deferredToolInputParseError + throw new ModelError('unable to parse tool input JSON', { cause: deferredToolInputParseError }) } // Return the final message with stop reason and optional metadata