@ggui-ai/ui-gen 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.27.0",
4
- "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
3
+ "version": "0.28.0",
4
+ "description": "UI generation harness for GGUI — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
7
7
  "ggui",
@@ -189,9 +189,9 @@
189
189
  "puppeteer-core": "^24.40.0 || ^25.0.0",
190
190
  "react": "^18.0.0 || ^19.0.0",
191
191
  "react-dom": "^18.0.0 || ^19.0.0",
192
- "@ggui-ai/mcp-server-core": "0.27.0",
193
- "@ggui-ai/project-config": "0.27.0",
194
- "@ggui-ai/protocol": "0.27.0"
192
+ "@ggui-ai/project-config": "0.28.0",
193
+ "@ggui-ai/mcp-server-core": "0.28.0",
194
+ "@ggui-ai/protocol": "0.28.0"
195
195
  },
196
196
  "peerDependenciesMeta": {
197
197
  "@google/adk": {
@@ -233,11 +233,11 @@
233
233
  "openai": "^7.9.0",
234
234
  "typescript": "^5.0.0",
235
235
  "zod": "^4.3.6",
236
- "@ggui-ai/design": "0.27.0",
237
- "@ggui-ai/sandbox": "0.27.0",
238
- "@ggui-ai/gadgets": "0.27.0",
239
- "@ggui-ai/ui-visual-tester": "0.27.0",
240
- "@ggui-ai/wire": "0.27.0"
236
+ "@ggui-ai/sandbox": "0.28.0",
237
+ "@ggui-ai/gadgets": "0.28.0",
238
+ "@ggui-ai/wire": "0.28.0",
239
+ "@ggui-ai/ui-visual-tester": "0.28.0",
240
+ "@ggui-ai/design": "0.28.0"
241
241
  },
242
242
  "devDependencies": {
243
243
  "@google/adk": "^2.0.0",
@@ -252,9 +252,9 @@
252
252
  "react-dom": "^19.0.0",
253
253
  "tsup": "^8.5.0",
254
254
  "vitest": "^3.2.6",
255
- "@ggui-ai/mcp-server-core": "0.27.0",
256
- "@ggui-ai/project-config": "0.27.0",
257
- "@ggui-ai/protocol": "0.27.0"
255
+ "@ggui-ai/mcp-server-core": "0.28.0",
256
+ "@ggui-ai/project-config": "0.28.0",
257
+ "@ggui-ai/protocol": "0.28.0"
258
258
  },
259
259
  "engines": {
260
260
  "node": ">=22.0.0"
@@ -29,6 +29,7 @@ import type { LlmRoute } from '@ggui-ai/protocol';
29
29
  export function getBedrockModelId(model: string): string {
30
30
  const BEDROCK_MAP: Record<string, string> = {
31
31
  'anthropic/claude-haiku-4-5': 'us.anthropic.claude-haiku-4-5-20251001-v1:0',
32
+ 'anthropic/claude-haiku-5-5': 'us.anthropic.claude-haiku-5-5', // listed ACTIVE in us-east-1 (ggui#1743)
32
33
  'anthropic/claude-sonnet-4-6': 'us.anthropic.claude-sonnet-4-6',
33
34
  'anthropic/claude-opus-4-6': 'us.anthropic.claude-opus-4-6-v1:0',
34
35
  // Claude 5-family (ggui#706, strings verified 2026-09-02 against
@@ -40,7 +40,7 @@ import type {
40
40
  ModelRef,
41
41
  AppGenerationProfileEffort,
42
42
  } from "@ggui-ai/protocol";
43
- import { isGeneratorId, modelRefOfRoute } from "@ggui-ai/protocol";
43
+ import { DEFAULT_MODEL as PROTOCOL_DEFAULT_MODEL, isGeneratorId, modelRefOfRoute } from "@ggui-ai/protocol";
44
44
  import type { GadgetCatalogAdapter } from "@ggui-ai/gadgets";
45
45
  import type {
46
46
  GenerationMetadata,
@@ -85,7 +85,9 @@ import type { AgentConfig } from "./harness/llm-router.js";
85
85
 
86
86
  /** The slug for the OSS default seed generator. */
87
87
  const DEFAULT_TIER: GeneratorTier = "default";
88
- const DEFAULT_MODEL: ModelRef = "anthropic/claude-haiku-4-5";
88
+ // ggui#1794 — the open-source default is protocol's, one source: a default switch moves one constant
89
+ // (`@ggui-ai/protocol` `DEFAULT_MODEL`), never two. ggui#1743 moved it to Haiku 5.5 on benchmark's receipt.
90
+ const DEFAULT_MODEL: ModelRef = PROTOCOL_DEFAULT_MODEL;
89
91
 
90
92
  export interface CreateUiGeneratorOptions {
91
93
  /**
@@ -57,7 +57,7 @@ function runNoHardcodedEntities(input: AxisCheckInput): EvalIssue[] {
57
57
  mkIssue(
58
58
  "state.merge.no_hardcoded_entities",
59
59
  `Hardcoded entity array literal (multiple objects with "${idField}") in the component — entity data should come from state/props.`,
60
- `Remove the literal array. Seed state from props.{entityProp} and merge updates via stream.`,
60
+ `Remove the literal array: this data comes from ${all.filter((e) => e.idField === idField).map((e) => `props.${e.name}`).join(" / ") || "props"}. Read it there (\`?? []\` for an absent value, never a literal default), seed state from it, and merge updates via stream.`,
61
61
  ),
62
62
  );
63
63
  }
@@ -249,14 +249,128 @@ export function getInitialValuePropNames(contract?: DataContract): string[] {
249
249
  // Source scanning helpers
250
250
  // =============================================================================
251
251
 
252
+ /**
253
+ * The top-level property keys of the object literal whose `{` is at `open`, read by
254
+ * walking brackets and skipping strings, so a nested object or a `}` inside a string
255
+ * neither ends the literal early nor contributes keys. `undefined` when `open` is not a `{`.
256
+ */
257
+ function objectLiteralKeysAt(src: string, open: number): string[] | undefined {
258
+ if (src[open] !== "{") return undefined;
259
+ const keys: string[] = [];
260
+ let depth = 0;
261
+ let expectKey = true;
262
+ for (let i = open; i < src.length; i++) {
263
+ const c = src[i];
264
+ if (c === '"' || c === "'" || c === "`") {
265
+ // Skip the string; a quoted top-level key ("name": …) is a key too.
266
+ let j = i + 1;
267
+ while (j < src.length && src[j] !== c) j += src[j] === "\\" ? 2 : 1;
268
+ if (depth === 1 && expectKey && /^\s*:/.test(src.slice(j + 1))) keys.push(src.slice(i + 1, j));
269
+ i = j;
270
+ expectKey = false;
271
+ continue;
272
+ }
273
+ if (c === "{" || c === "[" || c === "(") {
274
+ depth++;
275
+ if (depth === 1) expectKey = true;
276
+ continue;
277
+ }
278
+ if (c === "}" || c === "]" || c === ")") {
279
+ depth--;
280
+ if (depth === 0) return keys;
281
+ continue;
282
+ }
283
+ if (depth !== 1) continue;
284
+ if (c === ",") {
285
+ expectKey = true;
286
+ continue;
287
+ }
288
+ if (expectKey && /[A-Za-z_$]/.test(c ?? "")) {
289
+ const m = /^([A-Za-z_$][\w$]*)\s*(:|,|\})/.exec(src.slice(i));
290
+ // `key: value`, or the shorthand `key,` / `key }`.
291
+ if (m !== null && m[1] !== undefined) keys.push(m[1]);
292
+ expectKey = false;
293
+ }
294
+ }
295
+ return keys;
296
+ }
297
+
298
+ /** The argument texts of the call whose `(` is at `open`, split on top-level commas. */
299
+ function callArgsAt(src: string, open: number): string[] {
300
+ const args: string[] = [];
301
+ let depth = 0;
302
+ let start = open + 1;
303
+ for (let i = open; i < src.length; i++) {
304
+ const c = src[i];
305
+ if (c === '"' || c === "'" || c === "`") {
306
+ let j = i + 1;
307
+ while (j < src.length && src[j] !== c) j += src[j] === "\\" ? 2 : 1;
308
+ i = j;
309
+ continue;
310
+ }
311
+ if (c === "(" || c === "{" || c === "[") depth++;
312
+ else if (c === ")" || c === "}" || c === "]") {
313
+ depth--;
314
+ if (depth === 0) {
315
+ args.push(src.slice(start, i));
316
+ return args;
317
+ }
318
+ } else if (c === "," && depth === 1) {
319
+ args.push(src.slice(start, i));
320
+ start = i + 1;
321
+ }
322
+ }
323
+ return args;
324
+ }
325
+
326
+ /**
327
+ * The keys an initial-state argument provides: an object literal (`{ … }`), a lazy
328
+ * initializer returning one (`() => ({ … })`), or an identifier bound to one in the same
329
+ * file (`const INITIAL: Form = { … }`, also behind a lazy `() => INITIAL`).
330
+ */
331
+ function initialStateKeys(src: string, argStart: number, argText: string): string[] {
332
+ const lead = argText.length - argText.trimStart().length;
333
+ let at = argStart + lead;
334
+ let text = argText.trim();
335
+ const lazy = /^\(\s*\)\s*=>\s*\(?\s*/.exec(text);
336
+ if (lazy !== null) {
337
+ at += lazy[0].length;
338
+ text = text.slice(lazy[0].length);
339
+ }
340
+ if (text.startsWith("{")) return objectLiteralKeysAt(src, at) ?? [];
341
+ const ident = /^([A-Za-z_$][\w$]*)\s*\)?\s*$/.exec(text);
342
+ if (ident === null || ident[1] === undefined) return [];
343
+ // One binding, or none: a name declared twice (shadowed, or re-declared in another scope) is
344
+ // ambiguous, and ambiguity reads as absence, so the check can never pass on the wrong binding.
345
+ const decls = [...src.matchAll(new RegExp(`(?:const|let|var)\\s+${ident[1]}\\s*(?::[^=]{1,200})?=\\s*`, "g"))];
346
+ const decl = decls.length === 1 ? decls[0] : undefined;
347
+ if (decl === undefined) return [];
348
+ return objectLiteralKeysAt(src, (decl.index ?? 0) + decl[0].length) ?? [];
349
+ }
350
+
252
351
  /**
253
352
  * Collect all key names that appear as state slots — either keys inside a
254
353
  * `useState({ ... })` object literal, or standalone state variables.
354
+ *
355
+ * ggui#1790 — also the initial-state shapes the inline-literal patterns cannot see, each
356
+ * idiomatic React: `useState(INITIAL)` with `const INITIAL = { … }` in the file, a lazy
357
+ * initializer `useState(() => ({ … }))`, and `useReducer(reducer, { … } | INITIAL)`. Missing
358
+ * them made `state.payload.covers_submit` report every payload key missing on a form that held
359
+ * them all, and the model, with nothing real to fix, spent the turn cap rewording a comment.
255
360
  */
256
361
  export function collectStateKeys(src: string): Set<string> {
257
362
  const keys = new Set<string>();
258
363
  const varRe = /const\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=\s*useState/g;
259
364
  for (const m of src.matchAll(varRe)) keys.add(m[1]);
365
+ // ggui#1791 — a contextSpec slot is state too: the boilerplate binds each slot as
366
+ // `const [x, setX] = useGguiContext<T>('x')` under a DO-NOT-EDIT banner, so a form
367
+ // whose fields are context slots holds every payload key there and cannot move them
368
+ // into useState. Both the binding's name and the slot's name count.
369
+ const contextRe = /const\s*\[\s*(\w+)\s*,\s*\w+\s*\]\s*=\s*useGguiContext\s*(?:<[^>]*>)?\s*\(\s*['"`](\w+)['"`]/g;
370
+ for (const m of src.matchAll(contextRe)) {
371
+ keys.add(m[1]);
372
+ keys.add(m[2]);
373
+ }
260
374
  const objRe = /useState(?:<[^>]*>)?\s*\(\s*\{([\s\S]*?)\}\s*\)/g;
261
375
  for (const m of src.matchAll(objRe)) {
262
376
  const keyRe = /(?:^|,)\s*(\w+)\s*:/g;
@@ -267,6 +381,17 @@ export function collectStateKeys(src: string): Set<string> {
267
381
  const keyRe = /(?:^|,)\s*(\w+)\s*:/g;
268
382
  for (const km of m[1].matchAll(keyRe)) keys.add(km[1]);
269
383
  }
384
+ // ggui#1790 — the initial-state argument, resolved: useState's first, useReducer's second.
385
+ for (const m of src.matchAll(/\b(useState|useReducer)\s*(?:<[^>]*>)?\s*\(/g)) {
386
+ const open = (m.index ?? 0) + m[0].length - 1;
387
+ const args = callArgsAt(src, open);
388
+ const which = m[1] === "useReducer" ? 1 : 0;
389
+ const arg = args[which];
390
+ if (arg === undefined) continue;
391
+ let argStart = open + 1;
392
+ for (let k = 0; k < which; k++) argStart += (args[k]?.length ?? 0) + 1;
393
+ for (const key of initialStateKeys(src, argStart, arg)) keys.add(key);
394
+ }
270
395
  return keys;
271
396
  }
272
397
 
@@ -218,9 +218,9 @@ function buildEvalResult(
218
218
 
219
219
  function getDefaultEvalModel(provider: string): string {
220
220
  switch (provider) {
221
- case 'claude': return 'claude-haiku-4-5-20251001';
221
+ case 'claude': return 'claude-haiku-5-5';
222
222
  case 'openai': return 'gpt-5.6-luna';
223
223
  case 'google': return 'gemini-3.5-flash-lite';
224
- default: return 'claude-haiku-4-5-20251001';
224
+ default: return 'claude-haiku-5-5';
225
225
  }
226
226
  }
@@ -568,13 +568,13 @@ Use 'fail' only for pervasive design system violations. Use 'warn' for isolated
568
568
  function getDefaultEvalModel(provider: string): string {
569
569
  switch (provider) {
570
570
  case 'claude':
571
- return 'claude-haiku-4-5-20251001';
571
+ return 'claude-haiku-5-5';
572
572
  case 'openai':
573
573
  return 'gpt-5.6-luna';
574
574
  case 'google':
575
575
  return 'gemini-3.5-flash-lite';
576
576
  default:
577
- return 'claude-haiku-4-5-20251001';
577
+ return 'claude-haiku-5-5';
578
578
  }
579
579
  }
580
580
 
@@ -2004,7 +2004,7 @@ function parseCriteriaAnswers(raw: unknown): { answers?: CriteriaAnswer[]; count
2004
2004
 
2005
2005
  function getDefaultVisualModel(provider: 'claude' | 'google'): string {
2006
2006
  switch (provider) {
2007
- case 'claude': return 'claude-haiku-4-5-20251001';
2007
+ case 'claude': return 'claude-haiku-5-5';
2008
2008
  case 'google': return 'gemini-3-flash-preview';
2009
2009
  }
2010
2010
  }
@@ -132,6 +132,7 @@ export async function assembleGenerationResult(
132
132
  : telemetry.evalResult,
133
133
  // ggui#404 — present only when the same-exchange guard ended the run.
134
134
  ...(telemetry.sameExchangeBreak !== undefined ? { sameExchangeBreak: telemetry.sameExchangeBreak } : {}),
135
+ ...(telemetry.sameFailureBreak !== undefined ? { sameFailureBreak: telemetry.sameFailureBreak } : {}),
135
136
  needsBackgroundImprovement,
136
137
  selfCheckPassed: telemetry.selfCheckPassed,
137
138
  timing,
@@ -102,6 +102,11 @@ export interface DupeBreakState {
102
102
  * information-free — the model learned nothing it didn't already
103
103
  * have. 1 = first occurrence; 2 = one repeat (nudge); 3 = break. */
104
104
  consecutiveIdenticalExchanges: number;
105
+ /** ggui#1791 — for each failure signature ({@link failureSignatures}), how
106
+ * many failed writes (`apply_changes` / `rewrite`) have reported it since
107
+ * the last write that passed, whatever the patch and whatever failed in
108
+ * between. Cleared when a write passes. */
109
+ failureCounts: Map<string, number>;
105
110
  }
106
111
 
107
112
  /**
@@ -147,6 +152,70 @@ export const SAME_EXCHANGE_NUDGE =
147
152
  "you already have and emit an apply_changes (or scoped) call now; one " +
148
153
  "more identical repeat terminates the run.";
149
154
 
155
+ /**
156
+ * ggui#1791 — the failures a failed write reported, one signature per failing
157
+ * check, with what changes between two identical failures removed: every
158
+ * digit (line and column numbers, counts) and runs of whitespace.
159
+ *
160
+ * A self-check result lists its violations as `[category] description` lines;
161
+ * each one is its own signature, so a check that fails on every turn reads as
162
+ * the same failure whether or not another check fails beside it. A result with
163
+ * no violation line (a build error, a rejected patch) is one signature: its
164
+ * text without the `Committed <oid>: "<message>"` line, which carries a fresh
165
+ * oid and the model's own message every turn.
166
+ *
167
+ * The patch fingerprint above keys on the PATCH, so the same failure under a
168
+ * different patch each turn (a reworded comment, a moved line) never reads as
169
+ * stuck, and a run goes to the turn cap. These key on the FAILURE.
170
+ */
171
+ export function failureSignatures(resultText: string): string[] {
172
+ const norm = (text: string): string => text.replace(/[0-9]+/g, "#").replace(/\s+/g, " ").trim();
173
+ const violations = [...resultText.matchAll(/^\[[^\]\n]+\] [^\n]+/gm)].map((m) => norm(m[0]));
174
+ if (violations.length > 0) return [...new Set(violations)];
175
+ const rest = norm(resultText.replace(/^Committed [^\n]*\n/, ""));
176
+ return rest.length > 0 ? [rest] : [];
177
+ }
178
+
179
+ /** The failure a write has reported most often since the last pass, and how often. */
180
+ export interface RepeatedFailure {
181
+ readonly signature: string;
182
+ readonly repeats: number;
183
+ }
184
+
185
+ /**
186
+ * ggui#1791 — record one failed write: count each of its signatures once, and
187
+ * return the one now reported most often since the last passing write
188
+ * (undefined when the result carried no recognisable failure).
189
+ */
190
+ export function recordWriteFailure(state: DupeBreakState, resultText: string): RepeatedFailure | undefined {
191
+ let top: RepeatedFailure | undefined;
192
+ for (const signature of failureSignatures(resultText)) {
193
+ const repeats = (state.failureCounts.get(signature) ?? 0) + 1;
194
+ state.failureCounts.set(signature, repeats);
195
+ if (top === undefined || repeats > top.repeats) top = { signature, repeats };
196
+ }
197
+ return top;
198
+ }
199
+
200
+ /** ggui#1791 — a write passed: every count starts over. */
201
+ export function clearWriteFailures(state: DupeBreakState): void {
202
+ state.failureCounts.clear();
203
+ }
204
+
205
+ /** A failure reported for the third time since the last pass ⇒ one explicit nudge. */
206
+ export const SAME_FAILURE_NUDGE_AT = 3;
207
+
208
+ /** A failure reported for the fifth time since the last pass ⇒ the loop is not
209
+ * converging; the caller ends the run, which ships what the turn cap would
210
+ * have shipped. */
211
+ export const SAME_FAILURE_BREAK_AT = 5;
212
+
213
+ /** Appended to the tool result of the {@link SAME_FAILURE_NUDGE_AT}th identical failure. */
214
+ export const SAME_FAILURE_NUDGE =
215
+ "\n\n[harness] This same failure has now been reported for three of your " +
216
+ "patches. Changing other code does not clear it: re-read that check above and " +
217
+ "change exactly what it names. Two more reports of it end the run.";
218
+
150
219
  export function createDupeBreakState(): DupeBreakState {
151
220
  return {
152
221
  lastFailedPatchFingerprint: null,
@@ -164,6 +233,7 @@ export function createDupeBreakState(): DupeBreakState {
164
233
  consecutiveNoToolCalls: 0,
165
234
  lastExchangeKey: null,
166
235
  consecutiveIdenticalExchanges: 0,
236
+ failureCounts: new Map(),
167
237
  };
168
238
  }
169
239
 
@@ -25,7 +25,7 @@ import {
25
25
  import { runEvalRound } from "./run-eval-round.js";
26
26
  import type { TokenUsage } from "./run-eval-round.js";
27
27
  import type { VisualLegTokens } from "../../evaluation/visual-evaluator.js";
28
- import type { SameExchangeBreak } from "../result-types.js";
28
+ import type { SameExchangeBreak, SameFailureBreak } from "../result-types.js";
29
29
 
30
30
  type PreWarmedEvalContext =
31
31
  import("../../evaluation/llm-evaluator.js").PreWarmedEvalContext;
@@ -105,6 +105,8 @@ export interface GenerateTelemetry {
105
105
  * undefined unless that guard is what stopped the loop.
106
106
  */
107
107
  sameExchangeBreak: SameExchangeBreak | undefined;
108
+ /** ggui#1791 — set when the same-failure guard ended the run. */
109
+ sameFailureBreak: SameFailureBreak | undefined;
108
110
  /** Latest compiled code — empty string until the first self-check pass. */
109
111
  compiledCode: string;
110
112
  /**
@@ -147,6 +149,7 @@ export function createTelemetry(): GenerateTelemetry {
147
149
  probeRepairTrigger: undefined,
148
150
  contractFeedback: undefined,
149
151
  sameExchangeBreak: undefined,
152
+ sameFailureBreak: undefined,
150
153
  compiledCode: "",
151
154
  pairedSource: "",
152
155
  selfCheckPassed: false,
@@ -223,6 +226,7 @@ export function absorbTurn(telemetry: GenerateTelemetry, turn: CodingTurnResult)
223
226
  else telemetry.counters.outcomes.selfCheckFail++;
224
227
  }
225
228
  if (turn.sameExchangeBreak !== undefined) telemetry.sameExchangeBreak = turn.sameExchangeBreak;
229
+ if (turn.sameFailureBreak !== undefined) telemetry.sameFailureBreak = turn.sameFailureBreak;
226
230
  }
227
231
 
228
232
  export interface CreateGenerateRunnerInput {
@@ -21,7 +21,7 @@ import { executeTool } from "../../coding-agent/tools.js";
21
21
  import type { CostTracker } from "../../evaluation/cost-tracker.js";
22
22
  import type { LLMAgent } from "../llm-router.js";
23
23
  import type { Harness } from "../types-public.js";
24
- import type { SameExchangeBreak } from "../result-types.js";
24
+ import type { SameExchangeBreak, SameFailureBreak } from "../result-types.js";
25
25
  import {
26
26
  APPLY_CHANGES_HASHLINE_TOOL,
27
27
  APPLY_CHANGES_HASHLINE_TOOL_FLAT,
@@ -38,10 +38,16 @@ import {
38
38
  import { LUCIDE_ICON_NAMES } from "@ggui-ai/design";
39
39
  import { formatWithHashlines } from "../hashline.js";
40
40
  import {
41
+ clearWriteFailures,
41
42
  recordToolExchange,
43
+ recordWriteFailure,
42
44
  SAME_EXCHANGE_BREAK_AT,
43
45
  SAME_EXCHANGE_NUDGE,
46
+ SAME_FAILURE_BREAK_AT,
47
+ SAME_FAILURE_NUDGE,
48
+ SAME_FAILURE_NUDGE_AT,
44
49
  } from "./dupe-break.js";
50
+ import type { RepeatedFailure } from "./dupe-break.js";
45
51
 
46
52
  /** Staged process-mode phase machine — also defined in runtime.ts. */
47
53
  export type A1Phase = "scaffold" | "fill" | "post";
@@ -254,6 +260,11 @@ export interface CodingTurnResult {
254
260
  * control value many exits share. No key on any other turn.
255
261
  */
256
262
  readonly sameExchangeBreak?: SameExchangeBreak;
263
+ /**
264
+ * Set only on the `break` the same-failure guard returns (ggui#1791). No
265
+ * key on any other turn.
266
+ */
267
+ readonly sameFailureBreak?: SameFailureBreak;
257
268
  }
258
269
 
259
270
  /**
@@ -952,6 +963,26 @@ ${closingInstruction}`;
952
963
  }
953
964
  }
954
965
 
966
+ // ggui#1791 same-failure guard — the patch fingerprint above keys on the
967
+ // PATCH, so the same failure under a different patch each turn (a reworded
968
+ // comment, a moved line) never counts as stuck. This keys on the FAILURE:
969
+ // each failing check a write reports is counted since the last passing
970
+ // write, whatever failed in between. The third report of one gets a nudge,
971
+ // the fifth ends the run. A passing write clears the counts; a non-write
972
+ // tool (cat, icons) changes nothing and leaves them as they were.
973
+ const isWrite = call.name === "apply_changes" || call.name === "rewrite";
974
+ let repeatedFailure: RepeatedFailure | undefined;
975
+ if (ctx.dupeBreak && isWrite) {
976
+ if (result.done) {
977
+ clearWriteFailures(ctx.dupeBreak);
978
+ } else {
979
+ repeatedFailure = recordWriteFailure(ctx.dupeBreak, result.result);
980
+ if (repeatedFailure?.repeats === SAME_FAILURE_NUDGE_AT) {
981
+ augmentedResultText += SAME_FAILURE_NUDGE;
982
+ }
983
+ }
984
+ }
985
+
955
986
  // Send tool result back to close the API contract (Google/OpenAI/OpenRouter)
956
987
  // This enables proper session chaining on the next callTools() invocation.
957
988
  await codingAgent.sendToolResult([{
@@ -984,6 +1015,30 @@ ${closingInstruction}`;
984
1015
  };
985
1016
  }
986
1017
 
1018
+ if (repeatedFailure !== undefined && repeatedFailure.repeats >= SAME_FAILURE_BREAK_AT) {
1019
+ const signature = repeatedFailure.signature.slice(0, 160);
1020
+ console.log(
1021
+ `[coding-agent] SAME_FAILURE_BREAK | tool=${call.name} | repeats=${repeatedFailure.repeats} | failure=${JSON.stringify(signature.slice(0, 80))} — terminating run (ggui#1791)`,
1022
+ );
1023
+ return {
1024
+ control: "break",
1025
+ compiledCode: "",
1026
+ selfCheckPassed: false,
1027
+ outcome: undefined,
1028
+ phase: undefined,
1029
+ tokens,
1030
+ ...cacheTokens,
1031
+ llmMs,
1032
+ toolMs,
1033
+ lastResultText: augmentedResultText,
1034
+ lastDiffFailed: false,
1035
+ isEvalFeedback: false,
1036
+ iconNamesCache,
1037
+ preWarmedContext,
1038
+ sameFailureBreak: { signature, repeats: repeatedFailure.repeats },
1039
+ };
1040
+ }
1041
+
987
1042
  // Track whether the diff itself failed to apply (vs applied but had violations)
988
1043
  const turnDiffFailed = !!result.error && result.result.includes("DIFF");
989
1044
 
@@ -62,6 +62,16 @@ export interface SameExchangeBreak {
62
62
  readonly repeats: number;
63
63
  }
64
64
 
65
+ /**
66
+ * ggui#1791 — the same-failure guard ended the run: one failing check
67
+ * (`signature`, its first 160 characters) was reported by this many failed
68
+ * writes since the last passing one, whatever the patch.
69
+ */
70
+ export interface SameFailureBreak {
71
+ readonly signature: string;
72
+ readonly repeats: number;
73
+ }
74
+
65
75
  export interface GenerationResult extends AdapterResult {
66
76
  /** Number of generation passes the harness performed */
67
77
  passesUsed: number;
@@ -77,6 +87,8 @@ export interface GenerationResult extends AdapterResult {
77
87
  inLoopVisualTokens?: VisualLegTokens;
78
88
  /** Set only when the same-exchange guard ended the run (ggui#404); no key otherwise. */
79
89
  sameExchangeBreak?: SameExchangeBreak;
90
+ /** ggui#1791 — set only when the same-failure guard ended the run. */
91
+ sameFailureBreak?: SameFailureBreak;
80
92
  /** Whether background improvement should be spawned (auto-improve mode) */
81
93
  needsBackgroundImprovement?: boolean;
82
94
  /** Whether at least one commit passed the self-check (compile + type check + lint) */
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export {
22
22
  export type { CreateUiGeneratorOptions } from './create-ui-generator.js';
23
23
  export type { RuntimeRenderProbeConfig } from './harness/check/runtime-render/adapter.js';
24
24
  export type { ProviderRetryInfo } from './harness/llm-router.js';
25
- export type { GenerationResult, SameExchangeBreak } from './harness/result-types.js';
25
+ export type { GenerationResult, SameExchangeBreak, SameFailureBreak } from './harness/result-types.js';
26
26
  // The build identity a stamp outside this package can name: the same
27
27
  // digests `createUiGenerator` puts on every result's `metadata.build`, for
28
28
  // either design mode — so a harness definition can carry both modes' digests