diff --git a/strands-ts/src/models/__tests__/model.test.ts b/strands-ts/src/models/__tests__/model.test.ts index 3530e68df..0d39ce7dd 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,12 +340,62 @@ 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') + } 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 892c46e97..76f8a744a 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 new ModelError('unable to parse tool input JSON', { cause: deferredToolInputParseError }) + } + // Return the final message with stop reason and optional metadata const result: StreamAggregatedResult = { message: stoppedMessage,