Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ plan/
.tmp
.tmp-*
temp_reference/
.codelite/
9 changes: 9 additions & 0 deletions src/constants/messages.ts
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
export const NO_CONTENT_MESSAGE = '(no content)'

// Semantic assistant boundary injected by the OpenAI shim when a 'tool' role
// message must be followed by an 'assistant' message (Mistral / Devstral
// strict role sequence). The query loop detects this text to decide whether
// to continue the tool-execution path or treat the turn as stalled.
//
// IMPORTANT: if you change this string, update the detection in query.ts as
// well — both places must stay in sync.
export const TOOL_RESULTS_RECEIVED_MARKER = '[Tool results received]'
147 changes: 139 additions & 8 deletions src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
createMicrocompactBoundaryMessage,
} from './utils/messages.js'
import { analyzeContinuationIntent } from './utils/continuation.js'
import { TOOL_RESULTS_RECEIVED_MARKER } from './constants/messages.js'
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
import { prependUserContext, appendSystemContext } from './utils/api.js'
import {
Expand Down Expand Up @@ -995,6 +996,11 @@ async function* queryLoop(
// loop-exit signal. If false after streaming, we're done (modulo stop-hook retry).
const toolUseBlocks: ToolUseBlock[] = []
let needsFollowUp = false
// Tracks whether the streaming loop encountered a marker-only (shim
// artifact) assistant response this iteration — used by the
// continuation-nudge block below to force a nudge when the stripped
// text is empty (model effectively said nothing).
let markerOnlyStall = false

queryCheckpoint('query_setup_start')
const useStreamingToolExecution =
Expand Down Expand Up @@ -1455,19 +1461,110 @@ async function* queryLoop(
) {
withheld = true
}
if (!withheld) {
yield yieldMessage
}
if (message.type === 'assistant') {
assistantMessages.push(message)

const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
// Detect the semantic boundary the OpenAI shim injects so
// self-hosted providers (e.g. llama-server) which echo the
// shim's assistant-injection marker back in their response are
// handled correctly. Use the same whitespace-tolerant pattern
// as the strip logic below so detection and removal stay in
// sync even when the server wraps the marker in newlines.
// Detection pattern: \s* greedily matches surrounding
// whitespace/newlines so we find the marker even when the
// provider wraps it in formatting.
const markerPattern = new RegExp(
`\\s*${TOOL_RESULTS_RECEIVED_MARKER
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`,
'g',
)
// Strip pattern: matches only the escaped literal marker,
// without consuming adjacent whitespace. This prevents
// mid-sentence corruption when the marker is embedded in
// larger text (e.g. "continue [Marker] and finish" →
// "continue and finish" instead of "continueand finish").
// Strip pattern: matches only the escaped literal marker,
// without consuming adjacent whitespace. This prevents
// mid-sentence corruption when the marker is embedded in
// larger text (e.g. "continue [Marker] and finish" →
// "continue and finish" instead of "continueand finish").
const stripPattern = new RegExp(
TOOL_RESULTS_RECEIVED_MARKER.replace(
/[.*+?^${}()|[\]\\]/g,
'\\$&',
),
'g',
)
const hasToolResultsMarker = message.message.content.some(
content =>
content.type === 'text' &&
typeof content.text === 'string' &&
markerPattern.test(content.text),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Marker-only response (no tool_use blocks): this is a shim
// artifact, not a user-visible message. Withhold it from the
// UI and strip it from the state so the continuation-nudge
// block below sees a clean text tail and the capped nudge
// path handles the stall (max 20 retries).
if (hasToolResultsMarker && msgToolUseBlocks.length === 0) {
// Compute remaining text after removing the marker. Only
// withhold / stall when there is no substantive text left;
// a message containing real continuation text should stay
// visible even without tool_use blocks.
const remaining = message.message.content
.map((content: unknown) => {
if (
typeof content === 'object' &&
content !== null &&
'type' in content &&
'text' in content &&
(content as { type: string }).type === 'text'
) {
const textBlock = content as { text: string }
const cleaned = textBlock.text.replace(
stripPattern,
'',
)
return cleaned.trim().length > 0
? { ...textBlock, text: cleaned }
: null
}
return content
})
.filter(Boolean) as typeof message.message.content
const remainingText = remaining
.filter(b => typeof b === 'object' && b !== null && 'text' in b && (b as { text: string }).text.trim().length > 0)
.map(b => (b as { text: string }).text)
.join(' ')
if (remainingText.trim().length === 0) {
withheld = true
markerOnlyStall = true
}
if (remaining.length === 0) {
// Drop the empty message entirely rather than mutating
// it in place — preserves the "original message untouched"
// invariant and prevents content:[] from reaching the wire.
// Use `as typeof message.message.content` to avoid BetaTextBlock
// citations requirement on the placeholder block.
message.message.content = [{ type: 'text' as const, text: '' }] as typeof message.message.content
} else {
message.message.content = remaining
}
} else if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}
// Clear markerOnlyStall when a substantive message arrives
// after a marker-only one — a prior empty block shouldn't
// cause an unnecessary continuation nudge.
if (msgToolUseBlocks.length > 0 || !hasToolResultsMarker) {
markerOnlyStall = false
}
assistantMessages.push(message)
if (!withheld) {
yield yieldMessage
}

if (
streamingToolExecutor &&
Expand All @@ -1479,6 +1576,10 @@ async function* queryLoop(
}
}

if (message.type !== 'assistant') {
yield message
}

if (
streamingToolExecutor &&
!toolUseContext.abortController.signal.aborted
Expand Down Expand Up @@ -2288,9 +2389,28 @@ async function* queryLoop(
.join(' ')
.toLowerCase()

const { shouldNudge, reason: nudgeReason } = analyzeContinuationIntent(
let { shouldNudge, reason: nudgeReason } = analyzeContinuationIntent(
lastText,
)
) as { shouldNudge: boolean; reason?: string }

// Marker-only stall: when a self-hosted LLM echoes the shim's
// assistant-injection marker and the remaining text is empty (or
// whitespace), the model effectively said nothing. Force a nudge
// so the turn doesn't complete prematurely.
if (!shouldNudge && markerOnlyStall) {
shouldNudge = true
nudgeReason = 'marker_only_stall'
}

// Stop-hook-active: the model was already told the task is
// incomplete (via goal evaluation or hook blocking errors).
// Force a nudge even if the text itself has no continuation
// signal — the model is being prompted to continue from the
// previous turn's tool results.
if (!shouldNudge && stopHookActive) {
shouldNudge = true
nudgeReason = 'stop_hook_active'
}

if (shouldNudge) {
logForDebugging(
Expand Down Expand Up @@ -2333,6 +2453,17 @@ async function* queryLoop(
}
}

// Nudge cap hit and last message was marker-only: the model produced
// zero real output but the cap guard skipped the nudge block. Report
// a distinct reason so a stuck self-hosted endpoint isn't masked as
// a successful completion.
if (markerOnlyStall && state.continuationNudgeCount >= MAX_CONTINUATION_NUDGES) {
logForDebugging(
`Marker-only stall exhausted (${MAX_CONTINUATION_NUDGES} nudges) — model produced no real output`,
)
return { reason: 'marker_stall_exhausted' }
}

return { reason: 'completed' }
}

Expand Down
88 changes: 88 additions & 0 deletions src/query/goalContinuation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,92 @@ describe('goal query continuation', () => {
),
).toBe(false)
})

test('stop hook blocking error forces continuation nudge (stop_hook_active)', async () => {
const { query } = await import('../query.js')
let modelCalls = 0
let stopHookCalls = 0
const observedStopHookActive: boolean[] = []
const modelRequestMessages: any[][] = []
const appStateRef = {
current: {
...getDefaultAppState(),
},
}

const terminal = await (async () => {
const generator = query({
messages: [],
systemPrompt: asSystemPrompt([]),
userContext: {},
systemContext: {},
canUseTool: async () => ({ behavior: 'allow' }),
toolUseContext: makeToolUseContext(appStateRef),
querySource: 'sdk',
deps: {
uuid: () => `uuid-${modelCalls}`,
microcompact: async messages => ({ messages }),
autocompact: async () => ({ wasCompacted: false }),
goalEvaluationDeps: {
evaluateGoal: async () => ({
complete: true,
confidence: 0.9,
decision: 'complete' as const,
reason: 'Done.',
nextInstruction: null,
}),
saveGoalState: async () => {},
},
stopHookExecutionDeps: {
executeStopHooks: async function* (
_permissionMode: string | undefined,
_signal: AbortSignal | undefined,
_timeoutMs: number | undefined,
stopHookActive: boolean,
) {
observedStopHookActive.push(stopHookActive)
stopHookCalls++
// Produce a blocking error only on the first stop hook call.
// This sets stopHookActive=true for the next turn.
if (stopHookCalls === 1) {
yield {
blockingError: { blockingError: 'hook blocked' },
} as any
return
}
// Subsequent calls produce no blocking errors.
},
getStopHookMessage: () => 'stop hook blocked the turn',
isTeammate: () => false,
},
callModel: async function* ({ messages }: any) {
modelCalls++
modelRequestMessages.push(JSON.parse(JSON.stringify(messages)))
yield assistant(
`assistant-${modelCalls}`,
'Tasks done.',
)
},
} as any,
})
while (true) {
const next = await generator.next()
if (next.done) return next.value
}
})()

expect(modelCalls).toBeGreaterThanOrEqual(2)
// First call: stop hook blocks → stopHookActive set to true
expect(observedStopHookActive[0]).toBe(false)
// Second call (after stop_hook_blocking transition): stopHookActive = true
expect(observedStopHookActive[1]).toBe(true)
// The model returned text without tool calls → stop_hook_active forces
// a continuation nudge.
expect(terminal.reason).toBe('completed')
// Verify the nudge user message was injected into the second model call.
const nudgeMessages = modelRequestMessages[1].filter(
(m: any) => m.type === 'user' && m.isMeta,
)
expect(nudgeMessages.length).toBeGreaterThan(0)
})
})
Loading