@@ -47,11 +47,19 @@ export interface CruxSummarizer {
4747}
4848
4949/** Why a crux call produced no usable summaries (#235). */
50- export type CruxMissKind = "empty-toolCalls" | "unparseable" | "truncated" | "empty-parsed" ;
50+ export type CruxMissKind =
51+ | "empty-toolCalls"
52+ | "unparseable"
53+ | "truncated"
54+ | "empty-parsed"
55+ | "id-mismatch" ;
5156
5257export interface CruxMiss {
5358 kind : CruxMissKind ;
5459 finishReason : string | null ;
60+ /** `id-mismatch` only: how many entries came back, and the first one's raw id. */
61+ returned ?: number ;
62+ firstId ?: string ;
5563}
5664
5765function isTruncatedStop ( reason : string | null ) : boolean {
@@ -60,11 +68,24 @@ function isTruncatedStop(reason: string | null): boolean {
6068 return r === "length" || r === "max_tokens" ;
6169}
6270
63- /** Classify an empty/unusable crux reply. `null` means at least one usable summary. */
64- export function classifyCruxMiss ( res : ChatResponse , parsed : NodeCrux [ ] ) : CruxMiss | null {
71+ /**
72+ * Classify an empty/unusable crux reply. `null` means at least one usable summary.
73+ * With `requested`, a summary only counts when its id is one we asked for: enrich
74+ * looks results up by node id, so a summary under any other id is never applied.
75+ */
76+ export function classifyCruxMiss (
77+ res : ChatResponse ,
78+ parsed : NodeCrux [ ] ,
79+ requested ?: readonly string [ ] ,
80+ ) : CruxMiss | null {
6581 const finishReason = res . stopReason ;
66- if ( parsed . some ( ( p ) => p . summary . trim ( ) ) ) return null ;
82+ const wanted = requested ? new Set ( requested ) : undefined ;
83+ const matched = wanted ? parsed . filter ( ( p ) => wanted . has ( p . id ) ) : parsed ;
84+ if ( matched . some ( ( p ) => p . summary . trim ( ) ) ) return null ;
6785 if ( isTruncatedStop ( finishReason ) ) return { kind : "truncated" , finishReason } ;
86+ if ( parsed . length > 0 && matched . length === 0 ) {
87+ return { kind : "id-mismatch" , finishReason, returned : parsed . length , firstId : parsed [ 0 ] . id } ;
88+ }
6889 if ( parsed . length > 0 ) return { kind : "empty-parsed" , finishReason } ;
6990 const emptyTools = res . toolCalls . length === 0 ;
7091 const emptyText = ! res . text ?. trim ( ) ;
@@ -75,9 +96,29 @@ export function classifyCruxMiss(res: ChatResponse, parsed: NodeCrux[]): CruxMis
7596/** Per-file error text: miss class + the provider's finish_reason (#235). */
7697export function formatCruxMiss ( kind : CruxMissKind , finishReason : string | null ) : string {
7798 const fr = finishReason == null || finishReason === "" ? "null" : finishReason ;
99+ if ( kind === "id-mismatch" ) {
100+ return `model returned summaries but no id matched a requested target [${ kind } , finish_reason=${ fr } ]` ;
101+ }
78102 return `model returned no usable symbol summaries [${ kind } , finish_reason=${ fr } ]` ;
79103}
80104
105+ /** Longest echoed id shown in an error; a whole echoed row can carry a long signature. */
106+ const MAX_SHOWN_ID_CHARS = 200 ;
107+
108+ /**
109+ * The model's text for an `id-mismatch`, kept apart from {@link formatCruxMiss}:
110+ * the failure gate matches words like "quota" in the error message, and a
111+ * returned id can carry any source text, so it must never reach the gate.
112+ */
113+ export function formatCruxMissDetail ( miss : CruxMiss | null | undefined ) : string {
114+ if ( miss ?. kind !== "id-mismatch" || miss . firstId === undefined ) return "" ;
115+ const id =
116+ miss . firstId . length > MAX_SHOWN_ID_CHARS
117+ ? `${ miss . firstId . slice ( 0 , MAX_SHOWN_ID_CHARS ) } …`
118+ : miss . firstId ;
119+ return `first of ${ miss . returned ?? 1 } returned id(s): ${ JSON . stringify ( id ) } ` ;
120+ }
121+
81122const SYSTEM_PROMPT = `You explain code definitions for a code graph that helps engineers navigate a codebase.
82123
83124You are given ONE source file with 1-based line numbers, and a list of TARGET definitions in it. Describe EVERY target via the record_symbols tool.
@@ -122,27 +163,73 @@ function numberLines(source: string): string {
122163 . join ( "\n" ) ;
123164}
124165
166+ /**
167+ * `id=` is the LAST field of a target line, so the id ends where the line does.
168+ * With the id first nothing terminated it, and models asked for the id
169+ * "verbatim" copied the whole row — `a.cs#Level | variable | lines L58-L58 | sig`
170+ * — into `id` (#259). {@link resolveTargetId} still repairs echoes of either layout.
171+ */
125172function userContent ( input : FileCruxInput ) : string {
126173 const targets = input . nodes
127174 . map (
128175 ( n ) =>
129- `- id=${ n . id } | ${ n . kind } | lines L${ n . startLine } -L${ n . endLine } ` +
130- ( n . signature ? ` | ${ n . signature } ` : "" ) ,
176+ `- ${ n . kind } | lines L${ n . startLine } -L${ n . endLine } ` +
177+ ( n . signature ? ` | ${ n . signature } ` : "" ) +
178+ ` | id=${ n . id } ` ,
131179 )
132180 . join ( "\n" ) ;
133181 const n = input . nodes . length ;
134182 return `FILE: ${ input . path } \n\n${ numberLines ( input . source ) } \n\nTARGETS (${ n } — return all ${ n } , one entry per id):\n${ targets } ` ;
135183}
136184
137- /** Normalize the tool's parsed argument object into a {@link NodeCrux} list. */
138- function parseResults ( obj : { symbols ?: unknown } | undefined ) : NodeCrux [ ] {
185+ /** The longest requested id `test` accepts, or null. */
186+ function longestMatch ( requested : readonly string [ ] , test : ( id : string ) => boolean ) : string | null {
187+ let best : string | null = null ;
188+ for ( const id of requested ) if ( test ( id ) && ( best === null || id . length > best . length ) ) best = id ;
189+ return best ;
190+ }
191+
192+ /**
193+ * Map an id the model returned onto one of the ids we asked for, or null.
194+ *
195+ * Models echo decorated target rows instead of the bare id (#259, #298, #486).
196+ * Rather than cut the string at a guessed delimiter, every rule compares against
197+ * the requested ids, so the result is always an id we asked for — never a
198+ * fragment of a hallucinated one — and no rule assumes which characters an id
199+ * may contain. In order:
200+ * 1. exact match;
201+ * 2. a requested id followed by ` | ` (the row echoed from the id onwards);
202+ * 3. the same two with a leading `id=` removed;
203+ * 4. a row ending in ` | id=<requested id>` (the current layout echoed whole).
204+ * When several requested ids qualify, the longest wins.
205+ */
206+ export function resolveTargetId ( raw : string , requested : readonly string [ ] ) : string | null {
207+ const id = raw . trim ( ) ;
208+ if ( requested . includes ( id ) ) return id ;
209+ const echoed = longestMatch ( requested , ( r ) => id . startsWith ( `${ r } | ` ) ) ;
210+ if ( echoed ) return echoed ;
211+ if ( id . startsWith ( "id=" ) ) {
212+ const bare = id . slice ( 3 ) . trim ( ) ;
213+ if ( requested . includes ( bare ) ) return bare ;
214+ const bareEchoed = longestMatch ( requested , ( r ) => bare . startsWith ( `${ r } | ` ) ) ;
215+ if ( bareEchoed ) return bareEchoed ;
216+ }
217+ return longestMatch ( requested , ( r ) => id . endsWith ( ` | id=${ r } ` ) ) ;
218+ }
219+
220+ /**
221+ * Normalize the tool's parsed argument object into a {@link NodeCrux} list. Ids
222+ * are resolved against `requested`; one that matches nothing keeps its raw text
223+ * so {@link classifyCruxMiss} can report what the model actually sent.
224+ */
225+ function parseResults ( obj : { symbols ?: unknown } | undefined , requested : readonly string [ ] ) : NodeCrux [ ] {
139226 if ( ! obj || ! Array . isArray ( obj . symbols ) ) return [ ] ;
140227 const num = ( v : unknown ) => ( typeof v === "number" && Number . isFinite ( v ) ? Math . trunc ( v ) : 0 ) ;
141228 return obj . symbols
142229 . map ( ( s ) => s as Record < string , unknown > )
143230 . filter ( ( s ) => typeof s . id === "string" )
144231 . map ( ( s ) => ( {
145- id : s . id as string ,
232+ id : resolveTargetId ( s . id as string , requested ) ?? ( s . id as string ) ,
146233 summary : typeof s . summary === "string" ? s . summary . trim ( ) : "" ,
147234 crux_start : num ( s . crux_start ) ,
148235 crux_end : num ( s . crux_end ) ,
@@ -196,8 +283,9 @@ export class ChatCruxSummarizer implements CruxSummarizer {
196283 { role : "user" , content : userContent ( input ) } ,
197284 ] ,
198285 } ) ;
199- const parsed = parseResults ( argsFromResponse ( res ) ) ;
200- this . lastMiss = classifyCruxMiss ( res , parsed ) ;
286+ const requested = input . nodes . map ( ( n ) => n . id ) ;
287+ const parsed = parseResults ( argsFromResponse ( res ) , requested ) ;
288+ this . lastMiss = classifyCruxMiss ( res , parsed , requested ) ;
201289 return parsed ;
202290 }
203291}
0 commit comments