Skip to content

Commit bfa1693

Browse files
committed
perf(streaming): inline tool call streaming + fix large-context hang
- Stream content_block_start/delta/stop inline as tool events arrive, so the tool name is visible immediately rather than after full stream - Stop accumulating tool input in totalContent; only the tool name is needed for the bracket-format fallback parser - Short-circuit parseBracketToolCalls with includes('[Called ') guard to prevent catastrophic regex backtracking on large JSON payloads - Detect truncated tool calls (no stop event) and close the block cleanly with a user-visible error instead of forwarding invalid JSON - Add stopped/blockIndex to ToolCallState; update stream-transformer.ts and types.ts accordingly fix(auth): use profileArn region for OIDC token refresh - IDC login and kiro-cli sync both defaulted oidcRegion to us-east-1 when no explicit idc_region was set, causing token refreshes to hit oidc.us-east-1.amazonaws.com for eu-central-1 accounts - Now derive oidcRegion from the profileArn region, which is authoritative
1 parent adfd1b5 commit bfa1693

7 files changed

Lines changed: 228 additions & 25 deletions

File tree

src/__tests__/stream-transformer.test.ts

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,14 @@ describe('transformSdkStream: tool call streaming', () => {
173173
]
174174
const sdk = makeSdkResponse(events)
175175
const result = await collectSdk(transformSdkStream(sdk, 'auto', 'conv-1'))
176-
const toolBlock = result.find(
177-
(e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.arguments
178-
)
179-
expect(toolBlock).toBeDefined()
180-
const args = JSON.parse(toolBlock.choices[0].delta.tool_calls[0].function.arguments)
176+
177+
// With inline streaming, args arrive as multiple partial_json deltas.
178+
// Collect all argument chunks for this tool and concatenate.
179+
const argChunks = result
180+
.filter((e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.arguments !== undefined)
181+
.map((e: any) => e.choices[0].delta.tool_calls[0].function.arguments)
182+
.join('')
183+
const args = JSON.parse(argChunks)
181184
expect(args.filePath).toBe('/tmp/x.txt')
182185
expect(args.content).toBe('hello')
183186
})
@@ -193,6 +196,73 @@ describe('transformSdkStream: tool call streaming', () => {
193196
const toolBlocks = result.filter((e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name)
194197
expect(toolBlocks.length).toBe(2)
195198
})
199+
200+
test('truncated tool call (no stop event) closes the block and emits error text', async () => {
201+
// Simulates Kiro cutting the stream mid-tool-call (large .frm JSON that
202+
// exceeds the response size limit).
203+
// With inline streaming: content_block_start IS emitted (tool name visible),
204+
// then on truncation the block is closed and an error text is appended.
205+
const events = [
206+
{ assistantResponseEvent: { content: 'I will edit the file.' } },
207+
{
208+
toolUseEvent: {
209+
toolUseId: 't-trunc',
210+
name: 'replaceFileContent',
211+
input: '{"path":"/app.frm","content":{"forms":[{"name":"form1"'
212+
}
213+
}
214+
// note: no stop event — stream cut off mid-JSON
215+
]
216+
const sdk = makeSdkResponse(events)
217+
const result = await collectSdk(transformSdkStream(sdk, 'auto', 'conv-1'))
218+
219+
// content_block_start IS emitted (inline streaming — tool name is immediately visible)
220+
const toolNameBlocks = result.filter(
221+
(e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name
222+
)
223+
expect(toolNameBlocks.length).toBeGreaterThan(0)
224+
expect(toolNameBlocks[0]!.choices[0].delta.tool_calls[0].function.name).toBe(
225+
'replaceFileContent'
226+
)
227+
228+
// Must emit a text delta explaining the truncation
229+
const allText = result
230+
.filter((e) => e.choices?.[0]?.delta?.content)
231+
.map((e: any) => e.choices[0].delta.content)
232+
.join('')
233+
expect(allText).toContain('truncated')
234+
expect(allText).toContain('replaceFileContent')
235+
})
236+
237+
test('tool input is NOT accumulated into totalContent — bracket parser never sees large JSON', async () => {
238+
// Regression: previously `totalContent += tc.input` caused catastrophic
239+
// regex backtracking when the tool input was hundreds of KB of JSON.
240+
const largeInput = JSON.stringify(
241+
Array.from({ length: 100 }, (_, i) => ({ id: i, value: 'x'.repeat(500) }))
242+
)
243+
const events = [
244+
{
245+
toolUseEvent: {
246+
toolUseId: 't-big',
247+
name: 'write',
248+
input: largeInput.slice(0, largeInput.length / 2)
249+
}
250+
},
251+
{ toolUseEvent: { toolUseId: 't-big', input: largeInput.slice(largeInput.length / 2) } },
252+
{ toolUseEvent: { toolUseId: 't-big', stop: true } }
253+
]
254+
const sdk = makeSdkResponse(events)
255+
const start = performance.now()
256+
const result = await collectSdk(transformSdkStream(sdk, 'auto', 'conv-1'))
257+
const elapsed = performance.now() - start
258+
259+
// Must complete in well under 1s — would hang for seconds with catastrophic backtracking
260+
expect(elapsed).toBeLessThan(500)
261+
262+
// Tool call must still be emitted correctly
263+
const toolBlocks = result.filter((e) => e.choices?.[0]?.delta?.tool_calls?.[0]?.function?.name)
264+
expect(toolBlocks.length).toBeGreaterThan(0)
265+
})
196266
})
197267

198268
// ── SDK stream: real token usage ─────────────────────────────────────────────

src/__tests__/tool-call-parser.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,33 @@ describe('deduplicateToolCalls', () => {
6565
})
6666
})
6767

68+
describe('parseBracketToolCalls: large JSON content (catastrophic backtracking guard)', () => {
69+
test('returns empty array fast when "[Called " is absent — even with huge JSON', () => {
70+
// A 500 KB JSON array with many nested braces — previously caused catastrophic
71+
// backtracking in the regex. The short-circuit guard must make this instant.
72+
const largeJson = JSON.stringify(
73+
Array.from({ length: 200 }, (_, i) => ({
74+
id: i,
75+
data: 'x'.repeat(1000),
76+
nested: { a: 1, b: [1, 2, 3] }
77+
}))
78+
)
79+
const start = performance.now()
80+
const result = parseBracketToolCalls(largeJson)
81+
const elapsed = performance.now() - start
82+
expect(result).toHaveLength(0)
83+
expect(elapsed).toBeLessThan(50) // must be near-instant, not seconds
84+
})
85+
86+
test('still finds bracket tool calls when "[Called " is present in large text', () => {
87+
const prefix = 'x'.repeat(10000)
88+
const text = `${prefix}[Called bash with args: {"command":"ls"}]${prefix}`
89+
const result = parseBracketToolCalls(text)
90+
expect(result).toHaveLength(1)
91+
expect(result[0]!.name).toBe('bash')
92+
})
93+
})
94+
6895
describe('cleanToolCallsFromText', () => {
6996
test('removes bracket tool call from text', () => {
7097
const text = 'Here is the result [Called bash with args: {"command":"ls"}] done.'

src/core/auth/idc-auth-method.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,14 @@ export class IdcAuthMethod {
6161
const invokedWithoutPrompts = !inputs || Object.keys(inputs).length === 0
6262

6363
const startUrl = normalizeStartUrl(inputs?.start_url || this.config.idc_start_url) || undefined
64-
const oidcRegion: KiroRegion = normalizeRegion(inputs?.idc_region || this.config.idc_region)
64+
// For the OIDC device-code flow, prefer explicit idc_region, then fall back to
65+
// the region from a pre-configured profileArn, then default_region. This ensures
66+
// accounts with a eu-central-1 profileArn don't hit oidc.us-east-1.amazonaws.com.
6567
const configuredProfileArn = this.config.idc_profile_arn
68+
const arnRegion = extractRegionFromArn(configuredProfileArn)
69+
const oidcRegion: KiroRegion = normalizeRegion(
70+
inputs?.idc_region || this.config.idc_region || arnRegion || configuredServiceRegion
71+
)
6672
logger.log('IDC authorize: resolved defaults', {
6773
hasInputs: !!inputs && Object.keys(inputs).length > 0,
6874
invokedWithoutPrompts,
@@ -160,7 +166,7 @@ export class IdcAuthMethod {
160166
email,
161167
authMethod: 'idc',
162168
region: serviceRegion,
163-
oidcRegion,
169+
oidcRegion: oidcRegion,
164170
clientId: token.clientId,
165171
clientSecret: token.clientSecret,
166172
profileArn,

src/plugin/streaming/sdk-stream-transformer.ts

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,21 +130,82 @@ export async function* transformSdkStream(
130130
}
131131
} else if (event.toolUseEvent) {
132132
const tc = event.toolUseEvent
133+
// Only accumulate the tool *name* into totalContent — the input JSON is
134+
// never bracket-format and can be hundreds of KB; including it causes
135+
// catastrophic backtracking in parseBracketToolCalls.
133136
if (tc.name) totalContent += tc.name
134-
if (tc.input) totalContent += tc.input
135137

136138
if (tc.toolUseId) {
137139
if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) {
138140
currentToolCall.input += tc.input || ''
141+
if (tc.input) {
142+
const _c = convertToOpenAI(
143+
{
144+
type: 'content_block_delta',
145+
index: currentToolCall.blockIndex,
146+
delta: { type: 'input_json_delta', partial_json: tc.input }
147+
},
148+
conversationId,
149+
model
150+
)
151+
if (_c !== null) yield _c
152+
}
139153
} else if (tc.name) {
140154
if (currentToolCall) toolCalls.push(currentToolCall)
155+
for (const ev of stopBlock(streamState.textBlockIndex, streamState)) {
156+
const _c = convertToOpenAI(ev, conversationId, model)
157+
if (_c !== null) yield _c
158+
}
159+
const blockIndex = streamState.nextBlockIndex++
141160
currentToolCall = {
142161
toolUseId: tc.toolUseId,
143162
name: toolNameMapper ? toolNameMapper(tc.name) : tc.name,
144-
input: tc.input || ''
163+
input: tc.input || '',
164+
stopped: false,
165+
blockIndex
166+
}
167+
{
168+
const _c = convertToOpenAI(
169+
{
170+
type: 'content_block_start',
171+
index: blockIndex,
172+
content_block: {
173+
type: 'tool_use',
174+
id: tc.toolUseId,
175+
name: currentToolCall.name,
176+
input: {}
177+
}
178+
},
179+
conversationId,
180+
model
181+
)
182+
if (_c !== null) yield _c
183+
}
184+
if (tc.input) {
185+
const _c = convertToOpenAI(
186+
{
187+
type: 'content_block_delta',
188+
index: blockIndex,
189+
delta: { type: 'input_json_delta', partial_json: tc.input }
190+
},
191+
conversationId,
192+
model
193+
)
194+
if (_c !== null) yield _c
145195
}
146196
}
147197
if (tc.stop && currentToolCall) {
198+
currentToolCall.stopped = true
199+
// The AI SDK dispatches tools on message_delta, not on block stop,
200+
// so closing inline is safe and lets the client show progress immediately.
201+
{
202+
const _c = convertToOpenAI(
203+
{ type: 'content_block_stop', index: currentToolCall.blockIndex },
204+
conversationId,
205+
model
206+
)
207+
if (_c !== null) yield _c
208+
}
148209
toolCalls.push(currentToolCall)
149210
currentToolCall = null
150211
}
@@ -172,7 +233,27 @@ export async function* transformSdkStream(
172233
}
173234

174235
if (currentToolCall) {
236+
// Stream cut off mid-tool-call. Close the open block so the event stream
237+
// is well-formed, then append a visible error so the model can recover.
238+
logger.debug(
239+
`[STREAM] Truncated tool call: name=${currentToolCall.name} id=${currentToolCall.toolUseId} inputLen=${currentToolCall.input.length}`
240+
)
241+
if (currentToolCall.blockIndex !== undefined) {
242+
const _c = convertToOpenAI(
243+
{ type: 'content_block_stop', index: currentToolCall.blockIndex },
244+
conversationId,
245+
model
246+
)
247+
if (_c !== null) yield _c
248+
}
175249
toolCalls.push(currentToolCall)
250+
for (const ev of createTextDeltaEvents(
251+
`\n\n[Kiro: tool call "${currentToolCall.name}" was truncated mid-stream — the response was too large. Try a smaller operation or split the task.]`,
252+
streamState
253+
)) {
254+
const _c = convertToOpenAI(ev, conversationId, model)
255+
if (_c !== null) yield _c
256+
}
176257
currentToolCall = null
177258
}
178259

@@ -205,25 +286,32 @@ export async function* transformSdkStream(
205286
if (_c !== null) yield _c
206287
}
207288

208-
const bracketToolCalls = parseBracketToolCalls(totalContent)
289+
const bracketToolCalls = totalContent.includes('[Called ')
290+
? parseBracketToolCalls(totalContent)
291+
: []
209292
if (bracketToolCalls.length > 0) {
210293
for (const btc of bracketToolCalls) {
211294
toolCalls.push({
212295
toolUseId: btc.toolUseId,
213296
name: btc.name,
214-
input: typeof btc.input === 'string' ? btc.input : JSON.stringify(btc.input)
297+
input: typeof btc.input === 'string' ? btc.input : JSON.stringify(btc.input),
298+
stopped: true
299+
// no blockIndex — these are emitted post-stream below
215300
})
216301
}
217302
}
218303

219304
const dedupedToolCalls = deduplicateToolCallsByContent(toolCalls)
220305

221-
if (dedupedToolCalls.length > 0) {
222-
const baseIndex = streamState.nextBlockIndex
223-
for (let i = 0; i < dedupedToolCalls.length; i++) {
224-
const tc = dedupedToolCalls[i]
306+
// SDK tool calls were already emitted inline (content_block_start/delta/stop
307+
// during streaming). Only bracket-format tool calls (blockIndex undefined)
308+
// need to be emitted here.
309+
const postStreamCalls = dedupedToolCalls.filter((tc) => tc.blockIndex === undefined)
310+
if (postStreamCalls.length > 0) {
311+
for (let i = 0; i < postStreamCalls.length; i++) {
312+
const tc = postStreamCalls[i]
225313
if (!tc) continue
226-
const blockIndex = baseIndex + i
314+
const blockIndex = streamState.nextBlockIndex++
227315

228316
{
229317
const _c = convertToOpenAI(
@@ -259,10 +347,7 @@ export async function* transformSdkStream(
259347
{
260348
type: 'content_block_delta',
261349
index: blockIndex,
262-
delta: {
263-
type: 'input_json_delta',
264-
partial_json: inputJson
265-
}
350+
delta: { type: 'input_json_delta', partial_json: inputJson }
266351
},
267352
conversationId,
268353
model

src/plugin/streaming/stream-transformer.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,13 @@ export async function* transformKiroStream(
168168
currentToolCall = {
169169
toolUseId: tc.toolUseId,
170170
name: tc.name,
171-
input: tc.input || ''
171+
input: tc.input || '',
172+
stopped: false
172173
}
173174
}
174175

175176
if (tc.stop && currentToolCall) {
177+
currentToolCall.stopped = true
176178
toolCalls.push(currentToolCall)
177179
currentToolCall = null
178180
}
@@ -227,13 +229,16 @@ export async function* transformKiroStream(
227229
if (_c !== null) yield _c
228230
}
229231

230-
const bracketToolCalls = parseBracketToolCalls(totalContent)
232+
const bracketToolCalls = totalContent.includes('[Called ')
233+
? parseBracketToolCalls(totalContent)
234+
: []
231235
if (bracketToolCalls.length > 0) {
232236
for (const btc of bracketToolCalls) {
233237
toolCalls.push({
234238
toolUseId: btc.toolUseId,
235239
name: btc.name,
236-
input: typeof btc.input === 'string' ? btc.input : JSON.stringify(btc.input)
240+
input: typeof btc.input === 'string' ? btc.input : JSON.stringify(btc.input),
241+
stopped: true
237242
})
238243
}
239244
}

src/plugin/streaming/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ export interface ToolCallState {
2222
toolUseId: string
2323
name: string
2424
input: string
25+
/** true only when the SDK sent a stop event for this tool call */
26+
stopped: boolean
27+
/**
28+
* Block index assigned when content_block_start was emitted inline during
29+
* streaming. Undefined for bracket-format tool calls which are emitted
30+
* post-stream. Used to avoid re-emitting SDK tool calls at the end.
31+
*/
32+
blockIndex?: number
2533
}
2634

2735
export const THINKING_START_TAG = '<thinking>'

src/plugin/sync/kiro-cli.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,12 @@ export async function syncFromKiroCli() {
4646

4747
const isIdc = row.key.includes('odic')
4848
const authMethod = isIdc ? 'idc' : 'desktop'
49-
const oidcRegion = normalizeRegion(data.region)
5049
let profileArn: string | undefined = data.profile_arn || data.profileArn
5150
if (!profileArn && isIdc) profileArn = activeProfileArn || readActiveProfileArnFromKiroCli()
52-
const serviceRegion = extractRegionFromArn(profileArn) || oidcRegion
51+
// serviceRegion wins over data.region: kiro-cli stores data.region as the
52+
// OIDC region (often us-east-1) regardless of where the account actually lives.
53+
const serviceRegion = extractRegionFromArn(profileArn) || normalizeRegion(data.region)
54+
const oidcRegion = serviceRegion
5355
const startUrl: string | undefined =
5456
typeof data.start_url === 'string'
5557
? data.start_url

0 commit comments

Comments
 (0)