@ggui-ai/ui-gen 0.25.0 → 0.26.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.
Files changed (106) hide show
  1. package/README.md +2 -2
  2. package/dist/{adapter-Cg_rkpGq.d.ts → adapter-BJwn74rx.d.ts} +49 -1
  3. package/dist/adapters/generation-dispatch.d.ts +8 -5
  4. package/dist/adapters/generation-dispatch.js +1118 -193
  5. package/dist/adapters/generation-dispatch.js.map +1 -1
  6. package/dist/adapters/index.js +27 -6
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/advanced/index.d.ts +10 -9
  9. package/dist/advanced/index.js +1126 -202
  10. package/dist/advanced/index.js.map +1 -1
  11. package/dist/blueprint-validator.js +416 -92
  12. package/dist/blueprint-validator.js.map +1 -1
  13. package/dist/boilerplate.d.ts +2 -2
  14. package/dist/boilerplate.js +10 -3
  15. package/dist/boilerplate.js.map +1 -1
  16. package/dist/check/index.d.ts +3 -2
  17. package/dist/check/index.js +9 -2
  18. package/dist/check/index.js.map +1 -1
  19. package/dist/{create-ui-generator-DFnnArrv.d.ts → create-ui-generator-N4gRXpcL.d.ts} +3 -4
  20. package/dist/{design-mode-DLufhrNp.d.ts → design-mode-DGWN_Gkm.d.ts} +1 -1
  21. package/dist/{dispatch-CStl9eAw.d.ts → dispatch-BUoXRj_r.d.ts} +2 -2
  22. package/dist/evaluation/axis-checks/index.d.ts +5 -4
  23. package/dist/evaluation/axis-checks/index.js.map +1 -1
  24. package/dist/evaluation/axis-checks/registry.d.ts +4 -3
  25. package/dist/evaluation/index.d.ts +107 -539
  26. package/dist/evaluation/index.js +552 -55
  27. package/dist/evaluation/index.js.map +1 -1
  28. package/dist/evaluation/types.d.ts +17 -0
  29. package/dist/evaluation/types.js.map +1 -1
  30. package/dist/{generate-9IeNT4ZY.d.ts → generate-tSO_shQ0.d.ts} +1 -1
  31. package/dist/harness/check/runtime-render/index.d.ts +5 -5
  32. package/dist/harness/check/runtime-render/index.js +416 -92
  33. package/dist/harness/check/runtime-render/index.js.map +1 -1
  34. package/dist/harness/check/runtime-render/render-check-worker.js +393 -88
  35. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  36. package/dist/harness/index.d.ts +4 -5
  37. package/dist/harness/index.js +36 -5
  38. package/dist/harness/index.js.map +1 -1
  39. package/dist/harness/prompts.d.ts +2 -2
  40. package/dist/harness/prompts.js +10 -1
  41. package/dist/harness/prompts.js.map +1 -1
  42. package/dist/harness/result-types.d.ts +17 -3
  43. package/dist/harness/result-types.js.map +1 -1
  44. package/dist/harness/runtime.d.ts +14 -6
  45. package/dist/harness/runtime.js +11 -4
  46. package/dist/harness/runtime.js.map +1 -1
  47. package/dist/harness/types-public.d.ts +4 -2
  48. package/dist/harness/types-public.js +10 -3
  49. package/dist/harness/types-public.js.map +1 -1
  50. package/dist/harness/validator-trace-sink.d.ts +4 -3
  51. package/dist/index.d.ts +46 -11
  52. package/dist/index.js +1128 -200
  53. package/dist/index.js.map +1 -1
  54. package/dist/patch.d.ts +3 -3
  55. package/dist/{prompts-YU9piOgs.d.ts → prompts-BhNUuRoZ.d.ts} +1 -1
  56. package/dist/tools/render-check-worker.js +8 -1
  57. package/dist/tools/render-check-worker.js.map +1 -1
  58. package/dist/{types-public-BJTLpzWn.d.ts → types-public-B7wn4ce5.d.ts} +384 -3
  59. package/dist/validation/index.d.ts +1 -1
  60. package/dist/validation/index.js +1 -1
  61. package/dist/validation/index.js.map +1 -1
  62. package/dist/visual-evaluator-C9L-o6ap.d.ts +639 -0
  63. package/dist/workflows.d.ts +3 -3
  64. package/package.json +12 -12
  65. package/src/adapters/generation-dispatch.ts +2 -0
  66. package/src/boilerplate/free-design-prompt.ts +3 -3
  67. package/src/boilerplate/hard-sections.ts +1 -1
  68. package/src/boilerplate/templates/layouts/chat-mobile.tsx.tmpl +3 -1
  69. package/src/boilerplate/templates/layouts/chat-universal.tsx.tmpl +3 -1
  70. package/src/boilerplate/templates/layouts/fullscreen-desktop.tsx.tmpl +3 -1
  71. package/src/boilerplate/templates/layouts/fullscreen-universal.tsx.tmpl +3 -1
  72. package/src/check/run-tier0.ts +6 -3
  73. package/src/contract-context.ts +1 -1
  74. package/src/evaluation/cost-tracker.ts +54 -6
  75. package/src/evaluation/criteria/edge-probe.ts +286 -0
  76. package/src/evaluation/criteria/edge.ts +202 -0
  77. package/src/evaluation/criteria/resolve.ts +55 -7
  78. package/src/evaluation/index.ts +5 -1
  79. package/src/evaluation/judge-spend.ts +48 -0
  80. package/src/evaluation/types-public.ts +91 -0
  81. package/src/evaluation/types.ts +10 -0
  82. package/src/evaluation/visual-evaluator.ts +236 -40
  83. package/src/harness/check/runtime-render/adapter.ts +18 -0
  84. package/src/harness/check/runtime-render/probe-pending.ts +111 -0
  85. package/src/harness/check/runtime-render/render-check-host.ts +10 -6
  86. package/src/harness/check/runtime-render/render-check-worker.ts +6 -3
  87. package/src/harness/check/runtime-render/render-check.ts +417 -133
  88. package/src/harness/check/runtime-render/walk.ts +200 -0
  89. package/src/harness/coding/assemble-result.ts +2 -0
  90. package/src/harness/coding/generate-task-runner.ts +27 -0
  91. package/src/harness/coding/init-session.ts +5 -3
  92. package/src/harness/coding/run-coding-turn.ts +5 -1
  93. package/src/harness/coding/run-eval-round.ts +63 -3
  94. package/src/harness/in-loop-evaluation.ts +39 -0
  95. package/src/harness/llm-router.ts +58 -1
  96. package/src/harness/result-types.ts +9 -0
  97. package/src/harness/runtime.ts +9 -0
  98. package/src/harness/types-public.ts +10 -1
  99. package/src/index.ts +5 -0
  100. package/src/internal/verdict-line.ts +33 -0
  101. package/src/taught-tokens.ts +32 -0
  102. package/src/tools/render-check-worker.ts +2 -1
  103. package/src/tools/render-check.ts +8 -7
  104. package/src/validation/primitives.ts +1 -1
  105. package/dist/llm-router-nfO7wPec.d.ts +0 -267
  106. package/dist/{bank-CT4eEkta.d.ts → bank-Ctdp3FUA.d.ts} +3 -3
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The action walk (ggui#1652): find a control that dispatches one action by
3
+ * walking the card's screens, as a person would, in a mount of its own.
4
+ *
5
+ * A screen is the card's set of controls, told apart by each control's tag,
6
+ * type, label (its aria-label, placeholder or text) and disabled state, never
7
+ * by typed values, selection or generated names.
8
+ * Picking an answer is the same screen unless it enables or disables a
9
+ * control; a wizard's next step, or an Edit button that now reads Save, is a
10
+ * new one. Each control is pressed once per screen. When every control on
11
+ * the current screen has been pressed, the walk returns over controls it has
12
+ * already pressed to the nearest screen that still has one to press. A press
13
+ * that leads back to a screen already seen is not progress, so Back and Next
14
+ * cannot loop, in any language. The walk stops at the first press that
15
+ * dispatches the action, when every reachable control has been pressed, at
16
+ * a cap on presses, or at its deadline. Neither the cap nor the deadline
17
+ * stops it while it is on the first screen with a control there still
18
+ * unpressed; once a press leaves the first screen both apply, and a
19
+ * first-screen control left unpressed is pressed only if a known route
20
+ * leads back to it (a one-way wizard has none).
21
+ */
22
+ import type { MinimalElement } from "./host-boundary.js";
23
+
24
+ /** One control the walk can press, and how. */
25
+ export interface WalkControl {
26
+ readonly element: MinimalElement;
27
+ /** Whether the control names the action (its label, aria-label or `data-action`). */
28
+ readonly namesAction: boolean;
29
+ }
30
+
31
+ /** A fresh copy of the card, and how to fill its screen's empty form controls. */
32
+ export interface WalkMount {
33
+ readonly container: MinimalElement;
34
+ /** Fill the current screen's empty form controls; filled ones are left as they are. */
35
+ readonly prime: () => void;
36
+ }
37
+
38
+ export interface WalkInput<C extends WalkControl> {
39
+ /** Mount a fresh copy of the card; undefined when it cannot be mounted. */
40
+ readonly mount: () => Promise<WalkMount | undefined>;
41
+ /** The screen's controls, in the order they are tried. */
42
+ readonly controls: (container: MinimalElement) => readonly C[];
43
+ /** Press one control; true when the press dispatched the action. */
44
+ readonly press: (control: C) => Promise<boolean>;
45
+ readonly describe: (el: MinimalElement) => string;
46
+ /** Epoch ms after which no further press is made. */
47
+ readonly stopAt: number;
48
+ readonly maxPresses: number;
49
+ }
50
+
51
+ export type WalkStop = "dispatched" | "explored" | "press-cap" | "deadline" | "no-mount";
52
+
53
+ export interface WalkOutcome {
54
+ readonly fired: boolean;
55
+ /** The control whose press dispatched the action, and the mount it is in. */
56
+ readonly element?: MinimalElement;
57
+ readonly container?: MinimalElement;
58
+ readonly presses: number;
59
+ readonly screens: number;
60
+ readonly stoppedBy: WalkStop;
61
+ /** Whether any screen rendered a control that names the action. */
62
+ readonly namedControlSeen: boolean;
63
+ /** The last few controls pressed, oldest first. */
64
+ readonly lastPressed: readonly string[];
65
+ /** The text of every screen the walk reached, once each, in the order reached. */
66
+ readonly screenTexts: readonly string[];
67
+ }
68
+
69
+ /** How many of the last presses an outcome keeps. */
70
+ const LAST_PRESSED_KEPT = 4;
71
+
72
+ function isDisabled(el: MinimalElement): boolean {
73
+ return el.getAttribute("disabled") !== null || el.getAttribute("aria-disabled") === "true";
74
+ }
75
+
76
+ /**
77
+ * A control's label for the fingerprint. Never its `name` or `id`: React
78
+ * generates those (`useId`) afresh when a step re-mounts, so a screen keyed
79
+ * on them would look new on every return and the walk would loop on it.
80
+ */
81
+ function labelOf(el: MinimalElement): string {
82
+ const named = el.getAttribute("aria-label") ?? el.getAttribute("placeholder");
83
+ return (named ?? el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
84
+ }
85
+
86
+ /** The screen's identity: its controls' structure and disabled state, in document order. */
87
+ export function screenFingerprint(container: MinimalElement): string {
88
+ const parts: string[] = [];
89
+ for (const el of container.querySelectorAll(
90
+ 'button, [role="button"], a[href], input, select, textarea, form'
91
+ )) {
92
+ parts.push(
93
+ [el.tagName.toLowerCase(), el.getAttribute("type") ?? "", labelOf(el), isDisabled(el) ? "disabled" : ""].join("|")
94
+ );
95
+ }
96
+ return parts.join("\n");
97
+ }
98
+
99
+ interface Screen {
100
+ /** Each control's key, in try order. */
101
+ readonly order: readonly string[];
102
+ /** A pressed control's key → the screen its press led to. */
103
+ readonly led: Map<string, string>;
104
+ }
105
+
106
+ /** Each control's key on a screen: its description, numbered among controls described alike. */
107
+ function keyed<C extends WalkControl>(controls: readonly C[], describe: (el: MinimalElement) => string): Map<string, C> {
108
+ const seen = new Map<string, number>();
109
+ const out = new Map<string, C>();
110
+ for (const c of controls) {
111
+ const d = describe(c.element);
112
+ const n = seen.get(d) ?? 0;
113
+ seen.set(d, n + 1);
114
+ out.set(`${d}#${n}`, c);
115
+ }
116
+ return out;
117
+ }
118
+
119
+ /** The first press of the shortest known route to a screen with a control not yet pressed. */
120
+ function firstStepToUnpressed(screens: ReadonlyMap<string, Screen>, from: string): string | undefined {
121
+ const firstStep = new Map<string, string | undefined>([[from, undefined]]);
122
+ const queue = [from];
123
+ while (queue.length > 0) {
124
+ const at = queue.shift()!;
125
+ const screen = screens.get(at);
126
+ if (screen === undefined) continue;
127
+ if (at !== from && screen.order.some((k) => !screen.led.has(k))) return firstStep.get(at);
128
+ for (const [key, to] of screen.led) {
129
+ if (to === at || firstStep.has(to)) continue;
130
+ firstStep.set(to, firstStep.get(at) ?? key);
131
+ queue.push(to);
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ export async function walkToAction<C extends WalkControl>(input: WalkInput<C>): Promise<WalkOutcome> {
138
+ const lastPressed: string[] = [];
139
+ const screenTexts: string[] = [];
140
+ const screens = new Map<string, Screen>();
141
+ let presses = 0;
142
+ let namedControlSeen = false;
143
+ const outcome = (stoppedBy: WalkStop, extra: Partial<WalkOutcome> = {}): WalkOutcome => ({
144
+ fired: false,
145
+ presses,
146
+ screens: screens.size,
147
+ stoppedBy,
148
+ namedControlSeen,
149
+ lastPressed: [...lastPressed],
150
+ screenTexts: [...screenTexts],
151
+ ...extra,
152
+ });
153
+
154
+ const mounted = await input.mount();
155
+ if (mounted === undefined) return outcome("no-mount");
156
+ const { container } = mounted;
157
+
158
+ const arrive = (): { id: string; controls: Map<string, C> } => {
159
+ mounted.prime();
160
+ const controls = keyed(input.controls(container), input.describe);
161
+ if ([...controls.values()].some((c) => c.namesAction)) namedControlSeen = true;
162
+ const id = screenFingerprint(container);
163
+ if (!screens.has(id)) {
164
+ screens.set(id, { order: [...controls.keys()], led: new Map() });
165
+ screenTexts.push(container.textContent ?? "");
166
+ }
167
+ return { id, controls };
168
+ };
169
+
170
+ let here = arrive();
171
+ const first = here.id;
172
+ for (;;) {
173
+ const screen = screens.get(here.id)!;
174
+ const onOpenFirstScreen = here.id === first && screen.order.some((k) => !screen.led.has(k));
175
+ if (!onOpenFirstScreen) {
176
+ if (presses >= input.maxPresses) return outcome("press-cap");
177
+ if (Date.now() >= input.stopAt) return outcome("deadline");
178
+ }
179
+ const key = screen.order.find((k) => !screen.led.has(k)) ?? firstStepToUnpressed(screens, here.id);
180
+ if (key === undefined) return outcome("explored");
181
+ const control = here.controls.get(key);
182
+ if (control === undefined) {
183
+ // Listed when the screen was first seen, gone now: nothing to press.
184
+ // It now leads nowhere, which a route never takes, so this cannot
185
+ // pick the same control again.
186
+ screen.led.set(key, here.id);
187
+ continue;
188
+ }
189
+ presses += 1;
190
+ lastPressed.push(input.describe(control.element));
191
+ if (lastPressed.length > LAST_PRESSED_KEPT) lastPressed.shift();
192
+ if (await input.press(control)) {
193
+ return outcome("dispatched", { fired: true, element: control.element, container });
194
+ }
195
+ here = arrive();
196
+ // The first press records where a control leads; a route that later
197
+ // leads elsewhere is simply followed on from where it led.
198
+ if (!screen.led.has(key)) screen.led.set(key, here.id);
199
+ }
200
+ }
@@ -119,6 +119,8 @@ export async function assembleGenerationResult(
119
119
  ...(telemetry.cacheCreationTokens !== undefined
120
120
  ? { cacheCreationTokens: telemetry.cacheCreationTokens }
121
121
  : {}),
122
+ // ggui#1522 — the in-loop visual judge's spend, beside `tokens` and never inside it.
123
+ ...(telemetry.inLoopVisualTokens !== undefined ? { inLoopVisualTokens: telemetry.inLoopVisualTokens } : {}),
122
124
  generationTimeMs: totalMs,
123
125
  turnsUsed: telemetry.turnsUsed,
124
126
  passesUsed: 1,
@@ -24,6 +24,7 @@ import {
24
24
  } from "./run-coding-turn.js";
25
25
  import { runEvalRound } from "./run-eval-round.js";
26
26
  import type { TokenUsage } from "./run-eval-round.js";
27
+ import type { VisualLegTokens } from "../../evaluation/visual-evaluator.js";
27
28
  import type { SameExchangeBreak } from "../result-types.js";
28
29
 
29
30
  type PreWarmedEvalContext =
@@ -75,6 +76,12 @@ export interface GenerateTelemetry {
75
76
  */
76
77
  cacheReadTokens?: number;
77
78
  cacheCreationTokens?: number;
79
+ /**
80
+ * ggui#1522 — the in-loop visual judge's spend across eval rounds, apart from {@link totalIn} / {@link totalOut}
81
+ * (priced at the coding and text-evaluator models): this part is priced at the VISUAL agent's model. Stays
82
+ * `undefined` while no round reported one.
83
+ */
84
+ inLoopVisualTokens?: VisualLegTokens;
78
85
  /** Latest evalResult — undefined when eval was disabled or never ran. */
79
86
  evalResult: EvalResult | undefined;
80
87
  /**
@@ -167,6 +174,25 @@ export function absorbTokens(telemetry: GenerateTelemetry, usage: TokenUsage): v
167
174
  }
168
175
  }
169
176
 
177
+ /** ggui#1522 — fold one round's in-loop visual spend into the run's own part (never into `totalIn` / `totalOut`). */
178
+ export function absorbInLoopVisualTokens(telemetry: GenerateTelemetry, part: VisualLegTokens | undefined): void {
179
+ if (part === undefined) return;
180
+ const prev = telemetry.inLoopVisualTokens;
181
+ const prevCriteria = prev?.criteria;
182
+ const criteria =
183
+ part.criteria === undefined
184
+ ? prevCriteria
185
+ : {
186
+ inputTokens: (prevCriteria?.inputTokens ?? 0) + part.criteria.inputTokens,
187
+ outputTokens: (prevCriteria?.outputTokens ?? 0) + part.criteria.outputTokens,
188
+ };
189
+ telemetry.inLoopVisualTokens = {
190
+ inputTokens: (prev?.inputTokens ?? 0) + part.inputTokens,
191
+ outputTokens: (prev?.outputTokens ?? 0) + part.outputTokens,
192
+ ...(criteria !== undefined ? { criteria } : {}),
193
+ };
194
+ }
195
+
170
196
  /**
171
197
  * Fold one coding turn's measurements into the run's telemetry — tokens (via
172
198
  * {@link absorbTokens}), timings, the phase/outcome counters, and the
@@ -413,6 +439,7 @@ export function createGenerateTaskRunner(input: CreateGenerateRunnerInput): Task
413
439
  if (round.contractFeedback) telemetry.contractFeedback = round.contractFeedback;
414
440
  evalDone = round.evalDone;
415
441
  absorbTokens(telemetry, round.evalTokens);
442
+ absorbInLoopVisualTokens(telemetry, round.inLoopVisualTokens);
416
443
 
417
444
  if (round.control === "break") break;
418
445
  // round.control === "feedback" — set next coding turn input.
@@ -19,6 +19,7 @@ import type { AgentSpec, SingleComponentParams } from "../runtime.js";
19
19
  import type { ResolvedRunPolicy } from "../policy.js";
20
20
  import { DEFAULT_HARNESS_POLICY } from "../policy.js";
21
21
  import { createDupeBreakState, type DupeBreakState } from "./dupe-break.js";
22
+ import { inLoopEvaluation } from "../in-loop-evaluation.js";
22
23
 
23
24
  type PreWarmedEvalContext =
24
25
  import("../../evaluation/llm-evaluator.js").PreWarmedEvalContext;
@@ -165,10 +166,13 @@ export async function initSession(input: {
165
166
  "required",
166
167
  );
167
168
 
169
+ // ggui#1513 — the one rule for which evaluation legs run; a host reads the same export.
170
+ const { text: codeEvalEnabled, visual: visualEvalEnabled } = inLoopEvaluation(params);
171
+
168
172
  // ── Pre-warm eval (overlaps with coding turn 1) ──
169
173
  // Uses a separate agent instance so it doesn't disturb the coding cache.
170
174
  let preWarmPromise: Promise<PreWarmedEvalContext | null> | undefined;
171
- if (params.evaluation?.enabled) {
175
+ if (codeEvalEnabled) {
172
176
  const evalSpec = agents.evaluation;
173
177
  preWarmPromise = import("../../evaluation/llm-evaluator.js")
174
178
  .then((mod) =>
@@ -202,8 +206,6 @@ export async function initSession(input: {
202
206
  // ── Eval gate + lazy-loaded modules ──
203
207
  const qualityConfig = params.qualityConfig;
204
208
  const qualityMode = qualityConfig?.quality ?? "fast";
205
- const codeEvalEnabled = !!params.evaluation?.enabled;
206
- const visualEvalEnabled = !!(params.visualEvaluation?.enabled || qualityConfig?.visualEval);
207
209
  const probeOnlyEnabled =
208
210
  harness.check.runtimeRender !== undefined && !codeEvalEnabled && !visualEvalEnabled;
209
211
  const maxEvalRounds =
@@ -544,7 +544,11 @@ ${closingInstruction}`;
544
544
  );
545
545
  const llmMs = Date.now() - llmStart;
546
546
  if (costTracker) {
547
- costTracker.record(codingModel, response.inputTokens, response.outputTokens);
547
+ // ggui#1524 — the cache counts reach the cap: a cached prefix is billed, so it is spend.
548
+ costTracker.record(codingModel, response.inputTokens, response.outputTokens, {
549
+ ...(response.cacheReadTokens !== undefined ? { read: response.cacheReadTokens } : {}),
550
+ ...(response.cacheCreationTokens !== undefined ? { write: response.cacheCreationTokens } : {}),
551
+ });
548
552
  }
549
553
 
550
554
  const tokens = { input: response.inputTokens, output: response.outputTokens };
@@ -52,6 +52,7 @@ import type {
52
52
  VisualEvalOutcome,
53
53
  VisualFitConfig,
54
54
  VisualFitOutcome,
55
+ VisualLegTokens,
55
56
  } from "../../evaluation/visual-evaluator.js";
56
57
 
57
58
  type PreWarmedEvalContext =
@@ -181,6 +182,12 @@ export interface EvalRoundResult {
181
182
  readonly preWarmedContext: PreWarmedEvalContext | null | undefined;
182
183
  /** Tokens added during this round — caller adds to its own totals. */
183
184
  readonly evalTokens: TokenUsage;
185
+ /**
186
+ * ggui#1522 — the in-loop visual judge's spend this round, apart from {@link evalTokens} (the text evaluator's,
187
+ * priced at its model): already recorded in the cost tracker at the visual agent's model. Absent when no visual
188
+ * leg ran or it reported no tokens.
189
+ */
190
+ readonly inLoopVisualTokens?: VisualLegTokens;
184
191
  /** Wall-clock of the parallel LLM + visual eval block (zero on the
185
192
  * low-risk bypass path, which exits before `Promise.all`). Caller
186
193
  * accumulates into its `cumulativeEvalLlmMs`. */
@@ -417,6 +424,7 @@ function probeMetaOf(outcome: RuntimeRenderOutcome, probeIssues: readonly EvalIs
417
424
  ...(outcome.renderMs !== undefined ? { renderMs: outcome.renderMs } : {}),
418
425
  ...(outcome.hostLoad !== undefined ? { hostLoad: outcome.hostLoad } : {}),
419
426
  ...(outcome.queuedMs !== undefined ? { queuedMs: outcome.queuedMs } : {}),
427
+ ...(outcome.pendingAffordance !== undefined ? { pendingAffordance: outcome.pendingAffordance } : {}),
420
428
  };
421
429
  const elapsed = outcome.elapsedMs !== undefined ? { elapsedMs: outcome.elapsedMs } : {};
422
430
  if (outcome.status !== "ran") {
@@ -591,6 +599,7 @@ export async function runEvalRound(
591
599
 
592
600
  let evalResult: EvalResult | undefined;
593
601
  let evalTokens: TokenUsage = { input: 0, output: 0 };
602
+ let inLoopVisualTokens: VisualLegTokens | undefined = undefined;
594
603
  let evalLlmMs = 0;
595
604
 
596
605
  try {
@@ -641,6 +650,7 @@ export async function runEvalRound(
641
650
  prevFailFingerprints,
642
651
  preWarmedContext,
643
652
  evalTokens,
653
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
644
654
  evalLlmMs,
645
655
  lastResultText: "",
646
656
  isEvalFeedback: false,
@@ -665,6 +675,7 @@ export async function runEvalRound(
665
675
  prevFailFingerprints,
666
676
  preWarmedContext,
667
677
  evalTokens,
678
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
668
679
  evalLlmMs,
669
680
  lastResultText: lines.join("\n\n"),
670
681
  isEvalFeedback: true,
@@ -683,6 +694,7 @@ export async function runEvalRound(
683
694
  prevFailFingerprints,
684
695
  preWarmedContext,
685
696
  evalTokens,
697
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
686
698
  evalLlmMs,
687
699
  lastResultText: "",
688
700
  isEvalFeedback: false,
@@ -772,6 +784,7 @@ export async function runEvalRound(
772
784
  prevFailFingerprints,
773
785
  preWarmedContext,
774
786
  evalTokens,
787
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
775
788
  evalLlmMs,
776
789
  lastResultText: lines.join("\n\n"),
777
790
  isEvalFeedback: true,
@@ -797,6 +810,7 @@ export async function runEvalRound(
797
810
  prevFailFingerprints,
798
811
  preWarmedContext,
799
812
  evalTokens,
813
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
800
814
  evalLlmMs,
801
815
  lastResultText: "",
802
816
  isEvalFeedback: false,
@@ -917,6 +931,18 @@ export async function runEvalRound(
917
931
  evalLlmMs = Date.now() - evalLlmStart;
918
932
  llmResult = llm;
919
933
  visualIssues = visual?.issues ?? null;
934
+ // ggui#1522 — the in-loop visual judge's own spend: recorded in the tracker at the VISUAL agent's model, so
935
+ // `canContinue()` sees it, and carried apart from `evalTokens` (the text evaluator's model).
936
+ // The report-only criteria calls (ggui#1436) count toward the cap too, deliberately: the cap bounds MONEY SPENT,
937
+ // and a cap that left out real spend would under-count exactly as this leg did before. So under a tight cap a
938
+ // configured criteria bank's calls can end the eval loop earlier — the one place a report-only block reaches an
939
+ // outcome, and it does so through its cost, never through its answers. The tracker's total feeds no metered figure.
940
+ inLoopVisualTokens = visual?.tokens;
941
+ if (inLoopVisualTokens !== undefined) {
942
+ costTracker.record(visualEvalAgent.model, inLoopVisualTokens.inputTokens, inLoopVisualTokens.outputTokens);
943
+ const criteriaSpend = inLoopVisualTokens.criteria;
944
+ if (criteriaSpend !== undefined) costTracker.record(visualEvalAgent.model, criteriaSpend.inputTokens, criteriaSpend.outputTokens);
945
+ }
920
946
  const visualSummary: VisualEvalSummary | undefined = visual?.summary;
921
947
  // ggui#1221 — the judge leg's coverage is ALWAYS stamped: ran / skipped (with the
922
948
  // reason) / not-applicable when no visual leg was configured for this round.
@@ -927,7 +953,11 @@ export async function runEvalRound(
927
953
 
928
954
  // Track costs from LLM eval calls
929
955
  if (llmResult) {
930
- costTracker.record(evaluationAgent.model, llmResult.inputTokens, llmResult.outputTokens);
956
+ // ggui#1524 — the text evaluator's cache counts reach the cap too.
957
+ costTracker.record(evaluationAgent.model, llmResult.inputTokens, llmResult.outputTokens, {
958
+ ...(llmResult.cacheReadTokens !== undefined ? { read: llmResult.cacheReadTokens } : {}),
959
+ ...(llmResult.cacheCreationTokens !== undefined ? { write: llmResult.cacheCreationTokens } : {}),
960
+ });
931
961
  evalTokens = {
932
962
  input: llmResult.inputTokens,
933
963
  output: llmResult.outputTokens,
@@ -996,12 +1026,22 @@ export async function runEvalRound(
996
1026
  );
997
1027
 
998
1028
  // ── Decide whether to continue the loop ──
999
- // In fast mode: only fails trigger another iteration.
1029
+ // In fast mode: only fails trigger another iteration, plus (ggui#1542) the visual JUDGE's major findings when
1030
+ // `visualEvaluation.actOn` is 'major'. They are taken from the visual outcome itself, never by category over the
1031
+ // merged list, because the text evaluator has a tier-2 `visual` criterion of its own. Only findings whose
1032
+ // `origin` is `judge` act (ggui#1545): the instruments' warn-level majors (fit's `canvas-overflow*` today) are
1033
+ // deterministic and run with or without a judge, so acting on them would switch a second feedback source on
1034
+ // with the judge's. Selecting by origin rather than excluding by name fails closed: a new instrument check, or
1035
+ // any issue with no origin, never joins the treatment unless it is marked a judge finding.
1000
1036
  // In auto-improve / high-quality: fails + warns trigger iterations.
1001
1037
  const blocking = evalResult.issues.filter((i) => i.result === "fail");
1038
+ const visualMajorsActed =
1039
+ visualEvaluation?.actOn === "major"
1040
+ ? (visualIssues ?? []).filter((i) => i.origin === "judge" && i.result === "warn" && i.severity === "major")
1041
+ : [];
1002
1042
  const shouldContinue =
1003
1043
  qualityMode === "fast"
1004
- ? blocking.length > 0
1044
+ ? blocking.length > 0 || visualMajorsActed.length > 0
1005
1045
  : blocking.length > 0 || warns.length > 0;
1006
1046
 
1007
1047
  // Compute current round's fail fingerprints — used by the stuck-loop
@@ -1046,6 +1086,7 @@ export async function runEvalRound(
1046
1086
  prevFailFingerprints: enrichedFingerprints,
1047
1087
  preWarmedContext,
1048
1088
  evalTokens,
1089
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1049
1090
  evalLlmMs,
1050
1091
  lastResultText: lines.join("\n\n"),
1051
1092
  isEvalFeedback: true,
@@ -1079,6 +1120,7 @@ export async function runEvalRound(
1079
1120
  prevFailFingerprints: new Set([...currFailFingerprints, ...contractFails.map(fingerprintFail)]),
1080
1121
  preWarmedContext,
1081
1122
  evalTokens,
1123
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1082
1124
  evalLlmMs,
1083
1125
  lastResultText: lines.join("\n\n"),
1084
1126
  isEvalFeedback: true,
@@ -1105,6 +1147,7 @@ export async function runEvalRound(
1105
1147
  prevFailFingerprints: currFailFingerprints,
1106
1148
  preWarmedContext,
1107
1149
  evalTokens,
1150
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1108
1151
  evalLlmMs,
1109
1152
  lastResultText: "",
1110
1153
  isEvalFeedback: false,
@@ -1186,6 +1229,7 @@ export async function runEvalRound(
1186
1229
  prevFailFingerprints: new Set([...currFailFingerprints, ...stuckContractFails.map(fingerprintFail)]),
1187
1230
  preWarmedContext,
1188
1231
  evalTokens,
1232
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1189
1233
  evalLlmMs,
1190
1234
  lastResultText: lines.join("\n\n"),
1191
1235
  isEvalFeedback: true,
@@ -1202,6 +1246,7 @@ export async function runEvalRound(
1202
1246
  prevFailFingerprints: currFailFingerprints,
1203
1247
  preWarmedContext,
1204
1248
  evalTokens,
1249
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1205
1250
  evalLlmMs,
1206
1251
  lastResultText: "",
1207
1252
  isEvalFeedback: false,
@@ -1272,6 +1317,7 @@ export async function runEvalRound(
1272
1317
  prevFailFingerprints: new Set([...currFailFingerprints, ...fitFails.map(fingerprintFail)]),
1273
1318
  preWarmedContext,
1274
1319
  evalTokens,
1320
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1275
1321
  evalLlmMs,
1276
1322
  lastResultText: fitLines.join("\n\n"),
1277
1323
  isEvalFeedback: true,
@@ -1301,6 +1347,7 @@ export async function runEvalRound(
1301
1347
  prevFailFingerprints: new Set([...currFailFingerprints, ...capContractFails.map(fingerprintFail)]),
1302
1348
  preWarmedContext,
1303
1349
  evalTokens,
1350
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1304
1351
  evalLlmMs,
1305
1352
  lastResultText: contractLines.join("\n\n"),
1306
1353
  isEvalFeedback: true,
@@ -1327,6 +1374,7 @@ export async function runEvalRound(
1327
1374
  prevFailFingerprints: currFailFingerprints,
1328
1375
  preWarmedContext,
1329
1376
  evalTokens,
1377
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1330
1378
  evalLlmMs,
1331
1379
  lastResultText: "",
1332
1380
  isEvalFeedback: false,
@@ -1359,6 +1407,7 @@ export async function runEvalRound(
1359
1407
  ]),
1360
1408
  preWarmedContext,
1361
1409
  evalTokens,
1410
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1362
1411
  evalLlmMs,
1363
1412
  lastResultText: probeLines.join("\n\n"),
1364
1413
  isEvalFeedback: true,
@@ -1380,6 +1429,7 @@ export async function runEvalRound(
1380
1429
  prevFailFingerprints: currFailFingerprints,
1381
1430
  preWarmedContext,
1382
1431
  evalTokens,
1432
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1383
1433
  evalLlmMs,
1384
1434
  lastResultText: "",
1385
1435
  isEvalFeedback: false,
@@ -1412,6 +1462,14 @@ export async function runEvalRound(
1412
1462
  console.log(
1413
1463
  `[simple] eval round ${evalRoundsUsed}: ${allActionable.length} issues (${blocking.length} fail, ${warns.length} warn) → feeding back to coding loop`,
1414
1464
  );
1465
+ // ggui#1542 — WHY this round continued, for a caller that set `actOn`: the manipulation check of an experiment on
1466
+ // the setting reads this line. `visual majors acted` counts the judge's majors only (fit's are never acted on).
1467
+ if (visualEvaluation?.actOn !== undefined) {
1468
+ console.log(
1469
+ `[simple] eval round ${evalRoundsUsed}: continuing: fails=${blocking.length}, visual majors acted=${visualMajorsActed.length} ` +
1470
+ `(actOn=${visualEvaluation.actOn})`,
1471
+ );
1472
+ }
1415
1473
 
1416
1474
  return {
1417
1475
  control: "feedback",
@@ -1431,6 +1489,7 @@ export async function runEvalRound(
1431
1489
  ),
1432
1490
  preWarmedContext,
1433
1491
  evalTokens,
1492
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1434
1493
  evalLlmMs,
1435
1494
  lastResultText: issueLines.join("\n\n"),
1436
1495
  isEvalFeedback: true,
@@ -1463,6 +1522,7 @@ export async function runEvalRound(
1463
1522
  prevFailFingerprints,
1464
1523
  preWarmedContext,
1465
1524
  evalTokens,
1525
+ ...(inLoopVisualTokens !== undefined ? { inLoopVisualTokens } : {}),
1466
1526
  evalLlmMs,
1467
1527
  lastResultText: "",
1468
1528
  isEvalFeedback: false,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Which in-loop evaluation legs a generation runs (ggui#1513).
3
+ *
4
+ * The harness gates its evaluation rounds on this, and nothing else does:
5
+ * the text evaluator runs when `evaluation.enabled`, and the visual judge
6
+ * runs when `visualEvaluation.enabled` or when a quality config asks for it
7
+ * (`qualityConfig.visualEval`). A host that needs to know whether a
8
+ * generation's token counts (`GenerationMetadata`) can include evaluation
9
+ * calls reads it here, with the options it configured the generator with,
10
+ * instead of keeping its own copy of the rule.
11
+ */
12
+ import type { QualityConfig } from "../evaluation/types-public.js";
13
+
14
+ /** The switches that turn an in-loop evaluation leg on. */
15
+ export interface InLoopEvaluationSwitches {
16
+ readonly evaluation?: { readonly enabled: boolean };
17
+ readonly visualEvaluation?: { readonly enabled: boolean };
18
+ readonly qualityConfig?: Pick<QualityConfig, "visualEval">;
19
+ }
20
+
21
+ /** The in-loop evaluation legs these options turn on. */
22
+ export function inLoopEvaluation(options: InLoopEvaluationSwitches): {
23
+ readonly text: boolean;
24
+ readonly visual: boolean;
25
+ } {
26
+ return {
27
+ text: options.evaluation?.enabled === true,
28
+ visual: options.visualEvaluation?.enabled === true || options.qualityConfig?.visualEval === true,
29
+ };
30
+ }
31
+
32
+ /**
33
+ * True when these options turn either leg on: a generation built with them
34
+ * can spend tokens on evaluation calls as well as on its coding turns.
35
+ */
36
+ export function runsInLoopEvaluation(options: InLoopEvaluationSwitches): boolean {
37
+ const legs = inLoopEvaluation(options);
38
+ return legs.text || legs.visual;
39
+ }