Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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]'
99 changes: 85 additions & 14 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,27 +1461,73 @@ 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.
const markerPattern = new RegExp(
`\\s*${TOOL_RESULTS_RECEIVED_MARKER
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`,
'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.
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.
let 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(
markerPattern,
'',
)
return cleaned ? { ...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
}
message.message.content = remaining
} else if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}

if (
streamingToolExecutor &&
!toolUseContext.abortController.signal.aborted
) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message)
}
if (!withheld) {
yield yieldMessage
}
}

Expand Down Expand Up @@ -2288,9 +2340,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
Loading