@ggui-ai/ui-gen 0.9.0 → 0.10.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 (87) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +9 -2
  2. package/dist/adapters/generation-dispatch.js +547 -85
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +28 -11
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +5 -3
  7. package/dist/advanced/index.js +563 -92
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +109 -3
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.js +14 -2
  12. package/dist/boilerplate.js.map +1 -1
  13. package/dist/check/index.d.ts +1 -1
  14. package/dist/check/index.js +11 -1
  15. package/dist/check/index.js.map +1 -1
  16. package/dist/{create-ui-generator-B9kV4U9h.d.ts → create-ui-generator-CF0EL0Li.d.ts} +13 -1
  17. package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
  18. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  19. package/dist/evaluation/axis-checks/index.js +101 -0
  20. package/dist/evaluation/axis-checks/index.js.map +1 -1
  21. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  22. package/dist/evaluation/axis-checks/registry.js +101 -0
  23. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  24. package/dist/evaluation/index.d.ts +46 -3
  25. package/dist/evaluation/index.js +358 -17
  26. package/dist/evaluation/index.js.map +1 -1
  27. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  28. package/dist/harness/check/runtime-render/index.js +110 -3
  29. package/dist/harness/check/runtime-render/index.js.map +1 -1
  30. package/dist/harness/index.d.ts +2 -2
  31. package/dist/harness/index.js +263 -18
  32. package/dist/harness/index.js.map +1 -1
  33. package/dist/harness/llm-trace-sink.d.ts +1 -1
  34. package/dist/harness/llm-trace-sink.js.map +1 -1
  35. package/dist/harness/prompts.js +17 -3
  36. package/dist/harness/prompts.js.map +1 -1
  37. package/dist/harness/result-types.d.ts +1 -1
  38. package/dist/harness/runtime.d.ts +9 -2
  39. package/dist/harness/runtime.js +20 -6
  40. package/dist/harness/runtime.js.map +1 -1
  41. package/dist/harness/types-public.d.ts +1 -1
  42. package/dist/harness/types-public.js +14 -2
  43. package/dist/harness/types-public.js.map +1 -1
  44. package/dist/harness/validator-trace-sink.d.ts +1 -1
  45. package/dist/index.d.ts +5 -3
  46. package/dist/index.js +563 -92
  47. package/dist/index.js.map +1 -1
  48. package/dist/{llm-router-1TyI0_TV.d.ts → llm-router-BEVtbAb9.d.ts} +61 -4
  49. package/dist/patch.d.ts +1 -1
  50. package/dist/provider-adapter.d.ts +16 -6
  51. package/dist/provider-adapter.js.map +1 -1
  52. package/dist/providers/index.d.ts +19 -6
  53. package/dist/providers/index.js.map +1 -1
  54. package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
  55. package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
  56. package/dist/workflows.d.ts +1 -1
  57. package/package.json +12 -12
  58. package/src/adapters/generation-dispatch.ts +9 -0
  59. package/src/adapters/openrouter/client.ts +2 -2
  60. package/src/adapters/openrouter/types.ts +10 -0
  61. package/src/boilerplate/generate.ts +10 -2
  62. package/src/boilerplate/json-schema-ts.ts +22 -2
  63. package/src/boilerplate/system-prompt.ts +1 -0
  64. package/src/compile.ts +1 -1
  65. package/src/contract-context.ts +10 -2
  66. package/src/create-ui-generator.ts +14 -0
  67. package/src/design-system-docs.ts +6 -4
  68. package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
  69. package/src/evaluation/axis-checks/registry.ts +2 -0
  70. package/src/evaluation/index.ts +11 -0
  71. package/src/evaluation/llm-evaluator.ts +14 -1
  72. package/src/evaluation/visual-evaluator.ts +44 -65
  73. package/src/harness/check/runtime-render/adapter.ts +18 -0
  74. package/src/harness/check/runtime-render/index.ts +8 -0
  75. package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
  76. package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
  77. package/src/harness/check/runtime-render/render-check.ts +72 -1
  78. package/src/harness/coding/run-eval-round.ts +61 -5
  79. package/src/harness/index.ts +1 -1
  80. package/src/harness/llm-router.ts +397 -18
  81. package/src/harness/llm-trace-sink.ts +2 -1
  82. package/src/harness/runtime.ts +7 -0
  83. package/src/index.ts +5 -5
  84. package/src/provider-adapter.ts +16 -6
  85. package/src/providers/bedrock.ts +5 -4
  86. package/src/providers/index.ts +16 -4
  87. package/src/tools/render-check.ts +19 -7
@@ -82,6 +82,15 @@ function jsonSchemaTypeToTs(schema) {
82
82
  if (schema.enum?.length) {
83
83
  return schema.enum.map((v) => typeof v === "string" ? `'${v}'` : String(v)).join(" | ");
84
84
  }
85
+ if (Array.isArray(schema.type)) {
86
+ const { nullable: _nullable, ...rest } = schema;
87
+ const members = [...new Set(schema.type)].map(
88
+ (member) => jsonSchemaTypeToTs({ ...rest, type: member })
89
+ );
90
+ const unique = [...new Set(members)];
91
+ if (schema.nullable && !unique.includes("null")) unique.push("null");
92
+ return unique.join(" | ");
93
+ }
85
94
  let result;
86
95
  switch (schema.type) {
87
96
  case "string":
@@ -99,7 +108,8 @@ function jsonSchemaTypeToTs(schema) {
99
108
  case "array": {
100
109
  if (schema.items) {
101
110
  const itemType = jsonSchemaTypeToTs(schema.items);
102
- result = schema.items.type === "object" && schema.items.properties ? `Array<${itemType}>` : itemType.includes("|") ? `(${itemType})[]` : `${itemType}[]`;
111
+ const itemsDeclareObject = Array.isArray(schema.items.type) ? schema.items.type.includes("object") : schema.items.type === "object";
112
+ result = itemsDeclareObject && schema.items.properties ? `Array<${itemType}>` : itemType.includes("|") ? `(${itemType})[]` : `${itemType}[]`;
103
113
  } else {
104
114
  result = "unknown[]";
105
115
  }
@@ -552,14 +562,16 @@ var OpenRouterError;
552
562
  var init_types = __esm({
553
563
  "src/adapters/openrouter/types.ts"() {
554
564
  OpenRouterError = class extends Error {
555
- constructor(message, status, code) {
565
+ constructor(message, status, code, headers) {
556
566
  super(message);
557
567
  this.status = status;
558
568
  this.code = code;
569
+ this.headers = headers;
559
570
  this.name = "OpenRouterError";
560
571
  }
561
572
  status;
562
573
  code;
574
+ headers;
563
575
  get isTransient() {
564
576
  return this.status === 408 || this.status === 429 || this.status === 502 || this.status === 503;
565
577
  }
@@ -604,7 +616,7 @@ var init_client2 = __esm({
604
616
  if (!response.ok) {
605
617
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
606
618
  const message = errorBody.error?.message ?? response.statusText;
607
- throw new OpenRouterError(message, response.status);
619
+ throw new OpenRouterError(message, response.status, void 0, response.headers);
608
620
  }
609
621
  return response.json();
610
622
  }
@@ -622,7 +634,7 @@ var init_client2 = __esm({
622
634
  if (!response.ok) {
623
635
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
624
636
  const message = errorBody.error?.message ?? response.statusText;
625
- throw new OpenRouterError(message, response.status);
637
+ throw new OpenRouterError(message, response.status, void 0, response.headers);
626
638
  }
627
639
  if (!response.body) {
628
640
  throw new OpenRouterError("No response body for streaming request", 500);
@@ -690,6 +702,29 @@ function errorSummary(e) {
690
702
  }
691
703
  return `${e.constructor.name}${status}${code}: ${e.message.slice(0, 120)}${body}`;
692
704
  }
705
+ function isRateLimited(e) {
706
+ if (!(e instanceof Error) || !("status" in e)) return false;
707
+ return e.status === 429;
708
+ }
709
+ function extractStatus(e) {
710
+ if (!(e instanceof Error) || !("status" in e)) return void 0;
711
+ const status = e.status;
712
+ return typeof status === "number" ? status : void 0;
713
+ }
714
+ function extractRetryAfterSec(e) {
715
+ if (!(e instanceof Error) || !("headers" in e)) return void 0;
716
+ const headers = e.headers;
717
+ if (headers === null || typeof headers !== "object" || !("get" in headers) || typeof headers.get !== "function") {
718
+ return void 0;
719
+ }
720
+ const raw = headers.get("retry-after");
721
+ if (raw === null) return void 0;
722
+ const parsed = Number(raw);
723
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : void 0;
724
+ }
725
+ function sleep(ms) {
726
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
727
+ }
693
728
  function interactionText(interaction) {
694
729
  let text = "";
695
730
  for (const step of interaction.steps ?? []) {
@@ -714,24 +749,38 @@ function toUserInputStep(text) {
714
749
  function createAgent(providerOrConfig) {
715
750
  const provider = typeof providerOrConfig === "string" ? providerOrConfig : providerOrConfig.provider;
716
751
  const routeOverride = typeof providerOrConfig === "string" ? void 0 : providerOrConfig.routeOverride;
752
+ const onRetry = typeof providerOrConfig === "string" ? void 0 : providerOrConfig.onRetry;
717
753
  switch (provider) {
718
754
  case "anthropic":
719
- return new AnthropicAgent(routeOverride);
755
+ return new AnthropicAgent(routeOverride, onRetry);
720
756
  case "openai":
721
- return new OpenAIAgent(routeOverride);
757
+ return new OpenAIAgent(routeOverride, onRetry);
722
758
  case "google":
723
- return new GoogleAgent(routeOverride);
759
+ return new GoogleAgent(routeOverride, onRetry);
724
760
  case "openrouter":
725
- return new OpenRouterAgent(routeOverride);
761
+ return new OpenRouterAgent(routeOverride, onRetry);
726
762
  }
727
763
  }
728
- var LLMAgent, AnthropicAgent, OpenAIAgent, GoogleAgent, OpenRouterAgent;
764
+ function createVisionAgent(config) {
765
+ switch (config.provider) {
766
+ case "anthropic":
767
+ return new AnthropicAgent(config.routeOverride, config.onRetry);
768
+ case "google":
769
+ return new GoogleAgent(config.routeOverride, config.onRetry);
770
+ }
771
+ }
772
+ var MAX_RETRY_ATTEMPTS, RETRY_AFTER_CAP_SEC, DEFAULT_BACKOFF_BASE_MS, DEFAULT_BACKOFF_MAX_MS, MAX_TOTAL_RETRY_DELAY_MS, LLMAgent, AnthropicAgent, OpenAIAgent, GoogleAgent, OpenRouterAgent;
729
773
  var init_llm_router = __esm({
730
774
  "src/harness/llm-router.ts"() {
731
775
  init_client();
732
776
  init_provider_router();
733
777
  init_tool_bridge();
734
778
  init_llm_trace_sink();
779
+ MAX_RETRY_ATTEMPTS = 2;
780
+ RETRY_AFTER_CAP_SEC = 15;
781
+ DEFAULT_BACKOFF_BASE_MS = 1e3;
782
+ DEFAULT_BACKOFF_MAX_MS = 8e3;
783
+ MAX_TOTAL_RETRY_DELAY_MS = 2e4;
735
784
  LLMAgent = class {
736
785
  client = null;
737
786
  // ── Session state for server-side chaining ──────────────
@@ -746,8 +795,11 @@ var init_llm_router = __esm({
746
795
  * back to `process.env` exactly as before.
747
796
  */
748
797
  routeOverride;
749
- constructor(routeOverride) {
798
+ /** See `AgentConfig.onRetry` (#489). Absent is a no-op. */
799
+ onRetry;
800
+ constructor(routeOverride, onRetry) {
750
801
  this.routeOverride = routeOverride;
802
+ this.onRetry = onRetry;
751
803
  }
752
804
  async getClient() {
753
805
  if (!this.client) {
@@ -782,17 +834,83 @@ var init_llm_router = __esm({
782
834
  async sendToolResult(_results) {
783
835
  }
784
836
  /**
785
- * Execute an API call. No retry — if it fails, it fails.
786
- * Logs the error with provider context and re-throws.
837
+ * Execute an API call, retrying once or twice on HTTP 429
838
+ * (rate-limited) before giving up (#489).
839
+ *
840
+ * Policy: on a 429, honor the provider's `Retry-After` header when
841
+ * present (capped at `RETRY_AFTER_CAP_SEC` — a provider asking for a
842
+ * longer wait is treated as "don't retry", not "wait longer": making
843
+ * the caller sit through 15+ more seconds on a single failed call is
844
+ * a bad experience regardless of what's driving the request);
845
+ * otherwise fall back to exponential backoff with jitter, capped at
846
+ * `DEFAULT_BACKOFF_MAX_MS` per attempt. Stops after
847
+ * `MAX_RETRY_ATTEMPTS` retries OR once the cumulative delay would
848
+ * exceed `MAX_TOTAL_RETRY_DELAY_MS`, whichever comes first. Any
849
+ * non-429 error, or a 429 with no attempts left, is logged and
850
+ * re-thrown immediately — unchanged from the pre-#489 behavior.
851
+ *
852
+ * **Budget scope (final-review correction):** `MAX_TOTAL_RETRY_DELAY_MS`
853
+ * bounds a single `apiCall()` invocation, not a whole generation. A
854
+ * generation issues many sequential `apiCall()` calls (coding turns,
855
+ * tool-result round-trips, eval rounds); under sustained throttling
856
+ * each one can independently add up to `MAX_TOTAL_RETRY_DELAY_MS`, so
857
+ * a generation with k rate-limited calls can add up to roughly `k *
858
+ * MAX_TOTAL_RETRY_DELAY_MS` to how long it holds its caller's
859
+ * resources (e.g. the caller's concurrency slot) — not a flat 20s
860
+ * ceiling. No shared per-generation budget is threaded here by
861
+ * design (accepted ruling, not an oversight): the caller's own
862
+ * queue/admission backstop is what bounds how long other
863
+ * queued work waits, independent of any one generation's retry
864
+ * total.
787
865
  */
788
866
  async apiCall(fn) {
789
867
  const start = Date.now();
790
- try {
791
- return await fn();
792
- } catch (e) {
793
- const ms = Date.now() - start;
794
- console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
795
- throw e;
868
+ let totalDelayMs = 0;
869
+ for (let attempt = 0; ; attempt++) {
870
+ try {
871
+ return await fn();
872
+ } catch (e) {
873
+ const ms = Date.now() - start;
874
+ if (attempt >= MAX_RETRY_ATTEMPTS || !isRateLimited(e)) {
875
+ console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
876
+ throw e;
877
+ }
878
+ const retryAfterSec = extractRetryAfterSec(e);
879
+ if (retryAfterSec !== void 0 && retryAfterSec > RETRY_AFTER_CAP_SEC) {
880
+ console.error(
881
+ `[${this.provider}] API error after ${ms}ms (retry-after ${retryAfterSec}s exceeds ${RETRY_AFTER_CAP_SEC}s cap, not retrying): ${errorSummary(e)}`
882
+ );
883
+ throw e;
884
+ }
885
+ const delayMs = retryAfterSec !== void 0 ? retryAfterSec * 1e3 : Math.min(DEFAULT_BACKOFF_BASE_MS * 2 ** attempt, DEFAULT_BACKOFF_MAX_MS) + Math.floor(Math.random() * 250);
886
+ if (totalDelayMs + delayMs > MAX_TOTAL_RETRY_DELAY_MS) {
887
+ console.error(
888
+ `[${this.provider}] API error after ${ms}ms (retry budget exhausted, not retrying): ${errorSummary(e)}`
889
+ );
890
+ throw e;
891
+ }
892
+ totalDelayMs += delayMs;
893
+ const info = {
894
+ provider: this.provider,
895
+ attempt: attempt + 1,
896
+ maxAttempts: MAX_RETRY_ATTEMPTS,
897
+ status: extractStatus(e),
898
+ retryAfterSec,
899
+ delayMs,
900
+ message: errorSummary(e)
901
+ };
902
+ console.warn(
903
+ `[${this.provider}] rate-limited (attempt ${info.attempt}/${info.maxAttempts}), retrying in ${delayMs}ms`
904
+ );
905
+ try {
906
+ this.onRetry?.(info);
907
+ } catch (observerError) {
908
+ console.warn(
909
+ `[${this.provider}] onRetry observer threw (ignored, retry proceeds): ${errorSummary(observerError)}`
910
+ );
911
+ }
912
+ await sleep(delayMs);
913
+ }
796
914
  }
797
915
  }
798
916
  };
@@ -885,6 +1003,73 @@ var init_llm_router = __esm({
885
1003
  throw e;
886
1004
  }
887
1005
  }
1006
+ async callVision(model, systemPrompt, userPrompt, image, maxTokens) {
1007
+ const client = await this.getClient();
1008
+ const traceId = newLlmTraceId();
1009
+ const startedAt = Date.now();
1010
+ const resolvedModel = this.resolveModel(model);
1011
+ const imageBlock = {
1012
+ type: "image",
1013
+ source: {
1014
+ type: "base64",
1015
+ media_type: image.mediaType,
1016
+ data: image.base64
1017
+ }
1018
+ };
1019
+ try {
1020
+ const response = await this.apiCall(
1021
+ () => client.messages.create({
1022
+ model: resolvedModel,
1023
+ max_tokens: maxTokens ?? 1500,
1024
+ system: systemPrompt,
1025
+ messages: [
1026
+ {
1027
+ role: "user",
1028
+ content: [imageBlock, { type: "text", text: userPrompt }]
1029
+ }
1030
+ ]
1031
+ })
1032
+ );
1033
+ const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
1034
+ const endedAt = Date.now();
1035
+ emitLlmTraceEvent({
1036
+ id: traceId,
1037
+ at: startedAt,
1038
+ endedAt,
1039
+ durationMs: endedAt - startedAt,
1040
+ provider: "anthropic",
1041
+ model: resolvedModel,
1042
+ kind: "callVision",
1043
+ systemPrompt,
1044
+ userPrompt,
1045
+ result: {
1046
+ inputTokens: response.usage.input_tokens,
1047
+ outputTokens: response.usage.output_tokens,
1048
+ text
1049
+ }
1050
+ });
1051
+ return {
1052
+ text,
1053
+ inputTokens: response.usage.input_tokens,
1054
+ outputTokens: response.usage.output_tokens
1055
+ };
1056
+ } catch (e) {
1057
+ const endedAt = Date.now();
1058
+ emitLlmTraceEvent({
1059
+ id: traceId,
1060
+ at: startedAt,
1061
+ endedAt,
1062
+ durationMs: endedAt - startedAt,
1063
+ provider: "anthropic",
1064
+ model: resolvedModel,
1065
+ kind: "callVision",
1066
+ systemPrompt,
1067
+ userPrompt,
1068
+ error: { message: e instanceof Error ? e.message : String(e) }
1069
+ });
1070
+ throw e;
1071
+ }
1072
+ }
888
1073
  async callTools(model, systemPrompt, userPrompt, tools, toolChoice = "required", _scopedTools) {
889
1074
  const client = await this.getClient();
890
1075
  const traceId = newLlmTraceId();
@@ -1259,6 +1444,72 @@ var init_llm_router = __esm({
1259
1444
  outputTokens: interaction.usage?.total_output_tokens ?? 0
1260
1445
  };
1261
1446
  }
1447
+ async callVision(model, systemPrompt, userPrompt, image, maxTokens) {
1448
+ const client = await this.getClient();
1449
+ const traceId = newLlmTraceId();
1450
+ const startedAt = Date.now();
1451
+ const resolvedModel = this.resolveModel(model);
1452
+ try {
1453
+ const response = await this.apiCall(
1454
+ () => client.models.generateContent({
1455
+ model: resolvedModel,
1456
+ contents: [
1457
+ {
1458
+ role: "user",
1459
+ parts: [
1460
+ { inlineData: { mimeType: image.mediaType, data: image.base64 } },
1461
+ { text: userPrompt }
1462
+ ]
1463
+ }
1464
+ ],
1465
+ config: {
1466
+ systemInstruction: systemPrompt,
1467
+ ...maxTokens ? { maxOutputTokens: maxTokens } : {}
1468
+ }
1469
+ })
1470
+ );
1471
+ const text = response.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("") || "";
1472
+ const usage = response.usageMetadata;
1473
+ const result = {
1474
+ text,
1475
+ inputTokens: usage?.promptTokenCount ?? 0,
1476
+ outputTokens: usage?.candidatesTokenCount ?? 0
1477
+ };
1478
+ const endedAt = Date.now();
1479
+ emitLlmTraceEvent({
1480
+ id: traceId,
1481
+ at: startedAt,
1482
+ endedAt,
1483
+ durationMs: endedAt - startedAt,
1484
+ provider: "google",
1485
+ model: resolvedModel,
1486
+ kind: "callVision",
1487
+ systemPrompt,
1488
+ userPrompt,
1489
+ result: {
1490
+ inputTokens: result.inputTokens,
1491
+ outputTokens: result.outputTokens,
1492
+ text
1493
+ }
1494
+ });
1495
+ return result;
1496
+ } catch (e) {
1497
+ const endedAt = Date.now();
1498
+ emitLlmTraceEvent({
1499
+ id: traceId,
1500
+ at: startedAt,
1501
+ endedAt,
1502
+ durationMs: endedAt - startedAt,
1503
+ provider: "google",
1504
+ model: resolvedModel,
1505
+ kind: "callVision",
1506
+ systemPrompt,
1507
+ userPrompt,
1508
+ error: { message: e instanceof Error ? e.message : String(e) }
1509
+ });
1510
+ throw e;
1511
+ }
1512
+ }
1262
1513
  async callTools(model, systemPrompt, userPrompt, tools, _toolChoice = "required", scopedTools) {
1263
1514
  const client = await this.getClient();
1264
1515
  const interactionTools = tools.map((t) => ({
@@ -4730,7 +4981,11 @@ async function runLLMEvaluation(context, config, preWarmedContext) {
4730
4981
  designSystemSummary: context.designContext
4731
4982
  });
4732
4983
  const numberedSource = context.sourceCode.split("\n").map((line, i) => `${String(i + 1).padStart(4)} | ${line}`).join("\n");
4733
- const agent = createAgent({ provider: routerProvider, routeOverride: config.routeOverride });
4984
+ const agent = createAgent({
4985
+ provider: routerProvider,
4986
+ routeOverride: config.routeOverride,
4987
+ onRetry: config.onRetry
4988
+ });
4734
4989
  const evalStart = Date.now();
4735
4990
  const totalCriteriaCount = TIER_1_CRITERIA.length + TIER_2_CRITERIA.length;
4736
4991
  const results = await Promise.all([
@@ -8098,6 +8353,7 @@ var visual_evaluator_exports = {};
8098
8353
  __export(visual_evaluator_exports, {
8099
8354
  buildRenderHTML: () => buildRenderHTML,
8100
8355
  bundleForRendering: () => bundleForRendering,
8356
+ callMultimodalLLM: () => callMultimodalLLM,
8101
8357
  runVisualEval: () => runVisualEval,
8102
8358
  runVisualEvaluation: () => runVisualEvaluation
8103
8359
  });
@@ -8222,63 +8478,23 @@ async function captureScreenshot(html, viewport = { width: 1280, height: 800 })
8222
8478
  return null;
8223
8479
  }
8224
8480
  }
8225
- async function callMultimodalLLM(provider, model, prompt, screenshot, originalPrompt) {
8481
+ async function callMultimodalLLM(config, model, prompt, screenshot, originalPrompt) {
8226
8482
  const userPrompt = `## Original Request
8227
8483
  ${originalPrompt}
8228
8484
 
8229
8485
  Evaluate the screenshot of the generated component.`;
8230
- if (provider === "claude") {
8231
- const { default: Anthropic2 } = await import('@anthropic-ai/sdk');
8232
- const client2 = new Anthropic2({
8233
- apiKey: process.env.ANTHROPIC_API_KEY,
8234
- baseURL: "https://api.anthropic.com"
8235
- });
8236
- const response2 = await client2.messages.create({
8237
- model: model.startsWith("anthropic/") ? model.slice(10) : model,
8238
- max_tokens: 1500,
8239
- system: prompt,
8240
- messages: [{
8241
- role: "user",
8242
- content: [
8243
- {
8244
- type: "image",
8245
- source: {
8246
- type: "base64",
8247
- media_type: "image/png",
8248
- data: screenshot.toString("base64")
8249
- }
8250
- },
8251
- { type: "text", text: userPrompt }
8252
- ]
8253
- }]
8254
- });
8255
- return {
8256
- text: response2.content.filter((b) => b.type === "text").map((b) => b.text).join(""),
8257
- inputTokens: response2.usage.input_tokens,
8258
- outputTokens: response2.usage.output_tokens
8259
- };
8260
- }
8261
- const { GoogleGenAI } = await import('@google/genai');
8262
- const client = new GoogleGenAI({
8263
- apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
8264
- });
8265
- const response = await client.models.generateContent({
8266
- model: model.startsWith("google/") ? model.slice(7) : model,
8267
- contents: [{
8268
- role: "user",
8269
- parts: [
8270
- { inlineData: { mimeType: "image/png", data: screenshot.toString("base64") } },
8271
- { text: userPrompt }
8272
- ]
8273
- }],
8274
- config: { systemInstruction: prompt }
8486
+ const agent = createVisionAgent({
8487
+ provider: config.provider === "claude" ? "anthropic" : config.provider,
8488
+ routeOverride: config.routeOverride,
8489
+ onRetry: config.onRetry
8275
8490
  });
8276
- const usage = response.usageMetadata;
8277
- return {
8278
- text: response.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("") || "",
8279
- inputTokens: usage?.promptTokenCount ?? 0,
8280
- outputTokens: usage?.candidatesTokenCount ?? 0
8281
- };
8491
+ return agent.callVision(
8492
+ model,
8493
+ prompt,
8494
+ userPrompt,
8495
+ { mediaType: "image/png", base64: screenshot.toString("base64") },
8496
+ 1500
8497
+ );
8282
8498
  }
8283
8499
  async function runVisualEvaluation(context, config) {
8284
8500
  const startTime = Date.now();
@@ -8296,7 +8512,7 @@ async function runVisualEvaluation(context, config) {
8296
8512
  console.log(`[visual-eval] screenshot: ${screenshot.length}B (${Date.now() - startTime}ms)`);
8297
8513
  const model = config.model ?? getDefaultVisualModel(config.provider);
8298
8514
  const response = await callMultimodalLLM(
8299
- config.provider,
8515
+ config,
8300
8516
  model,
8301
8517
  VISUAL_EVAL_PROMPT,
8302
8518
  screenshot,
@@ -8370,6 +8586,7 @@ async function runVisualEval(context, config) {
8370
8586
  var VISUAL_EVAL_PROMPT;
8371
8587
  var init_visual_evaluator = __esm({
8372
8588
  "src/evaluation/visual-evaluator.ts"() {
8589
+ init_llm_router();
8373
8590
  VISUAL_EVAL_PROMPT = `You are a visual quality evaluator for ggui \u2014 a platform where AI agents generate React UI components on demand.
8374
8591
 
8375
8592
  ## Context
@@ -10064,13 +10281,14 @@ function generateBoilerplate(_userPrompt, contract, shellType, screen, composedS
10064
10281
  const propsFields = [];
10065
10282
  const propsProperties = contract?.propsSpec?.properties ?? {};
10066
10283
  for (const [key, entry] of Object.entries(propsProperties)) {
10067
- const required = entry.required !== false;
10284
+ const required = entry.required === true;
10068
10285
  const nullable = entry.schema.nullable === true;
10069
10286
  const tsType = jsonSchemaTypeToTs(entry.schema);
10070
10287
  const fullType = nullable ? `${tsType} | null` : tsType;
10071
10288
  const parts = [];
10072
10289
  if (entry.description) parts.push(entry.description);
10073
10290
  if (entry.default !== void 0) parts.push(`(default: ${JSON.stringify(entry.default)})`);
10291
+ if (!required) parts.push("(optional \u2014 the agent may omit it; guard or fall back)");
10074
10292
  const desc = parts.length > 0 ? ` // ${parts.join(" ")}` : "";
10075
10293
  propsFields.push(` ${key}${required ? "" : "?"}: ${fullType};${desc}`);
10076
10294
  }
@@ -10629,6 +10847,7 @@ A \`DataContract\` declares everything a GguiSession exchanges with the outside
10629
10847
 
10630
10848
  Props arrive at mount and may be partial on first render; later prop changes reach the SAME mounted component via \`ggui_amend\` (in-place repaint) on the live channel. Stream channels start empty and fill over time. Context slots start at their declared default (often \`null\`). **Never assume a field exists before you read it.**
10631
10849
 
10850
+ - **Optional contract props**: a \`Props\` field marked \`?\` in the boilerplate may be omitted by the agent ENTIRELY \u2014 the wire gate only enforces \`required: true\` entries, and the harness verifies your component with those props stripped. Guard them (\`?? default\`, \`?.\`, or an empty-state branch) from the FIRST render.
10632
10851
  - **Array iteration**: always default to \`[]\` before \`.map\`/\`.filter\`/\`.length\`. Use \`(props.items ?? []).map(...)\` not \`props.items.map(...)\`. Same for stream.all, stream.latest, etc.
10633
10852
  - **Object access**: optional-chain through nested fields. \`props.user?.name ?? 'Anonymous'\` not \`props.user.name\`.
10634
10853
  - **Number ops**: default before arithmetic. \`(props.count ?? 0) + 1\` not \`props.count + 1\`.
@@ -11570,10 +11789,12 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
11570
11789
  - Borders/dividers \u2192 \`var(--ggui-color-outline)\` or \`var(--ggui-color-outlineVariant)\`
11571
11790
 
11572
11791
  ### State Colors
11573
- - var(--ggui-color-success) \u2014 success states
11574
- - var(--ggui-color-warning) \u2014 warning states
11575
- - var(--ggui-color-error) \u2014 error states, destructive actions
11576
- - var(--ggui-color-info) \u2014 informational
11792
+ Semantic families ship as SCALES; the \`500\` stop is the role color (per-mode brightness), \`50\` the tint, \`800\` the on-tint text:
11793
+ - var(--ggui-color-success-500) \u2014 success states (tint bg: success-50, text on tint: success-800)
11794
+ - var(--ggui-color-warning-500) \u2014 warning states (warning-50 / warning-800)
11795
+ - var(--ggui-color-error-500) \u2014 error states, destructive actions (error-50 / error-800)
11796
+ - var(--ggui-color-info-500) \u2014 informational (info-50 / info-800)
11797
+ Prefer the primitives' \`tone="success|warning|error|info"\` prop over hand-written vars \u2014 it resolves to the same stops.
11577
11798
 
11578
11799
  ### IMPORTANT: Color Rules
11579
11800
  - **NEVER** use hardcoded hex colors. ONLY use var(--ggui-color-*) tokens.
@@ -12365,6 +12586,106 @@ var STATE_MERGE_CHECKS = [
12365
12586
  }
12366
12587
  ];
12367
12588
 
12589
+ // src/evaluation/axis-checks/checks/state-ui-affordance.ts
12590
+ var STATE_ARIA_OR_ROLE = /aria-(checked|pressed|selected|expanded|current)\s*=|role\s*=/;
12591
+ var STATE_PRIMITIVE_IN_SUBTREE = /<(Checkbox|Toggle|Tabs|Accordion)[\s/>]/;
12592
+ var SEMANTIC_PRIMITIVES = /* @__PURE__ */ new Set(["Button", "Checkbox", "Toggle", "Link", "MenuItem"]);
12593
+ function collectSetters(src) {
12594
+ const setters = /* @__PURE__ */ new Set();
12595
+ for (const m of src.matchAll(/const\s*\[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState/g)) {
12596
+ setters.add(m[1]);
12597
+ }
12598
+ return setters;
12599
+ }
12600
+ function collectHandlers(src, setters) {
12601
+ const handlers = /* @__PURE__ */ new Set();
12602
+ for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>\s*\{?/g)) {
12603
+ const body = src.slice(m.index + m[0].length, m.index + m[0].length + 400);
12604
+ for (const s of setters) {
12605
+ if (body.includes(s)) {
12606
+ handlers.add(m[1]);
12607
+ break;
12608
+ }
12609
+ }
12610
+ }
12611
+ return handlers;
12612
+ }
12613
+ function attrBlock(src, start) {
12614
+ let depth = 0;
12615
+ for (let i = start; i < src.length; i++) {
12616
+ const c = src[i];
12617
+ if (c === "{") depth++;
12618
+ else if (c === "}") depth--;
12619
+ else if (c === ">" && depth === 0) return { block: src.slice(start, i), end: i };
12620
+ }
12621
+ return { block: src.slice(start, start + 900), end: start + 900 };
12622
+ }
12623
+ function onClickValue(block) {
12624
+ const m = block.match(/onClick\s*=\s*\{/);
12625
+ if (!m || m.index === void 0) return null;
12626
+ const open = m.index + m[0].length - 1;
12627
+ let depth = 0;
12628
+ for (let i = open; i < block.length; i++) {
12629
+ if (block[i] === "{") depth++;
12630
+ else if (block[i] === "}") {
12631
+ depth--;
12632
+ if (depth === 0) return block.slice(open + 1, i);
12633
+ }
12634
+ }
12635
+ return block.slice(open + 1);
12636
+ }
12637
+ function subtree(src, pos, tag) {
12638
+ const close = src.indexOf(`</${tag}>`, pos);
12639
+ return src.slice(pos, close === -1 ? pos + 700 : close);
12640
+ }
12641
+ function runStateAriaPresent(input) {
12642
+ if (input.compiledCode === null) return [];
12643
+ const src = input.sourceCode;
12644
+ const setters = collectSetters(src);
12645
+ const handlers = collectHandlers(src, setters);
12646
+ const issues = [];
12647
+ const flaggedLines = /* @__PURE__ */ new Set();
12648
+ const localComponents = /* @__PURE__ */ new Set();
12649
+ for (const m of src.matchAll(/(?:function\s+([A-Z]\w*)\s*\(|const\s+([A-Z]\w*)\s*=)/g)) {
12650
+ localComponents.add(m[1] ?? m[2]);
12651
+ }
12652
+ for (const m of src.matchAll(/<([A-Za-z][A-Za-z0-9]*)\b/g)) {
12653
+ const tag = m[1];
12654
+ if (m.index === void 0) continue;
12655
+ if (localComponents.has(tag)) continue;
12656
+ const { block, end } = attrBlock(src, m.index + m[0].length);
12657
+ const clickValue = onClickValue(block);
12658
+ if (clickValue === null) continue;
12659
+ const mutatesAffordance = [...setters].some((s) => clickValue.includes(s)) || [...handlers].some((h) => new RegExp(`\\b${h}\\b`).test(clickValue)) || // Extracted-subcomponent shape: the toggle handler arrives as an
12660
+ // `on[A-Z]…` callback prop and is invoked from onClick — inside a
12661
+ // ui-affordance component that IS the affordance mutation.
12662
+ /\bon[A-Z]\w*\b/.test(clickValue);
12663
+ if (!mutatesAffordance) continue;
12664
+ if (SEMANTIC_PRIMITIVES.has(tag)) continue;
12665
+ if (STATE_ARIA_OR_ROLE.test(block)) continue;
12666
+ if (STATE_PRIMITIVE_IN_SUBTREE.test(subtree(src, end, tag))) continue;
12667
+ const line = src.slice(0, m.index).split("\n").length;
12668
+ if (flaggedLines.has(line)) continue;
12669
+ flaggedLines.add(line);
12670
+ issues.push(
12671
+ mkIssue(
12672
+ "state.ui_affordance.state_aria_present",
12673
+ `Line ${line}: <${tag}> toggles the UI affordance from its onClick but exposes no state ARIA \u2014 its checked/selected/expanded state is invisible to assistive technology (styling is the only carrier).`,
12674
+ `Add the matching state attribute to the SAME element that carries the onClick: \`role="checkbox"\` + \`aria-checked={\u2026}\` for a toggle row, \`aria-pressed\` for a toggle button, \`aria-selected\` for a selection, \`aria-expanded\` for an open/close affordance \u2014 or replace the hand-rolled control with the state-carrying primitive (Checkbox / Toggle / Tabs / Accordion). \`as={Clickable}\` adds button + keyboard semantics but NOT state.`
12675
+ )
12676
+ );
12677
+ }
12678
+ return issues;
12679
+ }
12680
+ var STATE_UI_AFFORDANCE_CHECKS = [
12681
+ {
12682
+ id: "state.ui_affordance.state_aria_present",
12683
+ axis: "state",
12684
+ values: ["ui-affordance"],
12685
+ run: runStateAriaPresent
12686
+ }
12687
+ ];
12688
+
12368
12689
  // src/evaluation/axis-checks/checks/realtime.ts
12369
12690
  var REALTIME_ACTIVE = ["merge", "append", "status", "presence", "mixed"];
12370
12691
  function runStreamHandlerPerEvent(input) {
@@ -13054,6 +13375,7 @@ var EXTRA_CHECKS = [
13054
13375
  var REGISTRY = [
13055
13376
  ...UNIVERSAL_CHECKS,
13056
13377
  ...STATE_MERGE_CHECKS,
13378
+ ...STATE_UI_AFFORDANCE_CHECKS,
13057
13379
  ...REALTIME_CHECKS,
13058
13380
  ...WRITES_CHECKS,
13059
13381
  ...STATE_PAYLOAD_CHECKS,
@@ -13224,8 +13546,8 @@ ${cjsResult.code}
13224
13546
  // src/harness/check/runtime-render/load-component.ts
13225
13547
  async function loadComponent(input) {
13226
13548
  const { sourceCode, moduleResolutions = {} } = input;
13227
- const { createRequire: createRequire3 } = await import('module');
13228
- const require_ = createRequire3(import.meta.url);
13549
+ const { createRequire: createRequire4 } = await import('module');
13550
+ const require_ = createRequire4(import.meta.url);
13229
13551
  const sandboxRequire = (id) => {
13230
13552
  if (id in moduleResolutions) return moduleResolutions[id];
13231
13553
  if (id === "react/jsx-runtime" || id === "react/jsx-dev-runtime") {
@@ -13481,6 +13803,47 @@ function wrapDetection(kind, jsx, native, custom) {
13481
13803
  function dedupe(arr) {
13482
13804
  return Array.from(new Set(arr));
13483
13805
  }
13806
+ var installed = false;
13807
+ function installProductionActShim() {
13808
+ if (installed) return;
13809
+ installed = true;
13810
+ const require_ = createRequire(import.meta.url);
13811
+ let reactCjs;
13812
+ try {
13813
+ reactCjs = require_("react");
13814
+ } catch {
13815
+ return;
13816
+ }
13817
+ if (typeof reactCjs.act === "function") {
13818
+ return;
13819
+ }
13820
+ try {
13821
+ const testUtils = require_("react-dom/test-utils");
13822
+ const reactDom = require_("react-dom");
13823
+ testUtils.act = createFlushSyncAct(reactDom.flushSync);
13824
+ } catch {
13825
+ }
13826
+ }
13827
+ function createFlushSyncAct(flushSync) {
13828
+ return (callback) => {
13829
+ let callbackResult;
13830
+ flushSync(() => {
13831
+ callbackResult = callback();
13832
+ });
13833
+ if (isThenable(callbackResult)) {
13834
+ const pending = callbackResult;
13835
+ return new Promise((resolve3, reject) => {
13836
+ pending.then(() => resolve3(), reject);
13837
+ });
13838
+ }
13839
+ return new Promise((resolve3) => {
13840
+ queueMicrotask(resolve3);
13841
+ });
13842
+ };
13843
+ }
13844
+ function isThenable(value) {
13845
+ return typeof value === "object" && value !== null && typeof value.then === "function";
13846
+ }
13484
13847
 
13485
13848
  // src/internal/open-record.ts
13486
13849
  function openRecord(o) {
@@ -13598,6 +13961,7 @@ async function runRenderCheck(input) {
13598
13961
  const probe = createProbe();
13599
13962
  const wireConfig = createProbeWireConfig(probe);
13600
13963
  uninstallSpy = probe.installPostMessageSpy();
13964
+ installProductionActShim();
13601
13965
  const { render, cleanup } = await import('@testing-library/react');
13602
13966
  const userEventModule = await import('@testing-library/user-event');
13603
13967
  const userEvent = userEventModule.default ?? userEventModule;
@@ -13753,6 +14117,50 @@ async function runRenderCheck(input) {
13753
14117
  if (issue) issues.push(issue);
13754
14118
  }
13755
14119
  }
14120
+ const declaredProps = input.contract?.propsSpec?.properties;
14121
+ if (declaredProps) {
14122
+ const optionalFilled = Object.entries(declaredProps).filter(
14123
+ ([name, entry]) => entry.required !== true && input.mockupProps[name] !== void 0
14124
+ ).map(([name]) => name);
14125
+ if (optionalFilled.length > 0) {
14126
+ const requiredOnlyProps = { ...input.mockupProps };
14127
+ for (const name of optionalFilled) delete requiredOnlyProps[name];
14128
+ const omitBoundaryRef = {};
14129
+ class OmitErrorBoundary extends React.Component {
14130
+ state = { caught: false };
14131
+ static getDerivedStateFromError() {
14132
+ return { caught: true };
14133
+ }
14134
+ componentDidCatch(error) {
14135
+ omitBoundaryRef.error ??= error;
14136
+ }
14137
+ render() {
14138
+ if (this.state.caught) return null;
14139
+ return this.props.children;
14140
+ }
14141
+ }
14142
+ try {
14143
+ render(
14144
+ React.createElement(GguiWireProvider, {
14145
+ config: wireConfig,
14146
+ children: React.createElement(OmitErrorBoundary, {
14147
+ children: React.createElement(Component, requiredOnlyProps)
14148
+ })
14149
+ })
14150
+ );
14151
+ } catch (e) {
14152
+ omitBoundaryRef.error ??= e instanceof Error ? e : new Error(String(e));
14153
+ }
14154
+ if (omitBoundaryRef.error) {
14155
+ issues.push({
14156
+ check: "optional-props-omitted",
14157
+ outcome: "failed",
14158
+ subject: optionalFilled.join(", "),
14159
+ reason: `Render crashed when the optional prop(s) ${optionalFilled.map((n) => `'${n}'`).join(", ")} were omitted: ${omitBoundaryRef.error.message}. The contract marks them optional (no \`required: true\`), so a legitimate render may leave them out.`
14160
+ });
14161
+ }
14162
+ }
14163
+ }
13756
14164
  } finally {
13757
14165
  cleanup();
13758
14166
  }
@@ -14324,6 +14732,15 @@ function synthesizeFromSchema(schema, hint, warnings, depth) {
14324
14732
  if (schema.anyOf && schema.anyOf.length > 0) {
14325
14733
  return synthesizeFromSchema(schema.anyOf[0], hint, warnings, depth + 1);
14326
14734
  }
14735
+ if (Array.isArray(schema.type)) {
14736
+ const primary = schema.type.find((t) => t !== "null") ?? "null";
14737
+ return synthesizeFromSchema(
14738
+ { ...schema, type: primary },
14739
+ hint,
14740
+ warnings,
14741
+ depth + 1
14742
+ );
14743
+ }
14327
14744
  switch (schema.type) {
14328
14745
  case "string": {
14329
14746
  switch (schema.format) {
@@ -14494,6 +14911,16 @@ function toEvalIssue(issue) {
14494
14911
  description: `Component crashed at runtime: ${issue.reason}`,
14495
14912
  fix: classifyRenderCrashFix(issue.reason)
14496
14913
  };
14914
+ case "optional-props-omitted":
14915
+ return {
14916
+ tier: 0,
14917
+ result,
14918
+ category: "crash",
14919
+ subcategory,
14920
+ severity: "critical",
14921
+ description: `Component crashed when optional props were omitted: ${issue.reason}`,
14922
+ fix: `Guard every optional prop before member access (props.${subject.split(",")[0]?.trim() ?? "x"}?.map(...), a default via destructuring, or an early empty-state return). Optional props (no \`required: true\` in the contract) may be absent at runtime even though the harness fills them.`
14923
+ };
14497
14924
  case "action-wiring": {
14498
14925
  const fix = issue.outcome === "unverified" ? `Source shows the action callback flowing into a non-native or custom-component prop. If wiring is intentional (e.g., Dropdown.onChange, drag-drop), this warn is informational \u2014 manual/browser verification is required to confirm. Otherwise wire to a native onClick={() => ${subject}(payload)} on <button> or design-system <Button>.` : `Wire ${subject}() to a native event prop. Common fix: <Button onClick={() => ${subject}(payload)}>Label</Button>. Source-AST analysis didn't find this wiring in your JSX.`;
14499
14926
  return {
@@ -14540,6 +14967,7 @@ async function warmupRuntimeRenderProbe() {
14540
14967
  missing += 1;
14541
14968
  }
14542
14969
  };
14970
+ installProductionActShim();
14543
14971
  await Promise.all([
14544
14972
  tryLoad("happy-dom"),
14545
14973
  tryLoad("@testing-library/react"),
@@ -15624,6 +16052,14 @@ function mapProviderForEvaluator(provider) {
15624
16052
 
15625
16053
  // src/harness/coding/run-eval-round.ts
15626
16054
  var MAX_FEEDBACK_ISSUES = 3;
16055
+ function selectActionableFeedback(issues) {
16056
+ const pool = issues.filter((i) => i.result === "fail" || i.result === "warn");
16057
+ const reserved = pool.find((i) => i.tier === 0 && i.result === "fail");
16058
+ return [
16059
+ ...reserved ? [reserved] : [],
16060
+ ...pool.filter((i) => i !== reserved).sort((a, b) => (a.result === "fail" ? 0 : 1) - (b.result === "fail" ? 0 : 1))
16061
+ ].slice(0, MAX_FEEDBACK_ISSUES);
16062
+ }
15627
16063
  function fingerprintFail(issue) {
15628
16064
  const cat = issue.category ?? "";
15629
16065
  const sub = issue.subcategory ?? "";
@@ -15798,7 +16234,14 @@ async function runEvalRound(ctx, input) {
15798
16234
  // back to `process.env` either (the eval calls run
15799
16235
  // inside the same concurrent generation, so the race is
15800
16236
  // real here too, not just on the coding turn).
15801
- routeOverride: evaluationAgent.routeOverride
16237
+ routeOverride: evaluationAgent.routeOverride,
16238
+ // #489 — thread the same provider-429-retry observer.
16239
+ // Without this, a rate-limited retry inside the
16240
+ // evaluation agent's apiCall() happens (the base-class
16241
+ // retry loop is unconditional) but never reaches the
16242
+ // caller, silently missing the host application's
16243
+ // structured log.
16244
+ onRetry: evaluationAgent.onRetry
15802
16245
  },
15803
16246
  preWarmedContext
15804
16247
  ) : null,
@@ -15809,7 +16252,15 @@ async function runEvalRound(ctx, input) {
15809
16252
  model: visualEvalAgent.model,
15810
16253
  passThreshold: visualThreshold,
15811
16254
  sampleProps: visualEvaluation?.sampleProps,
15812
- viewport: visualEvaluation?.viewport
16255
+ viewport: visualEvaluation?.viewport,
16256
+ // #484/#489 — same threading as the LLM-eval leg above:
16257
+ // the visual eval's multimodal call runs inside the same
16258
+ // concurrent generation, so it needs the same routing
16259
+ // override (never fall back to `process.env`) and the
16260
+ // same provider-429-retry observer (retries reach the
16261
+ // host application's structured log).
16262
+ routeOverride: visualEvalAgent.routeOverride,
16263
+ onRetry: visualEvalAgent.onRetry
15813
16264
  }
15814
16265
  ) : null
15815
16266
  ]);
@@ -16028,7 +16479,7 @@ async function runEvalRound(ctx, input) {
16028
16479
  lastDiffFailed: false
16029
16480
  };
16030
16481
  }
16031
- const allActionable = evalResult.issues.filter((i) => i.result === "fail" || i.result === "warn").sort((a, b) => (a.result === "fail" ? 0 : 1) - (b.result === "fail" ? 0 : 1)).slice(0, MAX_FEEDBACK_ISSUES);
16482
+ const allActionable = selectActionableFeedback(evalResult.issues);
16032
16483
  const issueLines = allActionable.map((issue) => {
16033
16484
  const tag = issue.result === "fail" ? "FAIL" : "WARN";
16034
16485
  return `[${tag}] ${issue.category}${issue.subcategory ? "/" + issue.subcategory : ""}: ${issue.description}
@@ -16043,7 +16494,16 @@ async function runEvalRound(ctx, input) {
16043
16494
  evalResult,
16044
16495
  evalRoundsUsed,
16045
16496
  prevModeSubcats: updatedPrevModeSubcats,
16046
- prevFailFingerprints: currFailFingerprints,
16497
+ // DELIVERED fails only (Exp 54 P1 addendum, proposal 2): the
16498
+ // stuck detector's premise is "the LLM saw these and did not
16499
+ // resolve them" — recording fails that the MAX_FEEDBACK_ISSUES
16500
+ // cap cut from the feedback turns truncation into a premature
16501
+ // stuck-exit (the model never saw the issue it is blamed for
16502
+ // not fixing). An undelivered fail recurring next round now
16503
+ // reads as NEW, gets delivered, and earns its fix round.
16504
+ prevFailFingerprints: new Set(
16505
+ allActionable.filter((i) => i.result === "fail").map(fingerprintFail)
16506
+ ),
16047
16507
  preWarmedContext,
16048
16508
  evalTokens,
16049
16509
  evalLlmMs,
@@ -16538,12 +16998,14 @@ async function dispatchGeneration(params) {
16538
16998
  const codingAgent = {
16539
16999
  provider: routerProvider,
16540
17000
  model: resolvedModel,
16541
- routeOverride: params.routeOverride
17001
+ routeOverride: params.routeOverride,
17002
+ onRetry: params.onRetry
16542
17003
  };
16543
17004
  const evaluationAgent = params.models?.evaluation ? {
16544
17005
  provider: routerProvider,
16545
17006
  model: params.models.evaluation,
16546
- routeOverride: params.routeOverride
17007
+ routeOverride: params.routeOverride,
17008
+ onRetry: params.onRetry
16547
17009
  } : void 0;
16548
17010
  const effectiveContract = params.contract;
16549
17011
  const sessionParams = {