@ggui-ai/negotiator 0.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +49 -0
- package/dist/contract-hash.d.ts +54 -0
- package/dist/contract-hash.d.ts.map +1 -0
- package/dist/contract-hash.js +96 -0
- package/dist/contract-validators.d.ts +171 -0
- package/dist/contract-validators.d.ts.map +1 -0
- package/dist/contract-validators.js +478 -0
- package/dist/decision-input.d.ts +48 -0
- package/dist/decision-input.d.ts.map +1 -0
- package/dist/decision-input.js +14 -0
- package/dist/decision.d.ts +54 -0
- package/dist/decision.d.ts.map +1 -0
- package/dist/decision.js +500 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +25 -0
- package/dist/intent.d.ts +22 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +28 -0
- package/dist/llm-caller.d.ts +70 -0
- package/dist/llm-caller.d.ts.map +1 -0
- package/dist/llm-caller.js +38 -0
- package/dist/llm-rerank.d.ts +101 -0
- package/dist/llm-rerank.d.ts.map +1 -0
- package/dist/llm-rerank.js +178 -0
- package/dist/negotiate.d.ts +141 -0
- package/dist/negotiate.d.ts.map +1 -0
- package/dist/negotiate.js +161 -0
- package/dist/normalize-schema.d.ts +22 -0
- package/dist/normalize-schema.d.ts.map +1 -0
- package/dist/normalize-schema.js +191 -0
- package/dist/pure.d.ts +30 -0
- package/dist/pure.d.ts.map +1 -0
- package/dist/pure.js +43 -0
- package/dist/rag-search.d.ts +73 -0
- package/dist/rag-search.d.ts.map +1 -0
- package/dist/rag-search.js +192 -0
- package/dist/rerank-eval/pairs.d.ts +28 -0
- package/dist/rerank-eval/pairs.d.ts.map +1 -0
- package/dist/rerank-eval/pairs.js +531 -0
- package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
- package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe-cli.js +146 -0
- package/dist/rerank-eval/run-probe.d.ts +68 -0
- package/dist/rerank-eval/run-probe.d.ts.map +1 -0
- package/dist/rerank-eval/run-probe.js +113 -0
- package/dist/session.d.ts +42 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +21 -0
- package/dist/suggestion.d.ts +38 -0
- package/dist/suggestion.d.ts.map +1 -0
- package/dist/suggestion.js +47 -0
- package/dist/synth-bench/corpus.d.ts +106 -0
- package/dist/synth-bench/corpus.d.ts.map +1 -0
- package/dist/synth-bench/corpus.js +994 -0
- package/dist/synth-bench/run-bench-cli.d.ts +3 -0
- package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
- package/dist/synth-bench/run-bench-cli.js +181 -0
- package/dist/synth-bench/run-bench.d.ts +101 -0
- package/dist/synth-bench/run-bench.d.ts.map +1 -0
- package/dist/synth-bench/run-bench.js +374 -0
- package/dist/synthesize-contract.d.ts +131 -0
- package/dist/synthesize-contract.d.ts.map +1 -0
- package/dist/synthesize-contract.js +948 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/package.json +74 -0
- package/src/contract-hash.ts +102 -0
- package/src/contract-validators.ts +604 -0
- package/src/decision-input.ts +49 -0
- package/src/decision.ts +581 -0
- package/src/index.ts +63 -0
- package/src/intent.ts +37 -0
- package/src/llm-caller.ts +82 -0
- package/src/llm-rerank.ts +280 -0
- package/src/negotiate.ts +312 -0
- package/src/normalize-schema.ts +193 -0
- package/src/pure.ts +46 -0
- package/src/rag-search.ts +274 -0
- package/src/rerank-eval/pairs.ts +624 -0
- package/src/rerank-eval/run-probe-cli.ts +197 -0
- package/src/rerank-eval/run-probe.ts +198 -0
- package/src/session.ts +41 -0
- package/src/suggestion.ts +73 -0
- package/src/synth-bench/corpus.ts +1126 -0
- package/src/synth-bench/run-bench-cli.ts +237 -0
- package/src/synth-bench/run-bench.ts +525 -0
- package/src/synthesize-contract.ts +1161 -0
- package/src/types.ts +31 -0
|
@@ -0,0 +1,948 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract synthesis.
|
|
3
|
+
*
|
|
4
|
+
* When an agent calls `ggui_handshake({story: {intent}})` without
|
|
5
|
+
* authoring a `contract`, the negotiator's cold path used to stamp an
|
|
6
|
+
* empty stub on `plan.contract`. The stub survived the handshake →
|
|
7
|
+
* push hop but failed downstream: the generator emitted
|
|
8
|
+
* `useAction(...)` / `useGguiContext(...)` calls that didn't match
|
|
9
|
+
* any declared `actionSpec` / `contextSpec`, and the validator
|
|
10
|
+
* stripped them, leaving dead buttons on the rendered UI.
|
|
11
|
+
*
|
|
12
|
+
* `synthesizeContract` closes that gap. Given an LLM caller and an
|
|
13
|
+
* intent string, it asks the model to infer a plausible
|
|
14
|
+
* `DataContract` from the natural-language ask: which actions a user
|
|
15
|
+
* might fire, which context slots the agent would observe, which
|
|
16
|
+
* stream channels would carry live updates. The resulting contract
|
|
17
|
+
* feeds the negotiator's `plan.contract`, rides through to the
|
|
18
|
+
* paired push, and arrives at the generator with a real wire surface
|
|
19
|
+
* that the validator accepts.
|
|
20
|
+
*
|
|
21
|
+
* **Conservative by design.** The synthesized contract emits only
|
|
22
|
+
* what the LLM is confident about — better to under-declare than to
|
|
23
|
+
* fabricate actions the UI doesn't actually need. Operators who want
|
|
24
|
+
* a richer surface should author the contract themselves on the
|
|
25
|
+
* handshake input; synthesis is a fallback, not a replacement.
|
|
26
|
+
*
|
|
27
|
+
* **Failure modes collapse to null.** LLM throws, parse fails,
|
|
28
|
+
* provider doesn't support `callStructured` → return `null`. Caller
|
|
29
|
+
* falls back to an empty stub; behavior regresses to pre-synth but
|
|
30
|
+
* doesn't crash.
|
|
31
|
+
*
|
|
32
|
+
* **Cost.** ~$0.0005-0.001 per call (Haiku 4.5, ~500 input + ~300
|
|
33
|
+
* output tokens). Latency ~1.5s. Fires only on cold-path Tier 3
|
|
34
|
+
* AND when the agent omitted the contract — most pushes from
|
|
35
|
+
* contract-aware agents skip synthesis entirely.
|
|
36
|
+
*/
|
|
37
|
+
import { dataContractSchema, gadgetExportName, } from '@ggui-ai/protocol';
|
|
38
|
+
import { lintContract } from '@ggui-ai/protocol';
|
|
39
|
+
import { normalizeSchema } from './normalize-schema.js';
|
|
40
|
+
import { formatValidationFindings, validateActionsVsContext, validateContractCoherence, validateContractStructure, } from './contract-validators.js';
|
|
41
|
+
/**
|
|
42
|
+
* Map a protocol-linter {@link ContractIssue} into the negotiator's
|
|
43
|
+
* existing {@link ContractValidationFinding} shape so phase-2/3/4
|
|
44
|
+
* results compose with the structural + placement validators that
|
|
45
|
+
* predate the unified linter. The mapping is intentionally minimal:
|
|
46
|
+
* stable `code` → `kind`, `severity` carries through, `message`
|
|
47
|
+
* becomes `hint`. The negotiator's optional `actionName` / `slotName`
|
|
48
|
+
* / `cosine` fields stay undefined; `hint` carries the full path
|
|
49
|
+
* and message which downstream renderers already accept.
|
|
50
|
+
*/
|
|
51
|
+
function contractIssueToFinding(issue) {
|
|
52
|
+
return {
|
|
53
|
+
kind: issue.code,
|
|
54
|
+
severity: issue.severity,
|
|
55
|
+
hint: `${issue.path}: ${issue.message}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* System prompt for the synthesizer. Teaches the four-spec wire model
|
|
60
|
+
* (propsSpec / streamSpec / contextSpec / actionSpec) and the action-vs-
|
|
61
|
+
* context discrimination rule that separates discrete events from
|
|
62
|
+
* state mutations whose continuous value matters.
|
|
63
|
+
*
|
|
64
|
+
* Default policy: under-declare. A counter widget is contextSpec-only
|
|
65
|
+
* (the slot mirror IS the wire); declaring increment/decrement actions
|
|
66
|
+
* creates a parallel wire path the generator wires up incorrectly.
|
|
67
|
+
* The validator in `contract-validators.ts` flags the redundant-action
|
|
68
|
+
* pattern as a structural smell so prompt regressions are observable.
|
|
69
|
+
*/
|
|
70
|
+
const SYNTHESIZE_SYSTEM_PROMPT = `You are a UI contract inferrer for the ggui generative-UI protocol.
|
|
71
|
+
|
|
72
|
+
A contract has FOUR specs that describe distinct directions on the wire between the rendered UI and the agent. Each spec answers a different question; mixing them up is the most common contract bug.
|
|
73
|
+
|
|
74
|
+
THE FOUR-SPEC MODEL
|
|
75
|
+
|
|
76
|
+
propsSpec (agent → UI, render-time)
|
|
77
|
+
Static initial-render data the agent passes once when the UI is mounted. The UI reads it; it never changes after mount. Use ONLY when the intent names data the component cannot render without (a weather card needs city + temp; a profile needs name + avatar). Omit when the UI generates its own state (a counter starting at zero, a blank notepad).
|
|
78
|
+
|
|
79
|
+
streamSpec (agent → UI, live, append-only)
|
|
80
|
+
Channels where the agent pushes live data the UI displays as it arrives. Use ONLY when the intent describes ongoing agent-originated updates (a chat with messages, a live dashboard, a clock, a stock ticker, a notifications feed). Wrong instinct: do NOT use streamSpec for user-driven state, nor for a multi-step wizard / tutorial — its steps are a local stepper plus component-authored copy, not an agent-pushed feed.
|
|
81
|
+
|
|
82
|
+
contextSpec (UI → agent, live, debounced mirror)
|
|
83
|
+
Client state the agent OBSERVES continuously. The UI mutates each slot via a setter; the runtime mirrors the value back to the agent. Use for any client-side state whose CURRENT VALUE is what the agent cares about — a counter's count, a form's draft fields, a slider's position, a selected tab, a search query as the user types. The mirror is the wire path: the agent already sees every change.
|
|
84
|
+
|
|
85
|
+
actionSpec (UI → agent, one-shot event)
|
|
86
|
+
Discrete events the agent must WITNESS — a single point in time the agent receives a payload describing what happened. Use for events with semantic meaning beyond the current state of any slot: submit, save, send, finalize, navigate, confirm, cancel, search, delete-by-id. The payload carries the data the agent needs to act on the event.
|
|
87
|
+
|
|
88
|
+
THE ACTION-VS-CONTEXT DISCRIMINATION RULE
|
|
89
|
+
|
|
90
|
+
This is the load-bearing decision. Get it wrong and the UI looks right but doesn't work, OR the UI fires events the agent doesn't need (chatty wire, latency, cost).
|
|
91
|
+
|
|
92
|
+
THE PLACEMENT TEST — one question:
|
|
93
|
+
|
|
94
|
+
Does this thing need the agent's next-turn reasoning?
|
|
95
|
+
YES → actionSpec (discrete event; agent reacts on next turn via ggui_consume)
|
|
96
|
+
NO → contextSpec (state; agent observes the latest mirrored value when it next does work)
|
|
97
|
+
|
|
98
|
+
There is no third category. Every action is implicitly turn-driving — that's what makes it an action. If you're tempted to declare an action that "shouldn't drive a turn," it's not an action; it's state, and it belongs on contextSpec.
|
|
99
|
+
|
|
100
|
+
Declare actionSpec[X] IF AND ONLY IF X is a discrete event the agent must witness.
|
|
101
|
+
|
|
102
|
+
For state mutations whose CURRENT VALUE matters (counters, sliders, toggles, draft text, selected items), the slot setter on contextSpec IS the wire — the agent sees every change via the mirror. Adding an action is REDUNDANT and often creates a parallel wire path the generator wires up incorrectly.
|
|
103
|
+
|
|
104
|
+
Mutator verbs that signal "this is a slot setter, not an event":
|
|
105
|
+
increment, decrement, reset, set, add, remove, delete, update, change, toggle, flip, clear, append, prepend, insert
|
|
106
|
+
|
|
107
|
+
Event verbs that signal "this is a discrete event worth declaring":
|
|
108
|
+
submit, save, send, finalize, navigate, confirm, cancel, search, share, publish, subscribe
|
|
109
|
+
|
|
110
|
+
DEFAULT: prefer FEWER actions. Only declare an action when you cannot describe the user gesture by mutating a context slot.
|
|
111
|
+
|
|
112
|
+
CONCRETE PATTERNS
|
|
113
|
+
|
|
114
|
+
Counter widget — "make me a counter"
|
|
115
|
+
The agent cares about the current count. Increment/decrement/reset all mutate that value.
|
|
116
|
+
contextSpec: { count: {schema: {type: "number"}, default: 0} }
|
|
117
|
+
actionSpec: OMIT — buttons mutate the slot via its setter; the agent observes the change.
|
|
118
|
+
|
|
119
|
+
Slider — "a volume slider that goes 0-100"
|
|
120
|
+
The agent cares about the current volume.
|
|
121
|
+
contextSpec: { volume: {schema: {type: "number"}, default: 50} }
|
|
122
|
+
actionSpec: OMIT.
|
|
123
|
+
|
|
124
|
+
Toggle — "a dark-mode switch"
|
|
125
|
+
The agent cares about the current mode.
|
|
126
|
+
contextSpec: { darkMode: {schema: {type: "boolean"}, default: false} }
|
|
127
|
+
actionSpec: OMIT.
|
|
128
|
+
|
|
129
|
+
Notepad — "a notepad I can save"
|
|
130
|
+
Text streams continuously to the agent; save IS a discrete event.
|
|
131
|
+
contextSpec: { noteText: {schema: {type: "string"}, default: ""} }
|
|
132
|
+
actionSpec: { save: {label: "Save the note", schema: {type: "object", properties: {}, additionalProperties: false}} }
|
|
133
|
+
|
|
134
|
+
Form — "a feedback form with a rating and a comment"
|
|
135
|
+
Draft fields stream as the user types; submit IS the event.
|
|
136
|
+
contextSpec: { rating: {schema: {type: "number"}, default: 0}, comment: {schema: {type: "string"}, default: ""} }
|
|
137
|
+
actionSpec: { submit: {label: "Submit feedback", schema: {type: "object", properties: {}, additionalProperties: false}} }
|
|
138
|
+
|
|
139
|
+
Multi-step wizard — "a 3-step onboarding wizard", "a checkout flow", "a tutorial that walks through the app features"
|
|
140
|
+
A self-contained stepper. The CURRENT step is client state the agent observes — next / back / skip just mutate that slot (they are its setters, not actions). Completing the wizard (finish / done / submit) IS the one discrete event the agent must witness. The per-step CONTENT — copy, the feature being demoed, form fields — is authored in the generated component code; "walks through", "guided tour", "tutorial", "step through" describe a LOCAL stepper, NOT agent-pushed data, so a wizard has NO streamSpec and NO propsSpec.
|
|
141
|
+
contextSpec: { step: {schema: {type: "number"}, default: 0} } // add a draft slot too when the wizard collects data across steps (onboarding / checkout / survey)
|
|
142
|
+
actionSpec: { finish: {label: "Finish", schema: {type: "object", properties: {}, additionalProperties: false}} }
|
|
143
|
+
|
|
144
|
+
Search — "a search box", "a search box that filters results live as the user types"
|
|
145
|
+
The query is a contextSpec slot — it streams to the agent via the slot mirror as the user types. Results render in the generated component code; "live", "as you type", "filters live" describe that local query slot, NOT an agent-pushed feed — a search box has NO streamSpec.
|
|
146
|
+
contextSpec: { query: {schema: {type: "string"}, default: ""} }
|
|
147
|
+
Whether to ALSO declare a submit-search action depends on whether the agent acts on every keystroke (no action — the mirror IS the wire) or only on enter/click (declare a search action). Default to no action unless the intent names "search button" / "submit on enter". When declaring, pass the query as payload: schema: {type: "object", properties: {query: {type: "string"}}, required: ["query"]}.
|
|
148
|
+
|
|
149
|
+
Todo list — "a todo list", "an agent-backed todo list that persists across sessions"
|
|
150
|
+
The todos array is client state that mutates (add / delete / toggle) — it is ALWAYS a contextSpec slot, NEVER propsSpec. propsSpec is for data that never changes after mount; a todo list's items change constantly.
|
|
151
|
+
contextSpec: { todos: {schema: {type: "array"}, default: []} }
|
|
152
|
+
actionSpec: OMIT by default — a local-only list just lets the agent observe the items slot. Declare addTodo / deleteTodo ONLY when the intent says the agent must act on each add/delete ("agent-backed", "synced", "persists across sessions") — those words mean each mutation IS a discrete event the agent witnesses, layered ON TOP of the contextSpec slot, not instead of it.
|
|
153
|
+
|
|
154
|
+
Confirmation modal — "a delete-confirmation modal with confirm and cancel actions"
|
|
155
|
+
Confirm and cancel are the discrete events the agent must witness; the modal holds no client state and no live feed. Declare propsSpec ONLY when the intent NAMES the item / data the modal shows ("confirm deleting <the file name>"); a generic confirmation modal that names no data field has NO propsSpec.
|
|
156
|
+
actionSpec: { confirm: {label: "Confirm", schema: {type: "object", properties: {}, additionalProperties: false}}, cancel: {label: "Cancel", schema: {type: "object", properties: {}, additionalProperties: false}} }
|
|
157
|
+
No contextSpec, no streamSpec, no propsSpec.
|
|
158
|
+
|
|
159
|
+
Chat — "a chat with the agent"
|
|
160
|
+
Messages flow agent→UI as a stream; the user's message IS a discrete event with payload.
|
|
161
|
+
streamSpec: { messages: {schema: {type: "object", properties: {role: {type: "string"}, text: {type: "string"}}}} }
|
|
162
|
+
actionSpec: { sendMessage: {label: "Send message", schema: {type: "object", properties: {text: {type: "string"}}, required: ["text"]}} }
|
|
163
|
+
contextSpec: optional { draftText: {schema: {type: "string"}, default: ""} } if the agent should see the user typing live.
|
|
164
|
+
|
|
165
|
+
Weather card — "a weather card for Tokyo"
|
|
166
|
+
Static display, no user input. The intent names the data the card
|
|
167
|
+
cannot render without (city, temp) — so it MUST declare propsSpec.
|
|
168
|
+
propsSpec: { properties: {city: {schema: {type: "string"}, required: true}, temp: {schema: {type: "number"}, required: true}} }
|
|
169
|
+
No actionSpec, no contextSpec, no streamSpec.
|
|
170
|
+
|
|
171
|
+
Status / display panel — "a deployment status panel showing service name and current state", "an order summary card"
|
|
172
|
+
A panel that DISPLAYS named fields the agent supplies at render time. The intent names the data (service name, state, total) → that data is propsSpec, passed once. "status", "state", "current", "deployment", "summary", "panel" describe a SNAPSHOT — they are NOT streamSpec triggers. Reach for streamSpec ONLY when the intent explicitly says the data is "live", "streaming", "real-time", "updates as …", or "refreshes".
|
|
173
|
+
propsSpec: { properties: {serviceName: {schema: {type: "string"}, required: true}, state: {schema: {type: "string"}, required: true}} }
|
|
174
|
+
No actionSpec, no contextSpec, no streamSpec.
|
|
175
|
+
|
|
176
|
+
Live dashboard — "a stock ticker"
|
|
177
|
+
Agent pushes; user watches.
|
|
178
|
+
streamSpec: { ticks: {schema: {type: "object", properties: {symbol: {type: "string"}, price: {type: "number"}}}} }
|
|
179
|
+
No actionSpec.
|
|
180
|
+
|
|
181
|
+
Source-fed live stream — "a live-refreshing AAPL quote"
|
|
182
|
+
The runtime polls / subscribes a named agent-side tool and delivers updates on the channel. Declare the source inline AND the matching agentCapabilities catalog entry; the runtime negotiates transport (WebSocket subscribe vs iframe polling) — your contract doesn't choose. The channel's "schema" and the source tool's "outputSchema" describe the SAME payload — give them the IDENTICAL shape (same root "type", same properties). One tool call returns one delivery; if the tool returns an array of rows, the channel "schema" is that same array.
|
|
183
|
+
streamSpec: { ticker: {schema: {type: "object", properties: {price: {type: "number"}}}, source: {tool: "fetch_quote", args: {symbol: "AAPL"}}} }
|
|
184
|
+
agentCapabilities: { tools: { fetch_quote: {inputSchema: {type: "object"}, outputSchema: {type: "object", properties: {price: {type: "number"}}, required: ["price"]}, usage: "Fetches the latest quote for a symbol."} } }
|
|
185
|
+
No actionSpec.
|
|
186
|
+
|
|
187
|
+
Capability — "show my current location on a map"
|
|
188
|
+
Geolocation is a UI-owned browser capability; the captured value lands on a contextSpec slot the map renders from. NO action for "request location" — the capability hook owns its own lifecycle.
|
|
189
|
+
contextSpec: { location: {schema: {type: "object", properties: {latitude: {type: "number"}, longitude: {type: "number"}}}, default: {latitude: 0, longitude: 0}} }
|
|
190
|
+
clientCapabilities: { gadgets: { "@ggui-ai/gadgets": { useGeolocation: {} } } }
|
|
191
|
+
|
|
192
|
+
Capability — "let me record a voice memo and send it"
|
|
193
|
+
Mic capture is a UI lifecycle; sending IS a discrete event.
|
|
194
|
+
contextSpec: { recording: {schema: {type: "object"}, default: {}} }
|
|
195
|
+
actionSpec: { send: {label: "Send memo", schema: {type: "object", properties: {audio: {type: "string"}}, required: ["audio"]}} }
|
|
196
|
+
clientCapabilities: { gadgets: { "@ggui-ai/gadgets": { useMicrophone: {} } } }
|
|
197
|
+
|
|
198
|
+
Capability — "copy this code to my clipboard"
|
|
199
|
+
Clipboard write is component-only mechanic. The agent observes only the act ("user copied"), not the write itself.
|
|
200
|
+
actionSpec: { copy: {label: "Copy", schema: {type: "object", properties: {}, additionalProperties: false}} }
|
|
201
|
+
clientCapabilities: { gadgets: { "@ggui-ai/gadgets": { useClipboardWrite: {} } } }
|
|
202
|
+
|
|
203
|
+
Component gadget — "show last quarter's revenue as a bar chart"
|
|
204
|
+
A registered chart is a COMPONENT gadget — its export name is PascalCase. The contract declares only its identity; the generated component code RENDERS it as JSX (<RevenueChart … />) — it is never CALLED like a hook. The exact package + component name come from the AVAILABLE GADGETS list on the user prompt; only declare a gadget the operator actually registered.
|
|
205
|
+
propsSpec: { properties: {revenue: {schema: {type: "array"}, required: true}} }
|
|
206
|
+
clientCapabilities: { gadgets: { "@acme/charts": { "RevenueChart": {} } } }
|
|
207
|
+
|
|
208
|
+
PROPSSPEC + CONTEXTSPEC SHAPING — collections with stable identity
|
|
209
|
+
|
|
210
|
+
When a propsSpec or contextSpec field holds a collection of identifiable items (todos, messages, users, cart entries — anything with stable per-item ids), prefer the keyed-map shape:
|
|
211
|
+
|
|
212
|
+
GOOD (keyed-map + ordering index):
|
|
213
|
+
propsSpec: { properties: {
|
|
214
|
+
todosById: {schema: {type: "object", additionalProperties: {type: "object", properties: {id: {type: "string"}, text: {type: "string"}, done: {type: "boolean"}}, required: ["id", "text", "done"]}}, required: true},
|
|
215
|
+
todoIds: {schema: {type: "array", items: {type: "string"}}, required: true}
|
|
216
|
+
}}
|
|
217
|
+
|
|
218
|
+
ACCEPTABLE (array — order encoded by position):
|
|
219
|
+
propsSpec: { properties: {
|
|
220
|
+
todos: {schema: {type: "array", items: {type: "object", properties: {id: {type: "string"}, text: {type: "string"}, done: {type: "boolean"}}, required: ["id", "text", "done"]}}, required: true}
|
|
221
|
+
}}
|
|
222
|
+
|
|
223
|
+
Why keyed-map: \`ggui_update\` has two modes. \`kind:"replace"\` sends the FULL props every refresh. \`kind:"merge"\` (RFC 7396) sends ONLY a delta. RFC 7396 fully replaces arrays — there is no element-wise array merge. So under an array shape, flipping one todo's \`done\` bit still re-sends the whole array. Under the keyed-map shape, the same change is \`{todosById: {abc: {done: true}}}\` — a true delta, far smaller for the agent to construct on every domain-tool follow-up.
|
|
224
|
+
|
|
225
|
+
When to default to array shape: small fixed-position collections (form fields in a known order, chart axis labels), collections where order semantics matter more than identity (a queue), or collections that are nearly always fully replaced anyway. When to skip keyed-map: items have no natural id, OR the consumer code is simpler reading an array than \`Object.values\` + index lookups.
|
|
226
|
+
|
|
227
|
+
This is a soft preference — the cross-ref linter does NOT enforce shape. State your shaping choice in the "reason" field so it's observable.
|
|
228
|
+
|
|
229
|
+
THE TWO REFERENCE CATALOGS
|
|
230
|
+
|
|
231
|
+
The contract declares two read-only catalogs alongside the four inbound/outbound specs:
|
|
232
|
+
|
|
233
|
+
agentCapabilities (catalog only — NOT a component hook)
|
|
234
|
+
Tools the AGENT invokes. The synthesizer references them from exactly ONE place:
|
|
235
|
+
streamSpec[X].source.tool → "the runtime polls / subscribes this tool to feed the channel"
|
|
236
|
+
Declare an agentCapabilities.tools entry ONLY to back a source-fed streamSpec channel. Do NOT wire actions to tools — the synthesizer runs on the cold path and does not know the agent's toolbox; the agent reacts to an action event on its next turn via ggui_consume. The component code NEVER calls agentCapabilities entries directly. There is no useWiredTool hook.
|
|
237
|
+
|
|
238
|
+
clientCapabilities (registered gadgets the component imports)
|
|
239
|
+
Gadgets the COMPONENT imports — browser-capability HOOKS (e.g., useGeolocation, useCamera, useClipboardWrite, useMicrophone, useFilePicker, useClipboardPaste, useNotifications) AND operator-registered COMPONENT gadgets (charts, maps, rich-text editors — PascalCase exports the component renders as JSX). The wire map "gadgets" is PACKAGE-KEYED two-level: clientCapabilities.gadgets[<packageName>][<exportName>] = {}. The npm package name keys the outer map; the export name keys the inner map (a "use"-prefixed key is a hook the component CALLS, a PascalCase key is a component the component RENDERS as JSX). The wire carries identity only — no "version", no "permission".
|
|
240
|
+
Gadget values reach the agent ONLY when the component code threads them into a contextSpec slot or an actionSpec payload.
|
|
241
|
+
|
|
242
|
+
ANTI-PATTERNS — DO NOT EMIT
|
|
243
|
+
|
|
244
|
+
Several retired field/hook names appear in older training data. The cross-ref linter rejects them at push:
|
|
245
|
+
- wiredTools / agentTools / clientTools (retired catalog names; use agentCapabilities.tools / clientCapabilities.gadgets)
|
|
246
|
+
- clientCapabilities.capabilities (retired inner key; use clientCapabilities.gadgets)
|
|
247
|
+
- useWiredTool(...) / useClientTool(...) / useAgentTool(...) (retired hooks; the contract layer doesn't reference these at all)
|
|
248
|
+
- "@ggui-ai/client-tools" as a package import (retired package; use @ggui-ai/gadgets)
|
|
249
|
+
- PushStory / story.* on handshake input (retired wire shape; handshake input is flat — {sessionId, intent, contract?, hint?, forceCreate?})
|
|
250
|
+
- story.adapters / declaredAdapters (retired adapter gate; permissions are a registry-side descriptor field — the wire clientCapabilities.gadgets map carries identity only)
|
|
251
|
+
- dispatch: {kind: 'tool', tool: ...} (retired discriminated union on actionSpec entries — actions carry NO tool wiring)
|
|
252
|
+
- dispatch: {kind: 'agent', intendedTool: ...} (retired — same; do not wire actions to tools)
|
|
253
|
+
- actionSpec[X].nextStep (the synthesizer does NOT emit nextStep — the cold path has no agent toolbox to point at; the agent reacts to the action on its next turn)
|
|
254
|
+
- mode: 'host-routed' (retired; all actions are agent-routed)
|
|
255
|
+
- interaction: 'display' | 'collect' | 'converse' | 'broadcast' | 'flow' (retired top-level field; the four specs ARE the model — there is no interaction-mode enum)
|
|
256
|
+
- broadcast: { ... } (retired top-level field; use streamSpec[X].source)
|
|
257
|
+
- props: { properties: ... } as a CONTRACT field (retired contract-side spelling; the contract field is propsSpec — note the wire field on push/update is still "props" carrying VALUES)
|
|
258
|
+
|
|
259
|
+
OUTPUT RULES
|
|
260
|
+
|
|
261
|
+
1. Output exactly ONE tool call carrying the synthesized contract.
|
|
262
|
+
|
|
263
|
+
2. All four specs are optional. Omit any spec the intent does not justify — under-declaration is the safe default; the agent can author a richer contract on the next push if needed.
|
|
264
|
+
|
|
265
|
+
3. For actionSpec entries, "label" is a short imperative phrase ("Submit the form"). For payload-carrying actions (send, search, delete-by-id), declare the fields under {type: "object", properties: {…}, required: […]}. For payload-less event actions (submit, save, cancel, confirm, finalize), use {type: "object", properties: {}, additionalProperties: false}.
|
|
266
|
+
|
|
267
|
+
4. For contextSpec entries, "schema" matches the slot's data shape ({type: "number"} for a count, {type: "string"} for text, {type: "array"} for a list) and "default" is a sensible initial value.
|
|
268
|
+
|
|
269
|
+
5. For streamSpec entries, "schema" describes the payload shape per delivery. When a channel declares a "source", its "schema" MUST be the same shape as the source tool's agentCapabilities outputSchema — a mismatch is rejected at push (CTR_SCHEMA_INCOMPAT).
|
|
270
|
+
|
|
271
|
+
6. Every "schema" you emit — on ANY spec — must be valid JSON Schema. The "type" field is exactly ONE of these seven strings: "string", "number", "integer", "boolean", "array", "object", "null". Never invent a type ("list", "feed", "enum", "any") and never emit "type" as an array of strings. A field limited to a fixed set of values keeps a real base type and lists the values separately: {type: "string", enum: ["waiting", "playing", "done"]} — NOT {type: "enum"}. A nullable field is just its base type: {type: "string"} — NOT {type: ["string", "null"]}. An invalid schema makes the whole contract fail validation and the synthesizer declines.
|
|
272
|
+
|
|
273
|
+
7. Declare agentCapabilities.tools entries ONLY to back a source-fed streamSpec channel — i.e. when streamSpec[X].source.tool names the tool. Every source.tool reference MUST resolve to a catalog entry on the same contract — the cross-ref linter rejects dangling references at push. Catalog entries declare {inputSchema?, outputSchema?, usage?}; the agent owns the call. Do NOT declare agentCapabilities for any other reason.
|
|
274
|
+
|
|
275
|
+
8. Declare clientCapabilities entries ONLY when the intent names a gadget the UI imports — a browser capability (camera, mic, geolocation, clipboard, file picker, notifications) OR an operator-registered gadget shown in the AVAILABLE GADGETS list on the user prompt (a chart, a map, a rich-text editor). The v1 stdlib catalog from @ggui-ai/gadgets ships these hooks: useGeolocation, useClipboardWrite, useClipboardPaste, useNotifications, useFilePicker, useMicrophone, useCamera — declare them under clientCapabilities.gadgets["@ggui-ai/gadgets"][<hookName>] = {}. Operator-registered gadgets (hooks OR PascalCase components) declare under their own package key — clientCapabilities.gadgets[<package>][<exportName>] = {} — using the exact package + export names from the AVAILABLE GADGETS list. Package name keys the outer map, export name keys the inner map.
|
|
276
|
+
|
|
277
|
+
9. The "reason" field is a short operator-facing explanation for why these specs were chosen. Mention the discrimination rule explicitly when it bears: "counter is contextSpec-only because the slot mirror IS the wire; no action needed."`;
|
|
278
|
+
/**
|
|
279
|
+
* Tool schema the synthesizer's structured-output call uses. The
|
|
280
|
+
* shape mirrors `DataContract` but stays loose at the ToolSchema
|
|
281
|
+
* layer — Anthropic's tool-use adapter doesn't enforce nested
|
|
282
|
+
* required-field rules deeply, and we re-validate on the return path.
|
|
283
|
+
*/
|
|
284
|
+
export const SYNTHESIZE_TOOL = {
|
|
285
|
+
name: 'submit_inferred_contract',
|
|
286
|
+
description: 'Submit your inferred contract. Include only the four-spec fields the intent explicitly justifies; omit the rest.',
|
|
287
|
+
input_schema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
additionalProperties: false,
|
|
290
|
+
properties: {
|
|
291
|
+
actionSpec: {
|
|
292
|
+
type: 'object',
|
|
293
|
+
additionalProperties: {
|
|
294
|
+
type: 'object',
|
|
295
|
+
properties: {
|
|
296
|
+
label: { type: 'string' },
|
|
297
|
+
schema: { type: 'object' },
|
|
298
|
+
},
|
|
299
|
+
required: ['label'],
|
|
300
|
+
},
|
|
301
|
+
description: 'Map of action name → {label, schema?}. Each action becomes a useAction(name) hook the generator can wire to UI elements.',
|
|
302
|
+
},
|
|
303
|
+
contextSpec: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
additionalProperties: {
|
|
306
|
+
type: 'object',
|
|
307
|
+
properties: {
|
|
308
|
+
schema: { type: 'object' },
|
|
309
|
+
},
|
|
310
|
+
required: ['schema'],
|
|
311
|
+
},
|
|
312
|
+
description: 'Map of slot name → {schema, default?}. Each slot mirrors back to the agent\'s context via a useGguiContext(name) hook.',
|
|
313
|
+
},
|
|
314
|
+
streamSpec: {
|
|
315
|
+
type: 'object',
|
|
316
|
+
additionalProperties: {
|
|
317
|
+
type: 'object',
|
|
318
|
+
properties: {
|
|
319
|
+
schema: { type: 'object' },
|
|
320
|
+
source: {
|
|
321
|
+
type: 'object',
|
|
322
|
+
description: 'Optional source declaration when the channel is fed by a polling / subscribing agentCapabilities.tools entry. Declare for live-refreshing data sources (ticker, polling table, dashboard). The runtime negotiates transport (WebSocket subscribe vs iframe polling).',
|
|
323
|
+
properties: {
|
|
324
|
+
tool: {
|
|
325
|
+
type: 'string',
|
|
326
|
+
description: 'agentCapabilities.tools[*] key — MUST be declared on the same contract.',
|
|
327
|
+
},
|
|
328
|
+
args: {
|
|
329
|
+
type: 'object',
|
|
330
|
+
description: 'Arguments passed to the source tool on each invocation.',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
required: ['tool'],
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
required: ['schema'],
|
|
337
|
+
},
|
|
338
|
+
description: 'Map of channel name → {schema, source?}. Each channel becomes a useStream(name) hook the agent pushes to via ggui_emit OR the runtime feeds from source.tool. Only declare for live-update UIs (chat, dashboard, broadcast, clock, ticker).',
|
|
339
|
+
},
|
|
340
|
+
agentCapabilities: {
|
|
341
|
+
type: 'object',
|
|
342
|
+
properties: {
|
|
343
|
+
tools: {
|
|
344
|
+
type: 'object',
|
|
345
|
+
additionalProperties: {
|
|
346
|
+
type: 'object',
|
|
347
|
+
properties: {
|
|
348
|
+
inputSchema: { type: 'object' },
|
|
349
|
+
outputSchema: { type: 'object' },
|
|
350
|
+
usage: { type: 'string' },
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
description: 'Per-tool map: name → {inputSchema?, outputSchema?, usage?}. Catalog only — the agent owns invocation. Declare entries that are referenced via streamSpec[X].source.tool.',
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
description: 'Catalog of agent-invoked tools the contract references. The component code does NOT call these directly. Required when any streamSpec.source.tool refers to a tool name.',
|
|
357
|
+
},
|
|
358
|
+
clientCapabilities: {
|
|
359
|
+
type: 'object',
|
|
360
|
+
properties: {
|
|
361
|
+
gadgets: {
|
|
362
|
+
type: 'object',
|
|
363
|
+
additionalProperties: {
|
|
364
|
+
type: 'object',
|
|
365
|
+
additionalProperties: {
|
|
366
|
+
type: 'object',
|
|
367
|
+
properties: {
|
|
368
|
+
description: {
|
|
369
|
+
type: 'string',
|
|
370
|
+
description: 'Optional intent-specific override of the registered export description.',
|
|
371
|
+
},
|
|
372
|
+
usage: {
|
|
373
|
+
type: 'string',
|
|
374
|
+
description: 'Optional intent-specific override of the registered usage hint.',
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
description: 'Per-package export map: exportName → {description?, usage?}. The export name is the key — `use`-prefixed for a hook, PascalCase for a component.',
|
|
379
|
+
},
|
|
380
|
+
description: 'Package-keyed two-level map: packageName → exportName → {description?, usage?}. The npm package name keys the outer map; the export name keys the inner map (e.g. {"@ggui-ai/gadgets": {"useGeolocation": {}}}). The component imports the export and threads the result into a contextSpec slot or actionSpec payload.',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
description: 'Catalog of browser-capability gadgets the component code mounts (camera, mic, geolocation, clipboard, file picker, notifications). Declare ONLY when the intent names a browser capability.',
|
|
384
|
+
},
|
|
385
|
+
propsSpec: {
|
|
386
|
+
type: 'object',
|
|
387
|
+
properties: {
|
|
388
|
+
properties: {
|
|
389
|
+
type: 'object',
|
|
390
|
+
additionalProperties: {
|
|
391
|
+
type: 'object',
|
|
392
|
+
properties: {
|
|
393
|
+
schema: { type: 'object' },
|
|
394
|
+
required: { type: 'boolean' },
|
|
395
|
+
},
|
|
396
|
+
required: ['schema'],
|
|
397
|
+
},
|
|
398
|
+
description: 'Per-prop map: name → {schema, required?}. Declares the initial render data the agent passes at push time.',
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
description: 'Static-display data the agent passes at push time. Use ONLY when the intent names initial data fields (weather card → city/temp; profile → name/avatar). Omit when the UI generates its own state.',
|
|
402
|
+
},
|
|
403
|
+
reason: {
|
|
404
|
+
type: 'string',
|
|
405
|
+
description: 'Brief explanation — one sentence — for why these specs were chosen. Operator-facing.',
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
required: ['reason'],
|
|
409
|
+
},
|
|
410
|
+
};
|
|
411
|
+
/**
|
|
412
|
+
* Synthesis attempt budget — 1 initial call + up to 4 feedback-driven
|
|
413
|
+
* repair retries. The retry fires only on the rare path where an
|
|
414
|
+
* attempt fails the validation gate, so the extra calls cost nothing
|
|
415
|
+
* on the common (already-valid) path.
|
|
416
|
+
*/
|
|
417
|
+
const MAX_SYNTH_ATTEMPTS = 5;
|
|
418
|
+
/**
|
|
419
|
+
* Compose the repair note appended to the next attempt's user prompt:
|
|
420
|
+
* the rejected contract plus the precise failure reason. Feeding the
|
|
421
|
+
* model its own output and the validator's verdict lets it correct the
|
|
422
|
+
* exact problem — the synthesizer's self-check feedback loop. The
|
|
423
|
+
* contract JSON is capped so a pathological contract can't blow the
|
|
424
|
+
* prompt budget.
|
|
425
|
+
*/
|
|
426
|
+
function buildRepairNote(rejected, failure) {
|
|
427
|
+
const json = JSON.stringify(rejected);
|
|
428
|
+
const capped = json.length > 3000 ? `${json.slice(0, 3000)}…` : json;
|
|
429
|
+
return [
|
|
430
|
+
'YOUR PREVIOUS ATTEMPT was rejected. You returned this contract:',
|
|
431
|
+
capped,
|
|
432
|
+
'',
|
|
433
|
+
`Reason — ${failure}`,
|
|
434
|
+
'',
|
|
435
|
+
'Emit a corrected contract that fixes exactly this problem. Keep every other spec unchanged.',
|
|
436
|
+
].join('\n');
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Drop `actionSpec` entries the structural validator flags as
|
|
440
|
+
* `redundant-action` — an empty-payload action whose name is a
|
|
441
|
+
* mutator of an existing context slot (e.g. `increment` alongside a
|
|
442
|
+
* `count` slot). By the actions-vs-context placement rule those are
|
|
443
|
+
* not actions at all: the slot setter IS the wire, and a parallel
|
|
444
|
+
* action entry is one the generator wires up incorrectly. Pruning is
|
|
445
|
+
* purely subtractive, so the contract stays schema-valid. Returns the
|
|
446
|
+
* contract unchanged when nothing is redundant.
|
|
447
|
+
*/
|
|
448
|
+
function pruneRedundantActions(contract) {
|
|
449
|
+
const actionSpec = contract.actionSpec;
|
|
450
|
+
if (actionSpec === undefined)
|
|
451
|
+
return contract;
|
|
452
|
+
const redundant = new Set();
|
|
453
|
+
for (const f of validateContractStructure(contract).findings) {
|
|
454
|
+
if (f.kind === 'redundant-action' && typeof f.actionName === 'string') {
|
|
455
|
+
redundant.add(f.actionName);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (redundant.size === 0)
|
|
459
|
+
return contract;
|
|
460
|
+
const kept = {};
|
|
461
|
+
for (const [name, entry] of Object.entries(actionSpec)) {
|
|
462
|
+
if (!redundant.has(name))
|
|
463
|
+
kept[name] = entry;
|
|
464
|
+
}
|
|
465
|
+
const next = { ...contract };
|
|
466
|
+
if (Object.keys(kept).length > 0) {
|
|
467
|
+
next.actionSpec = kept;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
delete next.actionSpec;
|
|
471
|
+
}
|
|
472
|
+
return next;
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Run the synthesizer for a contract-less cold-path handshake.
|
|
476
|
+
*
|
|
477
|
+
* Empty / whitespace intent short-circuits to null with a reason —
|
|
478
|
+
* no contract can be inferred from nothing.
|
|
479
|
+
*
|
|
480
|
+
* Provider lacking `callStructured` (test stubs, providers without
|
|
481
|
+
* tool-use) collapses to null.
|
|
482
|
+
*
|
|
483
|
+
* Each attempt is self-checked against the validation gate; a failure
|
|
484
|
+
* feeds the precise error back for up to {@link MAX_SYNTH_ATTEMPTS}
|
|
485
|
+
* attempts (a transient `callStructured` throw re-runs the same
|
|
486
|
+
* prompt). Only when the budget is exhausted does it collapse to null
|
|
487
|
+
* — the caller then falls back to an empty contract stub.
|
|
488
|
+
*/
|
|
489
|
+
export async function synthesizeContract(deps, intent, options) {
|
|
490
|
+
const startedAt = Date.now();
|
|
491
|
+
const trimmed = intent.trim();
|
|
492
|
+
if (trimmed.length === 0) {
|
|
493
|
+
return {
|
|
494
|
+
contract: null,
|
|
495
|
+
reason: 'synthesize-skip: empty intent',
|
|
496
|
+
latencyMs: Date.now() - startedAt,
|
|
497
|
+
attempts: 0,
|
|
498
|
+
findings: [],
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (typeof deps.llm.callStructured !== 'function') {
|
|
502
|
+
return {
|
|
503
|
+
contract: null,
|
|
504
|
+
reason: 'synthesize-skip: provider does not support callStructured. Bind a structured-capable LLMCaller (Anthropic adapter) to enable contract synthesis.',
|
|
505
|
+
latencyMs: Date.now() - startedAt,
|
|
506
|
+
attempts: 0,
|
|
507
|
+
findings: [],
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const gadgetsSection = composeAvailableGadgetsSection(options?.appGadgets);
|
|
511
|
+
const baseUserPrompt = `INTENT: ${trimmed}${gadgetsSection ? `\n\n${gadgetsSection}` : ''}`;
|
|
512
|
+
// Bounded validate-and-repair loop. Each attempt is self-checked
|
|
513
|
+
// against the gate below (schema parse + structural / placement /
|
|
514
|
+
// linter validators); on failure the precise error AND the rejected
|
|
515
|
+
// contract are fed back into the next call so the model can correct
|
|
516
|
+
// the exact problem. A `callStructured` throw (transient network)
|
|
517
|
+
// re-runs the same prompt. This mirrors the ui-gen self-check
|
|
518
|
+
// harness shape — there is no patch layer because a DataContract is
|
|
519
|
+
// small enough that a full re-emit IS the surgical edit. Budget
|
|
520
|
+
// exhausted → decline exactly as the one-shot path did (caller falls
|
|
521
|
+
// back to an empty contract stub).
|
|
522
|
+
let repairNote;
|
|
523
|
+
let lastReason = 'synthesize-fail: exhausted repair attempts';
|
|
524
|
+
let lastFindings = [];
|
|
525
|
+
for (let attempt = 1; attempt <= MAX_SYNTH_ATTEMPTS; attempt++) {
|
|
526
|
+
const userPrompt = repairNote === undefined
|
|
527
|
+
? baseUserPrompt
|
|
528
|
+
: `${baseUserPrompt}\n\n${repairNote}`;
|
|
529
|
+
let toolInput;
|
|
530
|
+
try {
|
|
531
|
+
toolInput = await deps.llm.callStructured(SYNTHESIZE_SYSTEM_PROMPT, userPrompt, SYNTHESIZE_TOOL, 1024);
|
|
532
|
+
}
|
|
533
|
+
catch (err) {
|
|
534
|
+
// Transient (network) failure — retry the same prompt; the
|
|
535
|
+
// attempt produced nothing to correct, so no repair note.
|
|
536
|
+
lastReason = `synthesize-fail: callStructured threw — ${err instanceof Error ? err.message : String(err)}`;
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
const parsed = parseToolInput(toolInput);
|
|
540
|
+
if (!parsed) {
|
|
541
|
+
lastReason =
|
|
542
|
+
'synthesize-fail: tool input did not match expected shape';
|
|
543
|
+
repairNote =
|
|
544
|
+
'YOUR PREVIOUS ATTEMPT did not produce a usable submit_inferred_contract call. Call the tool exactly once with a well-formed contract.';
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
// `buildContract` normalizes every emitted schema (invalid `type`
|
|
548
|
+
// spellings → canonical JSON Schema) before the gate sees it.
|
|
549
|
+
const contract = buildContract(parsed);
|
|
550
|
+
// Defensive gate: re-validate the assembled contract against the
|
|
551
|
+
// canonical schema. Catches LLM outputs that pass the loose tool
|
|
552
|
+
// input_schema but produce structurally invalid contracts. Without
|
|
553
|
+
// this, a garbage contract would flow through plan.contract → push
|
|
554
|
+
// → registry, polluting the operator surface and downstream
|
|
555
|
+
// validators with shape they can't reason about.
|
|
556
|
+
const validated = dataContractSchema.safeParse(contract);
|
|
557
|
+
if (!validated.success) {
|
|
558
|
+
const errText = validated.error.message.slice(0, 400);
|
|
559
|
+
lastReason = `synthesize-fail: assembled contract failed schema validation — ${errText.slice(0, 200)}`;
|
|
560
|
+
repairNote = buildRepairNote(contract, `it failed JSON Schema validation: ${errText}`);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
// Programmatic safety detectors. The structural validator catches
|
|
564
|
+
// over-specified contracts the schema layer can't reason about;
|
|
565
|
+
// the actions-vs-context validator catches placement-rule
|
|
566
|
+
// violations; the protocol linter catches CTR_REF_* / CTR_DUP_NAME
|
|
567
|
+
// / CTR_RESERVED_NAME / CTR_SCHEMA_INCOMPAT errors + LINT_* warns.
|
|
568
|
+
// Warnings ride along on the reason string; errors trigger a
|
|
569
|
+
// repair retry.
|
|
570
|
+
// Deterministically prune redundant mutator-actions before the
|
|
571
|
+
// validators run — enforces the actions-vs-context placement rule
|
|
572
|
+
// (a counter is contextSpec-only) without depending on the LLM to
|
|
573
|
+
// get it right every draw. Subtractive, so the contract stays
|
|
574
|
+
// schema-valid.
|
|
575
|
+
const validatedContract = pruneRedundantActions(validated.data);
|
|
576
|
+
const structureSafety = validateContractStructure(validatedContract);
|
|
577
|
+
const placementSafety = validateActionsVsContext(validatedContract);
|
|
578
|
+
// Intent-aware coherence — the one validator that reads `trimmed`.
|
|
579
|
+
// Catches the degenerate "actionSpec-only, no data surface"
|
|
580
|
+
// contract; its error finding drives a repair retry.
|
|
581
|
+
const coherenceSafety = validateContractCoherence(validatedContract, trimmed);
|
|
582
|
+
const linterResult = lintContract(validatedContract);
|
|
583
|
+
const allFindings = [
|
|
584
|
+
...structureSafety.findings,
|
|
585
|
+
...placementSafety.findings,
|
|
586
|
+
...coherenceSafety.findings,
|
|
587
|
+
...linterResult.errors.map(contractIssueToFinding),
|
|
588
|
+
...linterResult.warnings.map(contractIssueToFinding),
|
|
589
|
+
];
|
|
590
|
+
const errorFindings = allFindings.filter((f) => f.severity === 'error');
|
|
591
|
+
if (errorFindings.length > 0) {
|
|
592
|
+
const errText = formatValidationFindings({ findings: errorFindings });
|
|
593
|
+
lastReason = `synthesize-fail-validator: ${errText}`;
|
|
594
|
+
lastFindings = allFindings;
|
|
595
|
+
repairNote = buildRepairNote(validatedContract, `it failed contract validation: ${errText}`);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const findingsSuffix = allFindings.length > 0
|
|
599
|
+
? ` — validator: ${formatValidationFindings({ findings: allFindings })}`
|
|
600
|
+
: '';
|
|
601
|
+
const repairedSuffix = attempt > 1 ? ` (repaired on attempt ${attempt})` : '';
|
|
602
|
+
return {
|
|
603
|
+
contract: validatedContract,
|
|
604
|
+
reason: `synthesize-ok${repairedSuffix}: ${parsed.reason}${findingsSuffix}`,
|
|
605
|
+
latencyMs: Date.now() - startedAt,
|
|
606
|
+
attempts: attempt,
|
|
607
|
+
findings: allFindings,
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
return {
|
|
611
|
+
contract: null,
|
|
612
|
+
reason: lastReason,
|
|
613
|
+
latencyMs: Date.now() - startedAt,
|
|
614
|
+
attempts: MAX_SYNTH_ATTEMPTS,
|
|
615
|
+
findings: lastFindings,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
function parseToolInput(raw) {
|
|
619
|
+
if (typeof raw !== 'object' || raw === null)
|
|
620
|
+
return null;
|
|
621
|
+
const obj = raw;
|
|
622
|
+
const reason = typeof obj['reason'] === 'string' ? obj['reason'] : '';
|
|
623
|
+
const actionSpecRaw = obj['actionSpec'];
|
|
624
|
+
const actionSpec = typeof actionSpecRaw === 'object' &&
|
|
625
|
+
actionSpecRaw !== null &&
|
|
626
|
+
!Array.isArray(actionSpecRaw)
|
|
627
|
+
? actionSpecRaw
|
|
628
|
+
: undefined;
|
|
629
|
+
const contextSpecRaw = obj['contextSpec'];
|
|
630
|
+
const contextSpec = typeof contextSpecRaw === 'object' &&
|
|
631
|
+
contextSpecRaw !== null &&
|
|
632
|
+
!Array.isArray(contextSpecRaw)
|
|
633
|
+
? contextSpecRaw
|
|
634
|
+
: undefined;
|
|
635
|
+
const streamSpecRaw = obj['streamSpec'];
|
|
636
|
+
const streamSpec = typeof streamSpecRaw === 'object' &&
|
|
637
|
+
streamSpecRaw !== null &&
|
|
638
|
+
!Array.isArray(streamSpecRaw)
|
|
639
|
+
? streamSpecRaw
|
|
640
|
+
: undefined;
|
|
641
|
+
// propsSpec is wrapper-shaped: {properties: {name: {schema, required?}}}.
|
|
642
|
+
// Extract `properties` defensively — LLMs sometimes flatten the wrapper.
|
|
643
|
+
// The tool-schema field is `propsSpec` (matching the contract field);
|
|
644
|
+
// reading `props` here silently dropped every synthesized propsSpec.
|
|
645
|
+
const propsRaw = obj['propsSpec'];
|
|
646
|
+
let propsProperties;
|
|
647
|
+
if (typeof propsRaw === 'object' && propsRaw !== null && !Array.isArray(propsRaw)) {
|
|
648
|
+
const inner = propsRaw.properties;
|
|
649
|
+
if (typeof inner === 'object' && inner !== null && !Array.isArray(inner)) {
|
|
650
|
+
propsProperties = inner;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
// agentCapabilities is wrapper-shaped: {tools: {name: {inputSchema?, outputSchema?, usage?}}}.
|
|
654
|
+
// Extract `tools` defensively so the synthesized contract surfaces the catalog
|
|
655
|
+
// when the LLM emits it (previously dropped silently — caused empty
|
|
656
|
+
// agentCapabilities even when the LLM authored entries).
|
|
657
|
+
const agentCapsRaw = obj['agentCapabilities'];
|
|
658
|
+
let agentTools;
|
|
659
|
+
if (typeof agentCapsRaw === 'object' && agentCapsRaw !== null && !Array.isArray(agentCapsRaw)) {
|
|
660
|
+
const inner = agentCapsRaw.tools;
|
|
661
|
+
if (typeof inner === 'object' && inner !== null && !Array.isArray(inner)) {
|
|
662
|
+
agentTools = inner;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
// clientCapabilities is wrapper-shaped:
|
|
666
|
+
// {gadgets: {packageName: {exportName: {description?, usage?}}}}.
|
|
667
|
+
// The wire map is PACKAGE-KEYED two-level — the outer key is
|
|
668
|
+
// the npm package name, the inner key is the export name. Extract
|
|
669
|
+
// `gadgets` defensively so the synthesized contract surfaces the
|
|
670
|
+
// browser-capability gadgets the LLM declared.
|
|
671
|
+
const clientCapsRaw = obj['clientCapabilities'];
|
|
672
|
+
let gadgets;
|
|
673
|
+
if (typeof clientCapsRaw === 'object' && clientCapsRaw !== null && !Array.isArray(clientCapsRaw)) {
|
|
674
|
+
const inner = clientCapsRaw.gadgets;
|
|
675
|
+
if (typeof inner === 'object' && inner !== null && !Array.isArray(inner)) {
|
|
676
|
+
gadgets = inner;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
return {
|
|
680
|
+
...(actionSpec ? { actionSpec } : {}),
|
|
681
|
+
...(contextSpec ? { contextSpec } : {}),
|
|
682
|
+
...(streamSpec ? { streamSpec } : {}),
|
|
683
|
+
...(propsProperties ? { propsSpec: { properties: propsProperties } } : {}),
|
|
684
|
+
...(agentTools ? { agentCapabilities: { tools: agentTools } } : {}),
|
|
685
|
+
...(gadgets ? { clientCapabilities: { gadgets: gadgets } } : {}),
|
|
686
|
+
reason,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Reconcile source-fed `streamSpec` channels with their backing tool.
|
|
691
|
+
*
|
|
692
|
+
* When a channel declares `source.tool`, the runtime feeds that tool's
|
|
693
|
+
* output straight onto the channel — the channel's `schema` and the
|
|
694
|
+
* tool's `outputSchema` describe one and the same payload. The LLM
|
|
695
|
+
* occasionally emits them with mismatched root types (channel `object`
|
|
696
|
+
* vs tool `array`), which the cross-ref linter rejects as
|
|
697
|
+
* `CTR_SCHEMA_INCOMPAT`. Sync them deterministically: the tool's
|
|
698
|
+
* `outputSchema` is authoritative (the tool IS the data source); when
|
|
699
|
+
* only the channel carries a shape, propagate it to the tool. Mutates
|
|
700
|
+
* `input` in place — a freshly-parsed object owned by the caller.
|
|
701
|
+
*/
|
|
702
|
+
function reconcileSourceSchemas(input) {
|
|
703
|
+
const streams = input.streamSpec;
|
|
704
|
+
const tools = input.agentCapabilities?.tools;
|
|
705
|
+
if (streams === undefined || tools === undefined)
|
|
706
|
+
return;
|
|
707
|
+
for (const channel of Object.values(streams)) {
|
|
708
|
+
const toolName = channel?.source?.tool;
|
|
709
|
+
if (typeof toolName !== 'string')
|
|
710
|
+
continue;
|
|
711
|
+
const tool = tools[toolName];
|
|
712
|
+
if (tool === undefined || tool === null || typeof tool !== 'object') {
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
if (tool.outputSchema !== undefined && tool.outputSchema !== null) {
|
|
716
|
+
channel.schema = tool.outputSchema;
|
|
717
|
+
}
|
|
718
|
+
else if (channel.schema !== undefined && channel.schema !== null) {
|
|
719
|
+
tool.outputSchema = channel.schema;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
function buildContract(input) {
|
|
724
|
+
// Sync source-fed channels with their backing tool BEFORE building,
|
|
725
|
+
// so the channel schema and the tool outputSchema are emitted from
|
|
726
|
+
// one shared shape — eliminates CTR_SCHEMA_INCOMPAT deterministically.
|
|
727
|
+
reconcileSourceSchemas(input);
|
|
728
|
+
const contract = {};
|
|
729
|
+
if (input.actionSpec && Object.keys(input.actionSpec).length > 0) {
|
|
730
|
+
const built = {};
|
|
731
|
+
for (const [name, entry] of Object.entries(input.actionSpec)) {
|
|
732
|
+
// Honor the synthesizer's declared schema when present — the
|
|
733
|
+
// intent might call for a payload (chip text, query, item id)
|
|
734
|
+
// and the LLM correctly inferred it. Fall back to a payload-
|
|
735
|
+
// less object schema when the LLM didn't supply one (most
|
|
736
|
+
// pure-UI buttons like increment/reset/submit don't carry data).
|
|
737
|
+
// The contract validator requires a schema field on every
|
|
738
|
+
// actionSpec entry so the fallback is the only safe default.
|
|
739
|
+
const supplied = normalizeSchema(entry.schema);
|
|
740
|
+
const schema = isObjectSchema(supplied)
|
|
741
|
+
? supplied
|
|
742
|
+
: { type: 'object', properties: {}, additionalProperties: false };
|
|
743
|
+
built[name] = { label: entry.label, schema };
|
|
744
|
+
}
|
|
745
|
+
contract.actionSpec = built;
|
|
746
|
+
}
|
|
747
|
+
if (input.contextSpec && Object.keys(input.contextSpec).length > 0) {
|
|
748
|
+
const built = {};
|
|
749
|
+
for (const [name, entry] of Object.entries(input.contextSpec)) {
|
|
750
|
+
built[name] = {
|
|
751
|
+
schema: normalizeSchema(entry.schema),
|
|
752
|
+
...(entry.default !== undefined ? { default: entry.default } : {}),
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
contract.contextSpec = built;
|
|
756
|
+
}
|
|
757
|
+
if (input.streamSpec && Object.keys(input.streamSpec).length > 0) {
|
|
758
|
+
const built = {};
|
|
759
|
+
for (const [name, entry] of Object.entries(input.streamSpec)) {
|
|
760
|
+
// Defensive: skip entries without a usable schema. Channel
|
|
761
|
+
// delivery validation requires a schema; emitting `{schema:
|
|
762
|
+
// undefined}` would break runtime fan-out at first delivery.
|
|
763
|
+
if (entry?.schema === undefined || entry.schema === null)
|
|
764
|
+
continue;
|
|
765
|
+
built[name] = {
|
|
766
|
+
schema: normalizeSchema(entry.schema),
|
|
767
|
+
// Thread `source` through — a source-fed channel (ticker,
|
|
768
|
+
// polling table) is wired to an agentCapabilities.tools entry
|
|
769
|
+
// via source.tool; dropping it silently de-wires the channel.
|
|
770
|
+
...(entry.source ? { source: entry.source } : {}),
|
|
771
|
+
};
|
|
772
|
+
}
|
|
773
|
+
if (Object.keys(built).length > 0) {
|
|
774
|
+
contract.streamSpec = built;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
if (input.propsSpec?.properties &&
|
|
778
|
+
Object.keys(input.propsSpec.properties).length > 0) {
|
|
779
|
+
const built = {};
|
|
780
|
+
for (const [name, entry] of Object.entries(input.propsSpec.properties)) {
|
|
781
|
+
if (entry?.schema === undefined || entry.schema === null)
|
|
782
|
+
continue;
|
|
783
|
+
built[name] = {
|
|
784
|
+
schema: normalizeSchema(entry.schema),
|
|
785
|
+
...(entry.required === true ? { required: true } : {}),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
if (Object.keys(built).length > 0) {
|
|
789
|
+
contract.propsSpec = {
|
|
790
|
+
properties: built,
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// agentCapabilities.tools — catalog the LLM authored to back a
|
|
795
|
+
// source-fed streamSpec channel (streamSpec[*].source.tool). The
|
|
796
|
+
// catalog populates from the LLM tool input; the synthesizer does
|
|
797
|
+
// not wire actions to tools (no nextStep on the cold path).
|
|
798
|
+
if (input.agentCapabilities?.tools &&
|
|
799
|
+
Object.keys(input.agentCapabilities.tools).length > 0) {
|
|
800
|
+
const built = {};
|
|
801
|
+
for (const [name, entry] of Object.entries(input.agentCapabilities.tools)) {
|
|
802
|
+
if (!entry || typeof entry !== 'object')
|
|
803
|
+
continue;
|
|
804
|
+
built[name] = {
|
|
805
|
+
...(entry.inputSchema !== undefined
|
|
806
|
+
? { inputSchema: normalizeSchema(entry.inputSchema) }
|
|
807
|
+
: {}),
|
|
808
|
+
...(entry.outputSchema !== undefined
|
|
809
|
+
? { outputSchema: normalizeSchema(entry.outputSchema) }
|
|
810
|
+
: {}),
|
|
811
|
+
...(typeof entry.usage === 'string' ? { usage: entry.usage } : {}),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
if (Object.keys(built).length > 0) {
|
|
815
|
+
contract.agentCapabilities = {
|
|
816
|
+
tools: built,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// clientCapabilities.gadgets — browser-capability gadgets the
|
|
821
|
+
// component imports. The wire map is PACKAGE-KEYED two-level
|
|
822
|
+
// (`Record<packageName, Record<exportName, GadgetExportUse>>`). The
|
|
823
|
+
// outer key is the npm package, the inner key is the export name
|
|
824
|
+
// (its grammar discriminates kind — `use`-prefixed hook or
|
|
825
|
+
// PascalCase component); the only wire-authored payload per export
|
|
826
|
+
// is optional `description` / `usage` override prose.
|
|
827
|
+
if (input.clientCapabilities?.gadgets &&
|
|
828
|
+
Object.keys(input.clientCapabilities.gadgets).length > 0) {
|
|
829
|
+
const built = {};
|
|
830
|
+
for (const [pkgName, packageUse] of Object.entries(input.clientCapabilities.gadgets)) {
|
|
831
|
+
if (!packageUse ||
|
|
832
|
+
typeof packageUse !== 'object' ||
|
|
833
|
+
Array.isArray(packageUse)) {
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const exportsBuilt = {};
|
|
837
|
+
for (const [exportName, use] of Object.entries(packageUse)) {
|
|
838
|
+
if (!use || typeof use !== 'object' || Array.isArray(use))
|
|
839
|
+
continue;
|
|
840
|
+
exportsBuilt[exportName] = {
|
|
841
|
+
...(typeof use.description === 'string'
|
|
842
|
+
? { description: use.description }
|
|
843
|
+
: {}),
|
|
844
|
+
...(typeof use.usage === 'string' ? { usage: use.usage } : {}),
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
if (Object.keys(exportsBuilt).length > 0) {
|
|
848
|
+
built[pkgName] = exportsBuilt;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
if (Object.keys(built).length > 0) {
|
|
852
|
+
contract.clientCapabilities = {
|
|
853
|
+
gadgets: built,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return contract;
|
|
858
|
+
}
|
|
859
|
+
/** Narrow check that the LLM-supplied schema is an object-shaped JSON
|
|
860
|
+
* Schema we can pass through. Garbage falls back to the payload-less
|
|
861
|
+
* default — defensive against LLMs that return strings or arrays
|
|
862
|
+
* under `schema`. */
|
|
863
|
+
function isObjectSchema(value) {
|
|
864
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
const t = value.type;
|
|
868
|
+
return t === 'object';
|
|
869
|
+
}
|
|
870
|
+
/** Per-export budget (chars) for the synth-prompt teaching section.
|
|
871
|
+
* Description always preserved; usage truncated last when over
|
|
872
|
+
* budget. Keeps prompt bloat bounded across large registries. */
|
|
873
|
+
const SYNTH_PER_LIBRARY_BUDGET = 300;
|
|
874
|
+
/** Total cap for the AVAILABLE GADGETS section. Excess entries
|
|
875
|
+
* silently truncated to keep the prompt tractable. */
|
|
876
|
+
const SYNTH_TOTAL_BUDGET = 3_000;
|
|
877
|
+
/**
|
|
878
|
+
* Compose the "AVAILABLE GADGETS" section appended to synth's user
|
|
879
|
+
* prompt (and the decision-engine user message — both paths share
|
|
880
|
+
* this one composer). Flattens the package-keyed
|
|
881
|
+
* {@link GadgetDescriptor} catalog into one line per export:
|
|
882
|
+
*
|
|
883
|
+
* - hook `useGeolocation` (package `@ggui-ai/gadgets`) — <desc> (usage: <usage>)
|
|
884
|
+
* - component `Chart` (package `@acme/charts`) — <desc> (usage: <usage>)
|
|
885
|
+
*
|
|
886
|
+
* The leading `hook` / `component` tag teaches the LLM the two render
|
|
887
|
+
* idioms (a hook is CALLED, a component is RENDERED as JSX); the
|
|
888
|
+
* `(package …)` tag carries the npm package name the LLM needs to
|
|
889
|
+
* author the package-keyed
|
|
890
|
+
* `clientCapabilities.gadgets[<package>][<export>]` wire entry.
|
|
891
|
+
*
|
|
892
|
+
* Budget enforcement: per-export text capped at
|
|
893
|
+
* {@link SYNTH_PER_LIBRARY_BUDGET}, total section capped at
|
|
894
|
+
* {@link SYNTH_TOTAL_BUDGET}.
|
|
895
|
+
*
|
|
896
|
+
* Returns `undefined` when the catalog is empty / every export lacks
|
|
897
|
+
* teaching text — the caller then omits the section entirely
|
|
898
|
+
* (preserves the no-registry prompt verbatim for cache hit).
|
|
899
|
+
*
|
|
900
|
+
* Pure helper; exported for the prompt-builder unit test.
|
|
901
|
+
*/
|
|
902
|
+
export function composeAvailableGadgetsSection(gadgets) {
|
|
903
|
+
if (!gadgets || gadgets.length === 0)
|
|
904
|
+
return undefined;
|
|
905
|
+
const lines = [];
|
|
906
|
+
let total = 0;
|
|
907
|
+
outer: for (const descriptor of gadgets) {
|
|
908
|
+
for (const exp of descriptor.exports) {
|
|
909
|
+
// Field-presence discrimination — `{hook}` vs `{component}`. The
|
|
910
|
+
// export-name grammar is itself kind-disjoint, but the explicit
|
|
911
|
+
// tag spares the LLM having to re-derive it.
|
|
912
|
+
const kind = 'hook' in exp ? 'hook' : 'component';
|
|
913
|
+
const name = gadgetExportName(exp);
|
|
914
|
+
const desc = (exp.description ?? '').trim();
|
|
915
|
+
const usage = (exp.usage ?? '').trim();
|
|
916
|
+
if (desc.length === 0 && usage.length === 0)
|
|
917
|
+
continue;
|
|
918
|
+
let entry = `- ${kind} \`${name}\` (package \`${descriptor.package}\`) — ${desc || '(no description)'}`;
|
|
919
|
+
if (usage.length > 0) {
|
|
920
|
+
// Reserve budget for description first; truncate usage if needed.
|
|
921
|
+
const remaining = SYNTH_PER_LIBRARY_BUDGET - entry.length;
|
|
922
|
+
if (remaining > 16) {
|
|
923
|
+
const tail = usage.length <= remaining - 9
|
|
924
|
+
? usage
|
|
925
|
+
: `${usage.slice(0, remaining - 12)}…`;
|
|
926
|
+
entry += ` (usage: ${tail})`;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
if (entry.length > SYNTH_PER_LIBRARY_BUDGET) {
|
|
930
|
+
entry = `${entry.slice(0, SYNTH_PER_LIBRARY_BUDGET - 1)}…`;
|
|
931
|
+
}
|
|
932
|
+
if (total + entry.length + 1 > SYNTH_TOTAL_BUDGET)
|
|
933
|
+
break outer;
|
|
934
|
+
lines.push(entry);
|
|
935
|
+
total += entry.length + 1;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
if (lines.length === 0)
|
|
939
|
+
return undefined;
|
|
940
|
+
// Surface the two render idioms so synth-emitted contracts match what
|
|
941
|
+
// the code-gen boilerplate direct-imports from each gadget package.
|
|
942
|
+
// Hooks implement `GadgetHook<TOutput, TOptions>` (lifecycle envelope
|
|
943
|
+
// of `{ status, value, error, start, stop }`) and are CALLED;
|
|
944
|
+
// components are RENDERED as JSX. Values reach the agent only via
|
|
945
|
+
// `contextSpec` / `actionSpec` threading.
|
|
946
|
+
const protocolHint = 'Hooks (`use`-prefixed) are CALLED — `import { useGeolocation } from \'<package>\';` then `useGeolocation()`; each returns a `GadgetHook<TOutput, TOptions>` envelope `{ status, value, error, start, stop }`. Components (PascalCase) are RENDERED as JSX — `import { Chart } from \'<package>\';` then `<Chart … />`. UI code direct-imports each export from its gadget package; gadget values reach the agent only when threaded into a contextSpec slot or actionSpec payload.';
|
|
947
|
+
return `AVAILABLE GADGETS (declare under clientCapabilities.gadgets[<package>][<export>] when the intent justifies):\n${lines.join('\n')}\n\n${protocolHint}`;
|
|
948
|
+
}
|