@ggui-ai/ui-gen 0.8.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 (100) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +18 -1
  2. package/dist/adapters/generation-dispatch.js +819 -1577
  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 +838 -1579
  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 +17 -4
  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/compose.js +5 -5
  17. package/dist/compose.js.map +1 -1
  18. package/dist/{create-ui-generator-GX7tvbyH.d.ts → create-ui-generator-CF0EL0Li.d.ts} +26 -1
  19. package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
  20. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  21. package/dist/evaluation/axis-checks/index.js +133 -1
  22. package/dist/evaluation/axis-checks/index.js.map +1 -1
  23. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  24. package/dist/evaluation/axis-checks/registry.js +133 -1
  25. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  26. package/dist/evaluation/index.d.ts +46 -3
  27. package/dist/evaluation/index.js +410 -24
  28. package/dist/evaluation/index.js.map +1 -1
  29. package/dist/fragments/index.js +5 -5
  30. package/dist/fragments/index.js.map +1 -1
  31. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  32. package/dist/harness/check/runtime-render/index.js +110 -3
  33. package/dist/harness/check/runtime-render/index.js.map +1 -1
  34. package/dist/harness/index.d.ts +5 -126
  35. package/dist/harness/index.js +292 -32
  36. package/dist/harness/index.js.map +1 -1
  37. package/dist/harness/llm-trace-sink.d.ts +1 -1
  38. package/dist/harness/llm-trace-sink.js.map +1 -1
  39. package/dist/harness/prompts.js +17 -3
  40. package/dist/harness/prompts.js.map +1 -1
  41. package/dist/harness/result-types.d.ts +1 -1
  42. package/dist/harness/runtime.d.ts +17 -1
  43. package/dist/harness/runtime.js +23 -8
  44. package/dist/harness/runtime.js.map +1 -1
  45. package/dist/harness/types-public.d.ts +1 -1
  46. package/dist/harness/types-public.js +22 -9
  47. package/dist/harness/types-public.js.map +1 -1
  48. package/dist/harness/validator-trace-sink.d.ts +1 -1
  49. package/dist/index.d.ts +5 -3
  50. package/dist/index.js +838 -1579
  51. package/dist/index.js.map +1 -1
  52. package/dist/llm-router-BEVtbAb9.d.ts +213 -0
  53. package/dist/patch.d.ts +1 -1
  54. package/dist/provider-adapter.d.ts +16 -6
  55. package/dist/provider-adapter.js.map +1 -1
  56. package/dist/providers/index.d.ts +54 -99
  57. package/dist/providers/index.js +13 -8
  58. package/dist/providers/index.js.map +1 -1
  59. package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
  60. package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
  61. package/dist/validation/index.js +2 -2
  62. package/dist/validation/index.js.map +1 -1
  63. package/dist/workflows.d.ts +1 -1
  64. package/package.json +18 -18
  65. package/src/adapters/generation-dispatch.ts +28 -2
  66. package/src/adapters/openrouter/client.ts +2 -2
  67. package/src/adapters/openrouter/types.ts +10 -0
  68. package/src/boilerplate/generate.ts +10 -2
  69. package/src/boilerplate/json-schema-ts.ts +22 -2
  70. package/src/boilerplate/system-prompt.ts +4 -2
  71. package/src/compile.ts +1 -1
  72. package/src/contract-context.ts +10 -2
  73. package/src/create-ui-generator.ts +58 -11
  74. package/src/design-system-docs.ts +6 -4
  75. package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
  76. package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
  77. package/src/evaluation/axis-checks/checks/universal.ts +39 -0
  78. package/src/evaluation/axis-checks/registry.ts +2 -0
  79. package/src/evaluation/index.ts +11 -0
  80. package/src/evaluation/llm-evaluator.ts +26 -3
  81. package/src/evaluation/visual-evaluator.ts +49 -66
  82. package/src/fragments/realtime.ts +3 -3
  83. package/src/fragments/state.ts +2 -2
  84. package/src/harness/check/runtime-render/adapter.ts +18 -0
  85. package/src/harness/check/runtime-render/index.ts +8 -0
  86. package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
  87. package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
  88. package/src/harness/check/runtime-render/render-check.ts +72 -1
  89. package/src/harness/coding/init-session.ts +4 -1
  90. package/src/harness/coding/run-eval-round.ts +67 -5
  91. package/src/harness/index.ts +1 -1
  92. package/src/harness/llm-router.ts +451 -27
  93. package/src/harness/llm-trace-sink.ts +2 -1
  94. package/src/harness/runtime.ts +15 -0
  95. package/src/index.ts +5 -5
  96. package/src/provider-adapter.ts +16 -6
  97. package/src/providers/bedrock.ts +89 -26
  98. package/src/providers/index.ts +16 -4
  99. package/src/tools/render-check.ts +19 -7
  100. package/src/validation/ui-compiler.ts +2 -2
@@ -1,138 +1,17 @@
1
1
  import { Harness } from './types-public.js';
2
2
  export { CheckLeg, CheckResult, CreateHarnessInput, DefaultTaskRunner, HarnessConstructionContext, HarnessId, HarnessMeta, HarnessName, HarnessOverrides, HarnessRevision, HowLeg, IterationRecord, LLMEvaluator, PatchFn, Phase, PhaseRunResult, PlannerDecision, PlannerFn, ProcessLeg, PromptBuilder, RetryPolicy, RunCheckInput, RunHarnessInput, RunHarnessReason, RunHarnessResult, RunWorkflowInput, Task, TaskContext, TaskRunner, TierCheck, WhatLeg, Workflow, WorkflowRunResult, createHarness, runCheck, runHarness, runWorkflow } from './types-public.js';
3
3
  export { applyLineRanges, defaultApplyPatch } from '../patch.js';
4
- import { JsonObject } from '@ggui-ai/protocol';
5
- import { LLMToolDef } from '../llm.js';
4
+ export { A as AgentConfig, L as LLMResponse, P as ProviderRetryInfo, c as callLLM, a as createAgent } from '../llm-router-BEVtbAb9.js';
6
5
  export { WORKFLOWS, WorkflowId, pickWorkflow } from '../workflows.js';
7
6
  export { LlmTraceEvent, LlmTraceKind, LlmTraceProvider, LlmTraceSink, getLlmTraceSink, setLlmTraceSink } from './llm-trace-sink.js';
8
7
  export { ValidatorTraceEvent, ValidatorTraceSink, getValidatorTraceSink, setValidatorTraceSink } from './validator-trace-sink.js';
9
8
  export { ProcessMode } from '../policy.js';
10
9
  export { computeHarnessId, computeHarnessName, hashClassification } from '../hash.js';
10
+ import '@ggui-ai/protocol';
11
11
  import '../axes-CzLEMDeB.js';
12
- import '../types-public-C36k1q6s.js';
12
+ import '../types-public-DIpjC-OA.js';
13
13
  import '../types-BOvHNG7K.js';
14
-
15
- interface AgentConfig {
16
- provider: 'anthropic' | 'openai' | 'google' | 'openrouter';
17
- model: string;
18
- /**
19
- * Sampling temperature. When defined it is threaded into the provider
20
- * request; when undefined the provider default is used (unchanged
21
- * behavior for all existing callers).
22
- */
23
- temperature?: number;
24
- }
25
- interface LLMResponse {
26
- text: string;
27
- inputTokens: number;
28
- outputTokens: number;
29
- }
30
- interface LLMTool {
31
- name: string;
32
- description: string;
33
- parameters: JsonObject;
34
- handler: (args: JsonObject) => Promise<{
35
- content: Array<{
36
- text: string;
37
- }>;
38
- isError?: boolean;
39
- }>;
40
- }
41
- interface LLMWithToolsResponse {
42
- text: string;
43
- inputTokens: number;
44
- outputTokens: number;
45
- turnsUsed: number;
46
- }
47
- interface LLMToolCall {
48
- /** Provider-specific call ID (for sendToolResult) */
49
- id?: string;
50
- name: string;
51
- input: JsonObject;
52
- }
53
- interface LLMToolCallResponse {
54
- toolCalls: LLMToolCall[];
55
- inputTokens: number;
56
- outputTokens: number;
57
- /**
58
- * Tokens read from / written to the prompt cache, when the provider
59
- * reports them. Optional because not every provider exposes
60
- * prompt-cache accounting — absent means "unreported", never zero.
61
- */
62
- cacheReadTokens?: number;
63
- cacheCreationTokens?: number;
64
- }
65
- /** Result of executing a tool — passed to sendToolResult to close the API contract. */
66
- interface LLMToolResult {
67
- /** Tool call ID from the response (for providers that need it) */
68
- callId?: string;
69
- /** Tool name */
70
- name: string;
71
- /** Text result of executing the tool */
72
- result: string;
73
- /** Whether the tool execution failed */
74
- isError?: boolean;
75
- }
76
- declare abstract class LLMAgent {
77
- abstract readonly provider: AgentConfig['provider'];
78
- private client;
79
- protected lastSessionId: string | undefined;
80
- protected abstract resolveModel(model: string): string;
81
- protected abstract createClient(): Promise<unknown>;
82
- protected getClient<T>(): Promise<T>;
83
- /** Text-only call — no tools */
84
- abstract callText(model: string, systemPrompt: string, userPrompt: string, maxTokens?: number, temperature?: number): Promise<LLMResponse>;
85
- /**
86
- * Single-turn function calling — returns tool calls without executing them.
87
- * Each provider uses its native function/tool calling:
88
- * - Anthropic: tool_use blocks
89
- * - OpenAI: function_call output items
90
- * - Google: functionCall parts
91
- * SDK handles JSON escaping — safe for code, diffs, and other content.
92
- */
93
- abstract callTools(model: string, systemPrompt: string, userPrompt: string, tools: LLMToolDef[], toolChoice?: 'required' | 'auto',
94
- /**
95
- * Optional scoped fallback tools. If the primary tools fail with a
96
- * transport-class error (e.g. `malformed_tool_call` on Gemini after
97
- * retry exhaustion), the provider may retry once with these narrower
98
- * tools before throwing. Universal signal — not provider-gated.
99
- */
100
- scopedTools?: LLMToolDef[]): Promise<LLMToolCallResponse>;
101
- /** Multi-turn agentic loop — executes tools internally */
102
- abstract callWithTools(model: string, systemPrompt: string, userPrompt: string, tools: LLMTool[], maxTurns: number): Promise<LLMWithToolsResponse>;
103
- /**
104
- * Pre-warm cache for repeated callTools() calls with the same system prompt + tools.
105
- * Override in providers that support server-side context caching (e.g., Google).
106
- * No-op by default (Anthropic/OpenAI handle caching automatically per-request).
107
- */
108
- warmCache(_model: string, _systemPrompt: string, _tools: LLMToolDef[], _toolChoice?: 'required' | 'auto'): Promise<void>;
109
- /** Cleanup any cached resources. Call after generation completes. No-op by default. */
110
- cleanup(): Promise<void>;
111
- /** Reset session state between independent generation runs. */
112
- resetSession(): void;
113
- /**
114
- * Send tool execution results back to the provider to close the API contract.
115
- * Call this after executing tools from callTools() and before the next callTools().
116
- *
117
- * For providers with server-side state (Google, OpenAI), this sends the
118
- * function results so the next callTools() can chain properly.
119
- * For stateless providers (Anthropic), this is a no-op.
120
- *
121
- * Override in providers that need it.
122
- */
123
- sendToolResult(_results: LLMToolResult[]): Promise<void>;
124
- /**
125
- * Execute an API call. No retry — if it fails, it fails.
126
- * Logs the error with provider context and re-throws.
127
- */
128
- protected apiCall<T>(fn: () => Promise<T>): Promise<T>;
129
- }
130
- /**
131
- * Create a fresh agent instance for the given provider.
132
- * Each call returns a new instance — no shared state between callers.
133
- */
134
- declare function createAgent(provider: AgentConfig['provider']): LLMAgent;
135
- declare function callLLM(config: AgentConfig, systemPrompt: string, userPrompt: string, maxTokens?: number): Promise<LLMResponse>;
14
+ import '../llm.js';
136
15
 
137
16
  /**
138
17
  * Fingerprint a materialized Harness. `h.id` is already a deterministic
@@ -142,4 +21,4 @@ declare function callLLM(config: AgentConfig, systemPrompt: string, userPrompt:
142
21
  */
143
22
  declare function hashHarness(h: Harness): string;
144
23
 
145
- export { type AgentConfig, Harness, type LLMResponse, callLLM, createAgent, hashHarness };
24
+ export { Harness, hashHarness };
@@ -27,14 +27,16 @@ var OpenRouterError;
27
27
  var init_types = __esm({
28
28
  "src/adapters/openrouter/types.ts"() {
29
29
  OpenRouterError = class extends Error {
30
- constructor(message, status, code) {
30
+ constructor(message, status, code, headers) {
31
31
  super(message);
32
32
  this.status = status;
33
33
  this.code = code;
34
+ this.headers = headers;
34
35
  this.name = "OpenRouterError";
35
36
  }
36
37
  status;
37
38
  code;
39
+ headers;
38
40
  get isTransient() {
39
41
  return this.status === 408 || this.status === 429 || this.status === 502 || this.status === 503;
40
42
  }
@@ -79,7 +81,7 @@ var init_client = __esm({
79
81
  if (!response.ok) {
80
82
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
81
83
  const message = errorBody.error?.message ?? response.statusText;
82
- throw new OpenRouterError(message, response.status);
84
+ throw new OpenRouterError(message, response.status, void 0, response.headers);
83
85
  }
84
86
  return response.json();
85
87
  }
@@ -97,7 +99,7 @@ var init_client = __esm({
97
99
  if (!response.ok) {
98
100
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
99
101
  const message = errorBody.error?.message ?? response.statusText;
100
- throw new OpenRouterError(message, response.status);
102
+ throw new OpenRouterError(message, response.status, void 0, response.headers);
101
103
  }
102
104
  if (!response.body) {
103
105
  throw new OpenRouterError("No response body for streaming request", 500);
@@ -517,11 +519,11 @@ var stateFragments = {
517
519
  axis: "state",
518
520
  value: "merge",
519
521
  cacheTier: "axisDelta",
520
- promptText: "## State: merge (live entity reconciliation)\n1. Seed `const [items, setItems] = useState(props.items ?? [])`.\n2. On stream updates: `setItems(prev => prev.map(it => it.id === event.id ? { ...it, ...event } : it))` \u2014 merge by id, do NOT append.\n3. Memoize derived views (grouping/sorting/filtering) with useMemo.\n4. Per-item actions pass `{ id, ... }` in the payload.\n5. Never push the stream payload into an append-only list unless realtime=append.",
522
+ promptText: "## State: merge (live entity reconciliation)\n1. Seed `const [items, setItems] = useState(props.items ?? [])` AND re-seed on prop change: `useEffect(() => setItems(props.items ?? []), [props.items])` \u2014 a `ggui_amend` repaint must move the list.\n2. On stream updates: `setItems(prev => prev.map(it => it.id === event.id ? { ...it, ...event } : it))` \u2014 merge by id, do NOT append.\n3. Memoize derived views (grouping/sorting/filtering) with useMemo.\n4. Per-item actions pass `{ id, ... }` in the payload.\n5. Never push the stream payload into an append-only list unless realtime=append.",
521
523
  boilerplateMarker: [
522
524
  "",
523
525
  " // \u2500\u2500 Live entity state (merge-by-id) \u2500\u2500",
524
- " // useState(props.items ?? []); merge stream events by item.id; never append.",
526
+ " // useState(props.items ?? []); re-seed via useEffect on [props.items]; merge stream events by item.id; never append.",
525
527
  ""
526
528
  ].join("\n")
527
529
  },
@@ -639,13 +641,13 @@ var realtimeFragments = {
639
641
  axis: "realtime",
640
642
  value: "merge",
641
643
  cacheTier: "axisDelta",
642
- promptText: "## Realtime: merge\nStream events carry an id; merge into LOCAL STATE. Subscribing alone is not enough \u2014 the local list must update on each event for the DOM to re-render.\n\n```tsx\nconst stream = useStream<UpdateT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => prev.map((it) => it.id === stream.latest!.id ? { ...it, ...stream.latest } : it));\n}, [stream.latest]);\n// Render `items.map(...)` \u2014 NOT props.items, NOT stream.all.\n```\n\nNever append \u2014 stream=merge means the entity already exists locally."
644
+ promptText: "## Realtime: merge\nStream events carry an id; merge into LOCAL STATE. Subscribing alone is not enough \u2014 the local list must update on each event for the DOM to re-render.\n\n```tsx\nconst stream = useStream<UpdateT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => setItems(props.items), [props.items]); // re-seed on ggui_amend repaint \u2014 props stay the source of truth\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => prev.map((it) => it.id === stream.latest!.id ? { ...it, ...stream.latest } : it));\n}, [stream.latest]);\n// Render `items.map(...)` \u2014 NOT props.items, NOT stream.all.\n```\n\nNever append \u2014 stream=merge means the entity already exists locally."
643
645
  },
644
646
  append: {
645
647
  axis: "realtime",
646
648
  value: "append",
647
649
  cacheTier: "axisDelta",
648
- promptText: "## Realtime: append\nStream events are new entities; APPEND to local state. Subscribing alone is not enough \u2014 the local list must update for the DOM to re-render.\n\n```tsx\nconst stream = useStream<EventT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => [...prev, stream.latest!]);\n}, [stream.latest]);\n// Render `items.map(...)` \u2014 NOT props.items, NOT stream.all (which accumulates only post-mount).\n```\n\nUse head for newest-first, tail for chat-style. Cap list length if needed. Do not dedupe unless the contract guarantees at-most-once."
650
+ promptText: "## Realtime: append\nStream events are new entities; APPEND to local state. Subscribing alone is not enough \u2014 the local list must update for the DOM to re-render.\n\n```tsx\nconst stream = useStream<EventT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => setItems(props.items), [props.items]); // re-seed on ggui_amend repaint \u2014 props stay the source of truth\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => [...prev, stream.latest!]);\n}, [stream.latest]);\n// Render `items.map(...)` \u2014 NOT props.items, NOT stream.all (which accumulates only post-mount).\n```\n\nUse head for newest-first, tail for chat-style. Cap list length if needed. Do not dedupe unless the contract guarantees at-most-once."
649
651
  },
650
652
  status: {
651
653
  axis: "realtime",
@@ -663,7 +665,7 @@ var realtimeFragments = {
663
665
  axis: "realtime",
664
666
  value: "mixed",
665
667
  cacheTier: "axisDelta",
666
- promptText: "## Realtime: mixed\nMultiple stream channels with DIFFERENT semantics. Each `streamKinds` entry in the contract maps to one of: merge, append, status, presence. Subscribe + sync to local state PER CHANNEL \u2014 subscribing alone never re-renders the DOM.\n\nExample (chat-interface \u2014 message=append, typing=presence):\n\n```tsx\nconst messages = useStream<MessageT>('message');\nconst typing = useStream<TypingT>('typing');\nconst [messageList, setMessageList] = useState(props.messages);\nconst [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());\n\nuseEffect(() => {\n if (!messages.latest) return;\n setMessageList((prev) => [...prev, messages.latest!]);\n}, [messages.latest]);\n\nuseEffect(() => {\n if (!typing.latest) return;\n setTypingUsers((prev) => {\n const next = new Set(prev);\n if (typing.latest!.active) next.add(typing.latest!.sender);\n else next.delete(typing.latest!.sender);\n return next;\n });\n}, [typing.latest]);\n```\n\nDo NOT treat all events the same. Do NOT skip the local-state sync \u2014 `useStream` alone won't trigger DOM updates of your derived view.",
668
+ promptText: "## Realtime: mixed\nMultiple stream channels with DIFFERENT semantics. Each `streamKinds` entry in the contract maps to one of: merge, append, status, presence. Subscribe + sync to local state PER CHANNEL \u2014 subscribing alone never re-renders the DOM.\n\nExample (chat-interface \u2014 message=append, typing=presence):\n\n```tsx\nconst messages = useStream<MessageT>('message');\nconst typing = useStream<TypingT>('typing');\nconst [messageList, setMessageList] = useState(props.messages);\nconst [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());\n\nuseEffect(() => setMessageList(props.messages), [props.messages]); // re-seed on ggui_amend repaint\n\nuseEffect(() => {\n if (!messages.latest) return;\n setMessageList((prev) => [...prev, messages.latest!]);\n}, [messages.latest]);\n\nuseEffect(() => {\n if (!typing.latest) return;\n setTypingUsers((prev) => {\n const next = new Set(prev);\n if (typing.latest!.active) next.add(typing.latest!.sender);\n else next.delete(typing.latest!.sender);\n return next;\n });\n}, [typing.latest]);\n```\n\nDo NOT treat all events the same. Do NOT skip the local-state sync \u2014 `useStream` alone won't trigger DOM updates of your derived view.",
667
669
  boilerplateMarker: [
668
670
  "",
669
671
  " // \u2500\u2500 Mixed stream handlers \u2500\u2500",
@@ -883,6 +885,15 @@ function jsonSchemaTypeToTs(schema) {
883
885
  if (schema.enum?.length) {
884
886
  return schema.enum.map((v) => typeof v === "string" ? `'${v}'` : String(v)).join(" | ");
885
887
  }
888
+ if (Array.isArray(schema.type)) {
889
+ const { nullable: _nullable, ...rest } = schema;
890
+ const members = [...new Set(schema.type)].map(
891
+ (member) => jsonSchemaTypeToTs({ ...rest, type: member })
892
+ );
893
+ const unique = [...new Set(members)];
894
+ if (schema.nullable && !unique.includes("null")) unique.push("null");
895
+ return unique.join(" | ");
896
+ }
886
897
  let result;
887
898
  switch (schema.type) {
888
899
  case "string":
@@ -900,7 +911,8 @@ function jsonSchemaTypeToTs(schema) {
900
911
  case "array": {
901
912
  if (schema.items) {
902
913
  const itemType = jsonSchemaTypeToTs(schema.items);
903
- result = schema.items.type === "object" && schema.items.properties ? `Array<${itemType}>` : itemType.includes("|") ? `(${itemType})[]` : `${itemType}[]`;
914
+ const itemsDeclareObject = Array.isArray(schema.items.type) ? schema.items.type.includes("object") : schema.items.type === "object";
915
+ result = itemsDeclareObject && schema.items.properties ? `Array<${itemType}>` : itemType.includes("|") ? `(${itemType})[]` : `${itemType}[]`;
904
916
  } else {
905
917
  result = "unknown[]";
906
918
  }
@@ -1033,13 +1045,14 @@ function generateBoilerplate(_userPrompt, contract, shellType, screen, composedS
1033
1045
  const propsFields = [];
1034
1046
  const propsProperties = contract?.propsSpec?.properties ?? {};
1035
1047
  for (const [key, entry] of Object.entries(propsProperties)) {
1036
- const required = entry.required !== false;
1048
+ const required = entry.required === true;
1037
1049
  const nullable = entry.schema.nullable === true;
1038
1050
  const tsType = jsonSchemaTypeToTs(entry.schema);
1039
1051
  const fullType = nullable ? `${tsType} | null` : tsType;
1040
1052
  const parts = [];
1041
1053
  if (entry.description) parts.push(entry.description);
1042
1054
  if (entry.default !== void 0) parts.push(`(default: ${JSON.stringify(entry.default)})`);
1055
+ if (!required) parts.push("(optional \u2014 the agent may omit it; guard or fall back)");
1043
1056
  const desc = parts.length > 0 ? ` // ${parts.join(" ")}` : "";
1044
1057
  propsFields.push(` ${key}${required ? "" : "?"}: ${fullType};${desc}`);
1045
1058
  }
@@ -1785,13 +1798,15 @@ A \`DataContract\` declares everything a GguiSession exchanges with the outside
1785
1798
 
1786
1799
  ## Defensive coding for absent / late-arriving data
1787
1800
 
1788
- Props arrive via \`ggui_update\` and may be partial on first render. 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.**
1801
+ 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.**
1789
1802
 
1803
+ - **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.
1790
1804
  - **Array iteration**: always default to \`[]\` before \`.map\`/\`.filter\`/\`.length\`. Use \`(props.items ?? []).map(...)\` not \`props.items.map(...)\`. Same for stream.all, stream.latest, etc.
1791
1805
  - **Object access**: optional-chain through nested fields. \`props.user?.name ?? 'Anonymous'\` not \`props.user.name\`.
1792
1806
  - **Number ops**: default before arithmetic. \`(props.count ?? 0) + 1\` not \`props.count + 1\`.
1793
1807
  - **Stream latest**: \`useStream\` returns \`{latest: T | null, all: T[], isComplete: boolean}\`. The default \`all\` is \`[]\` so it's safe to map; \`latest\` is null until the first frame arrives \u2014 guard before reading \`.foo\`.
1794
1808
  - **Stream reconciliation**: when a stream event carries an \`action\` discriminant (e.g. \`create | move | edit | delete\`), the channel is a CRUD feed \u2014 your handler MUST branch on EVERY value: append on \`create\`, drop on \`delete\`, replace-by-id on \`move\` / \`edit\`. Merging only the "edit" case silently loses created and deleted items. Reconcile into the SAME state that seeds from \`props\` (e.g. \`useState(() => props.tasks ?? [])\`) so the seed data and the live feed render as one list \u2014 and handle an event whose id is not yet present (a \`create\` for an unknown item) by inserting it, not ignoring it.
1809
+ - **Props re-sync on repaint**: props the agent owns are the source of truth for display \u2014 prefer rendering DIRECTLY from \`props\` (controlled, no local copy). When local optimistic state genuinely needs a prop seed (a click bumps a count before the agent confirms), it MUST re-sync on prop change \u2014 \`useEffect(() => setCount(props.count ?? 0), [props.count])\` \u2014 or a \`ggui_amend\` repaint applies the new props while the display keeps the stale local value. BAD: \`const [count, setCount] = useState(props.count ?? 0)\` alone. GOOD: the same seed + the one-line re-sync effect.
1795
1810
  - **Loading state**: while data is still absent, render \`<Skeleton>\` placeholders \u2014 never a blank screen. \`<Skeleton variant="text" />\` for a text line, \`variant="circle"\` for an avatar slot, default \`rect\` for a block.
1796
1811
  - **Empty state**: when a list or results array is empty, render \`<EmptyState title="\u2026" description="\u2026" />\` \u2014 a region that renders nothing when empty looks broken to the user.
1797
1812
 
@@ -1919,7 +1934,7 @@ Every value change is mirrored to the host LLM's context automatically (debounce
1919
1934
 
1920
1935
  **When NOT to use it.** Local UI state the contract did NOT declare \u2014 \`isDropdownOpen\`, hover flags, animation phase, ephemeral toggles. For those, use a plain \`useState\` directly. The runtime ignores undeclared state.
1921
1936
 
1922
- **\`contextSpec\` direction is one-way: client \u2192 agent.** The agent uses \`propsSpec\` (via \`ggui_update\`) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` \u2014 there is no return path.
1937
+ **\`contextSpec\` direction is one-way: client \u2192 agent.** The agent uses \`propsSpec\` (via \`ggui_amend\` for in-place repaints of this mount, or \`ggui_update\` when it mints a new history card) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` \u2014 there is no return path.
1923
1938
 
1924
1939
  **Schema mismatches drop silently.** If you set a value that doesn't match the slot's schema (e.g. a string into a \`{type: 'number'}\` slot), the runtime logs a dev \`console.warn\` and skips the post. Make sure your setter calls produce values that match the declared shape.
1925
1940
 
@@ -2815,6 +2830,34 @@ function errorSummary(e) {
2815
2830
  }
2816
2831
  return `${e.constructor.name}${status}${code}: ${e.message.slice(0, 120)}${body}`;
2817
2832
  }
2833
+ function isRateLimited(e) {
2834
+ if (!(e instanceof Error) || !("status" in e)) return false;
2835
+ return e.status === 429;
2836
+ }
2837
+ function extractStatus(e) {
2838
+ if (!(e instanceof Error) || !("status" in e)) return void 0;
2839
+ const status = e.status;
2840
+ return typeof status === "number" ? status : void 0;
2841
+ }
2842
+ function extractRetryAfterSec(e) {
2843
+ if (!(e instanceof Error) || !("headers" in e)) return void 0;
2844
+ const headers = e.headers;
2845
+ if (headers === null || typeof headers !== "object" || !("get" in headers) || typeof headers.get !== "function") {
2846
+ return void 0;
2847
+ }
2848
+ const raw = headers.get("retry-after");
2849
+ if (raw === null) return void 0;
2850
+ const parsed = Number(raw);
2851
+ return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : void 0;
2852
+ }
2853
+ function sleep(ms) {
2854
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2855
+ }
2856
+ var MAX_RETRY_ATTEMPTS = 2;
2857
+ var RETRY_AFTER_CAP_SEC = 15;
2858
+ var DEFAULT_BACKOFF_BASE_MS = 1e3;
2859
+ var DEFAULT_BACKOFF_MAX_MS = 8e3;
2860
+ var MAX_TOTAL_RETRY_DELAY_MS = 2e4;
2818
2861
  var LLMAgent = class {
2819
2862
  client = null;
2820
2863
  // ── Session state for server-side chaining ──────────────
@@ -2822,6 +2865,19 @@ var LLMAgent = class {
2822
2865
  // Enables turn 2+ to skip re-sending system prompt + history.
2823
2866
  // Call resetSession() between independent generation runs.
2824
2867
  lastSessionId;
2868
+ /**
2869
+ * Explicit per-call routing override — see `AgentConfig.routeOverride`.
2870
+ * Absent when constructed via the bare `new XAgent()` form (every
2871
+ * existing call site), so `createClient()`/`resolveModel()` fall
2872
+ * back to `process.env` exactly as before.
2873
+ */
2874
+ routeOverride;
2875
+ /** See `AgentConfig.onRetry` (#489). Absent is a no-op. */
2876
+ onRetry;
2877
+ constructor(routeOverride, onRetry) {
2878
+ this.routeOverride = routeOverride;
2879
+ this.onRetry = onRetry;
2880
+ }
2825
2881
  async getClient() {
2826
2882
  if (!this.client) {
2827
2883
  this.client = await this.createClient();
@@ -2855,36 +2911,104 @@ var LLMAgent = class {
2855
2911
  async sendToolResult(_results) {
2856
2912
  }
2857
2913
  /**
2858
- * Execute an API call. No retry — if it fails, it fails.
2859
- * Logs the error with provider context and re-throws.
2914
+ * Execute an API call, retrying once or twice on HTTP 429
2915
+ * (rate-limited) before giving up (#489).
2916
+ *
2917
+ * Policy: on a 429, honor the provider's `Retry-After` header when
2918
+ * present (capped at `RETRY_AFTER_CAP_SEC` — a provider asking for a
2919
+ * longer wait is treated as "don't retry", not "wait longer": making
2920
+ * the caller sit through 15+ more seconds on a single failed call is
2921
+ * a bad experience regardless of what's driving the request);
2922
+ * otherwise fall back to exponential backoff with jitter, capped at
2923
+ * `DEFAULT_BACKOFF_MAX_MS` per attempt. Stops after
2924
+ * `MAX_RETRY_ATTEMPTS` retries OR once the cumulative delay would
2925
+ * exceed `MAX_TOTAL_RETRY_DELAY_MS`, whichever comes first. Any
2926
+ * non-429 error, or a 429 with no attempts left, is logged and
2927
+ * re-thrown immediately — unchanged from the pre-#489 behavior.
2928
+ *
2929
+ * **Budget scope (final-review correction):** `MAX_TOTAL_RETRY_DELAY_MS`
2930
+ * bounds a single `apiCall()` invocation, not a whole generation. A
2931
+ * generation issues many sequential `apiCall()` calls (coding turns,
2932
+ * tool-result round-trips, eval rounds); under sustained throttling
2933
+ * each one can independently add up to `MAX_TOTAL_RETRY_DELAY_MS`, so
2934
+ * a generation with k rate-limited calls can add up to roughly `k *
2935
+ * MAX_TOTAL_RETRY_DELAY_MS` to how long it holds its caller's
2936
+ * resources (e.g. the caller's concurrency slot) — not a flat 20s
2937
+ * ceiling. No shared per-generation budget is threaded here by
2938
+ * design (accepted ruling, not an oversight): the caller's own
2939
+ * queue/admission backstop is what bounds how long other
2940
+ * queued work waits, independent of any one generation's retry
2941
+ * total.
2860
2942
  */
2861
2943
  async apiCall(fn) {
2862
2944
  const start = Date.now();
2863
- try {
2864
- return await fn();
2865
- } catch (e) {
2866
- const ms = Date.now() - start;
2867
- console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
2868
- throw e;
2945
+ let totalDelayMs = 0;
2946
+ for (let attempt = 0; ; attempt++) {
2947
+ try {
2948
+ return await fn();
2949
+ } catch (e) {
2950
+ const ms = Date.now() - start;
2951
+ if (attempt >= MAX_RETRY_ATTEMPTS || !isRateLimited(e)) {
2952
+ console.error(`[${this.provider}] API error after ${ms}ms: ${errorSummary(e)}`);
2953
+ throw e;
2954
+ }
2955
+ const retryAfterSec = extractRetryAfterSec(e);
2956
+ if (retryAfterSec !== void 0 && retryAfterSec > RETRY_AFTER_CAP_SEC) {
2957
+ console.error(
2958
+ `[${this.provider}] API error after ${ms}ms (retry-after ${retryAfterSec}s exceeds ${RETRY_AFTER_CAP_SEC}s cap, not retrying): ${errorSummary(e)}`
2959
+ );
2960
+ throw e;
2961
+ }
2962
+ const delayMs = retryAfterSec !== void 0 ? retryAfterSec * 1e3 : Math.min(DEFAULT_BACKOFF_BASE_MS * 2 ** attempt, DEFAULT_BACKOFF_MAX_MS) + Math.floor(Math.random() * 250);
2963
+ if (totalDelayMs + delayMs > MAX_TOTAL_RETRY_DELAY_MS) {
2964
+ console.error(
2965
+ `[${this.provider}] API error after ${ms}ms (retry budget exhausted, not retrying): ${errorSummary(e)}`
2966
+ );
2967
+ throw e;
2968
+ }
2969
+ totalDelayMs += delayMs;
2970
+ const info = {
2971
+ provider: this.provider,
2972
+ attempt: attempt + 1,
2973
+ maxAttempts: MAX_RETRY_ATTEMPTS,
2974
+ status: extractStatus(e),
2975
+ retryAfterSec,
2976
+ delayMs,
2977
+ message: errorSummary(e)
2978
+ };
2979
+ console.warn(
2980
+ `[${this.provider}] rate-limited (attempt ${info.attempt}/${info.maxAttempts}), retrying in ${delayMs}ms`
2981
+ );
2982
+ try {
2983
+ this.onRetry?.(info);
2984
+ } catch (observerError) {
2985
+ console.warn(
2986
+ `[${this.provider}] onRetry observer threw (ignored, retry proceeds): ${errorSummary(observerError)}`
2987
+ );
2988
+ }
2989
+ await sleep(delayMs);
2990
+ }
2869
2991
  }
2870
2992
  }
2871
2993
  };
2872
2994
  var AnthropicAgent = class extends LLMAgent {
2873
2995
  provider = "anthropic";
2874
2996
  resolveModel(model) {
2875
- if (process.env.CLAUDE_CODE_USE_BEDROCK === "1") {
2997
+ const useBedrock = this.routeOverride?.useBedrock ?? process.env.CLAUDE_CODE_USE_BEDROCK === "1";
2998
+ if (useBedrock) {
2876
2999
  return getBedrockModelId(model);
2877
3000
  }
2878
3001
  return model.startsWith("anthropic/") ? model.slice("anthropic/".length) : model;
2879
3002
  }
2880
3003
  async createClient() {
2881
- if (process.env.CLAUDE_CODE_USE_BEDROCK === "1") {
3004
+ const useBedrock = this.routeOverride?.useBedrock ?? process.env.CLAUDE_CODE_USE_BEDROCK === "1";
3005
+ if (useBedrock) {
2882
3006
  const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk');
2883
3007
  return new AnthropicBedrock({
2884
3008
  awsRegion: process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1"
2885
3009
  });
2886
3010
  }
2887
- return createAnthropicClient(process.env.ANTHROPIC_API_KEY);
3011
+ return createAnthropicClient(this.routeOverride?.apiKey ?? process.env.ANTHROPIC_API_KEY);
2888
3012
  }
2889
3013
  async callText(model, systemPrompt, userPrompt, maxTokens, temperature) {
2890
3014
  const client = await this.getClient();
@@ -2956,6 +3080,73 @@ var AnthropicAgent = class extends LLMAgent {
2956
3080
  throw e;
2957
3081
  }
2958
3082
  }
3083
+ async callVision(model, systemPrompt, userPrompt, image, maxTokens) {
3084
+ const client = await this.getClient();
3085
+ const traceId = newLlmTraceId();
3086
+ const startedAt = Date.now();
3087
+ const resolvedModel = this.resolveModel(model);
3088
+ const imageBlock = {
3089
+ type: "image",
3090
+ source: {
3091
+ type: "base64",
3092
+ media_type: image.mediaType,
3093
+ data: image.base64
3094
+ }
3095
+ };
3096
+ try {
3097
+ const response = await this.apiCall(
3098
+ () => client.messages.create({
3099
+ model: resolvedModel,
3100
+ max_tokens: maxTokens ?? 1500,
3101
+ system: systemPrompt,
3102
+ messages: [
3103
+ {
3104
+ role: "user",
3105
+ content: [imageBlock, { type: "text", text: userPrompt }]
3106
+ }
3107
+ ]
3108
+ })
3109
+ );
3110
+ const text = response.content.filter((block) => block.type === "text").map((block) => block.text).join("");
3111
+ const endedAt = Date.now();
3112
+ emitLlmTraceEvent({
3113
+ id: traceId,
3114
+ at: startedAt,
3115
+ endedAt,
3116
+ durationMs: endedAt - startedAt,
3117
+ provider: "anthropic",
3118
+ model: resolvedModel,
3119
+ kind: "callVision",
3120
+ systemPrompt,
3121
+ userPrompt,
3122
+ result: {
3123
+ inputTokens: response.usage.input_tokens,
3124
+ outputTokens: response.usage.output_tokens,
3125
+ text
3126
+ }
3127
+ });
3128
+ return {
3129
+ text,
3130
+ inputTokens: response.usage.input_tokens,
3131
+ outputTokens: response.usage.output_tokens
3132
+ };
3133
+ } catch (e) {
3134
+ const endedAt = Date.now();
3135
+ emitLlmTraceEvent({
3136
+ id: traceId,
3137
+ at: startedAt,
3138
+ endedAt,
3139
+ durationMs: endedAt - startedAt,
3140
+ provider: "anthropic",
3141
+ model: resolvedModel,
3142
+ kind: "callVision",
3143
+ systemPrompt,
3144
+ userPrompt,
3145
+ error: { message: e instanceof Error ? e.message : String(e) }
3146
+ });
3147
+ throw e;
3148
+ }
3149
+ }
2959
3150
  async callTools(model, systemPrompt, userPrompt, tools, toolChoice = "required", _scopedTools) {
2960
3151
  const client = await this.getClient();
2961
3152
  const traceId = newLlmTraceId();
@@ -3155,7 +3346,7 @@ var OpenAIAgent = class extends LLMAgent {
3155
3346
  }
3156
3347
  async createClient() {
3157
3348
  const { default: OpenAISDK } = await import('openai');
3158
- return new OpenAISDK({ apiKey: process.env.OPENAI_API_KEY });
3349
+ return new OpenAISDK({ apiKey: this.routeOverride?.apiKey ?? process.env.OPENAI_API_KEY });
3159
3350
  }
3160
3351
  async callText(model, systemPrompt, userPrompt, maxTokens, temperature) {
3161
3352
  const client = await this.getClient();
@@ -3326,7 +3517,7 @@ var GoogleAgent = class extends LLMAgent {
3326
3517
  async createClient() {
3327
3518
  const { GoogleGenAI: GoogleGenAISDK } = await import('@google/genai');
3328
3519
  return new GoogleGenAISDK({
3329
- apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
3520
+ apiKey: this.routeOverride?.apiKey ?? (process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY),
3330
3521
  httpOptions: { timeout: 3e5 }
3331
3522
  // 5 min — Pro models can be slow
3332
3523
  });
@@ -3351,6 +3542,72 @@ var GoogleAgent = class extends LLMAgent {
3351
3542
  outputTokens: interaction.usage?.total_output_tokens ?? 0
3352
3543
  };
3353
3544
  }
3545
+ async callVision(model, systemPrompt, userPrompt, image, maxTokens) {
3546
+ const client = await this.getClient();
3547
+ const traceId = newLlmTraceId();
3548
+ const startedAt = Date.now();
3549
+ const resolvedModel = this.resolveModel(model);
3550
+ try {
3551
+ const response = await this.apiCall(
3552
+ () => client.models.generateContent({
3553
+ model: resolvedModel,
3554
+ contents: [
3555
+ {
3556
+ role: "user",
3557
+ parts: [
3558
+ { inlineData: { mimeType: image.mediaType, data: image.base64 } },
3559
+ { text: userPrompt }
3560
+ ]
3561
+ }
3562
+ ],
3563
+ config: {
3564
+ systemInstruction: systemPrompt,
3565
+ ...maxTokens ? { maxOutputTokens: maxTokens } : {}
3566
+ }
3567
+ })
3568
+ );
3569
+ const text = response.candidates?.[0]?.content?.parts?.map((p) => p.text || "").join("") || "";
3570
+ const usage = response.usageMetadata;
3571
+ const result = {
3572
+ text,
3573
+ inputTokens: usage?.promptTokenCount ?? 0,
3574
+ outputTokens: usage?.candidatesTokenCount ?? 0
3575
+ };
3576
+ const endedAt = Date.now();
3577
+ emitLlmTraceEvent({
3578
+ id: traceId,
3579
+ at: startedAt,
3580
+ endedAt,
3581
+ durationMs: endedAt - startedAt,
3582
+ provider: "google",
3583
+ model: resolvedModel,
3584
+ kind: "callVision",
3585
+ systemPrompt,
3586
+ userPrompt,
3587
+ result: {
3588
+ inputTokens: result.inputTokens,
3589
+ outputTokens: result.outputTokens,
3590
+ text
3591
+ }
3592
+ });
3593
+ return result;
3594
+ } catch (e) {
3595
+ const endedAt = Date.now();
3596
+ emitLlmTraceEvent({
3597
+ id: traceId,
3598
+ at: startedAt,
3599
+ endedAt,
3600
+ durationMs: endedAt - startedAt,
3601
+ provider: "google",
3602
+ model: resolvedModel,
3603
+ kind: "callVision",
3604
+ systemPrompt,
3605
+ userPrompt,
3606
+ error: { message: e instanceof Error ? e.message : String(e) }
3607
+ });
3608
+ throw e;
3609
+ }
3610
+ }
3354
3611
  async callTools(model, systemPrompt, userPrompt, tools, _toolChoice = "required", scopedTools) {
3355
3612
  const client = await this.getClient();
3356
3613
  const interactionTools = tools.map((t) => ({
@@ -3553,7 +3810,7 @@ var OpenRouterAgent = class extends LLMAgent {
3553
3810
  async createClient() {
3554
3811
  const { OpenRouterClient: OpenRouterClient2 } = await Promise.resolve().then(() => (init_client(), client_exports));
3555
3812
  return new OpenRouterClient2({
3556
- apiKey: process.env.OPENROUTER_API_KEY
3813
+ apiKey: this.routeOverride?.apiKey ?? process.env.OPENROUTER_API_KEY
3557
3814
  });
3558
3815
  }
3559
3816
  async callText(model, systemPrompt, userPrompt, maxTokens, temperature) {
@@ -3679,21 +3936,24 @@ var OpenRouterAgent = class extends LLMAgent {
3679
3936
  return { text: finalText, inputTokens: totalInput, outputTokens: totalOutput, turnsUsed };
3680
3937
  }
3681
3938
  };
3682
- function createAgent(provider) {
3939
+ function createAgent(providerOrConfig) {
3940
+ const provider = typeof providerOrConfig === "string" ? providerOrConfig : providerOrConfig.provider;
3941
+ const routeOverride = typeof providerOrConfig === "string" ? void 0 : providerOrConfig.routeOverride;
3942
+ const onRetry = typeof providerOrConfig === "string" ? void 0 : providerOrConfig.onRetry;
3683
3943
  switch (provider) {
3684
3944
  case "anthropic":
3685
- return new AnthropicAgent();
3945
+ return new AnthropicAgent(routeOverride, onRetry);
3686
3946
  case "openai":
3687
- return new OpenAIAgent();
3947
+ return new OpenAIAgent(routeOverride, onRetry);
3688
3948
  case "google":
3689
- return new GoogleAgent();
3949
+ return new GoogleAgent(routeOverride, onRetry);
3690
3950
  case "openrouter":
3691
- return new OpenRouterAgent();
3951
+ return new OpenRouterAgent(routeOverride, onRetry);
3692
3952
  }
3693
3953
  }
3694
3954
  async function callLLM(config, systemPrompt, userPrompt, maxTokens) {
3695
3955
  const start = Date.now();
3696
- const agent = createAgent(config.provider);
3956
+ const agent = createAgent(config);
3697
3957
  const result = await agent.callText(
3698
3958
  config.model,
3699
3959
  systemPrompt,