@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
package/src/decision.ts
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision Engine — one LLM call, one UI decision.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the V2 brainstorm/option-picker pattern with a single
|
|
5
|
+
* opinionated decision: `create` / `update` / `compose` / `replace`.
|
|
6
|
+
* The LLM sees the agent's data, the current session stack, any
|
|
7
|
+
* `blueprintCandidates` from `ragSearch`, and returns a
|
|
8
|
+
* {@link NegotiatorDecision} with a full {@link DataContract} payload.
|
|
9
|
+
*
|
|
10
|
+
* Contract shape — the returned `contract` always includes an
|
|
11
|
+
* `intent` (semantic identity — same intent = cached component).
|
|
12
|
+
* Other fields are populated opportunistically; `agentCapabilities` is
|
|
13
|
+
* always populated deterministically from `input.agentTools` after
|
|
14
|
+
* the LLM returns (see {@link mergeAgentCapabilities}). The
|
|
15
|
+
* `clientCapabilities.gadgets` catalog is similarly enriched from
|
|
16
|
+
* the per-app gadget list (see {@link mergeGadgets}).
|
|
17
|
+
*
|
|
18
|
+
* Structured output — prefers `llmCaller.callStructured?` with a
|
|
19
|
+
* forced-tool-use schema (guaranteed JSON). Falls back to text + regex
|
|
20
|
+
* JSON extraction if the caller doesn't support structured output, or
|
|
21
|
+
* if the structured call throws. On total parse failure, emits a
|
|
22
|
+
* fallback decision with `action: 'create'` built from the agent's
|
|
23
|
+
* data shape.
|
|
24
|
+
*
|
|
25
|
+
* ## Public surface + semver weight
|
|
26
|
+
*
|
|
27
|
+
* Exported:
|
|
28
|
+
* - `DECISION_SYSTEM_PROMPT` — the system prompt constant. Its
|
|
29
|
+
* content is part of the behavioral contract: changing the prompt
|
|
30
|
+
* changes what the model emits and therefore changes the cache
|
|
31
|
+
* identity of downstream generated blueprints. Treat content
|
|
32
|
+
* changes like `CRITERIA` ordering: CHANGELOG-worthy.
|
|
33
|
+
* - `buildDecisionUserMessage(input)` — pure string builder. Stable
|
|
34
|
+
* output for stable input.
|
|
35
|
+
* - `makeDecision(input, llmCaller)` — runtime orchestrator.
|
|
36
|
+
*
|
|
37
|
+
* Intentionally **not** exported:
|
|
38
|
+
* - `DECISION_TOOL` — the OpenAI-style tool schema handed to
|
|
39
|
+
* `callStructured`. Pinning its shape in the public API would
|
|
40
|
+
* freeze the tool-schema format consumers never need to see.
|
|
41
|
+
* - `mergeAgentCapabilities`, `mergeGadgets`, `buildFallbackDecision`,
|
|
42
|
+
* `inferType` — engine-internal helpers.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import type {
|
|
46
|
+
AgentCapabilitiesSpec,
|
|
47
|
+
GadgetDescriptor,
|
|
48
|
+
GadgetExport,
|
|
49
|
+
GadgetExportUse,
|
|
50
|
+
GadgetPackageUse,
|
|
51
|
+
DataContract,
|
|
52
|
+
JsonValue,
|
|
53
|
+
NegotiatorAlternative,
|
|
54
|
+
NegotiatorDecision,
|
|
55
|
+
} from '@ggui-ai/protocol';
|
|
56
|
+
import { gadgetExportName, gadgetIdentityKey } from '@ggui-ai/protocol';
|
|
57
|
+
import type { NegotiatorDecisionInput } from './decision-input.js';
|
|
58
|
+
import type { LLMCaller } from './llm-caller.js';
|
|
59
|
+
import { composeAvailableGadgetsSection } from './synthesize-contract.js';
|
|
60
|
+
|
|
61
|
+
export const DECISION_SYSTEM_PROMPT = `You are a UI strategist for ggui, a generative UI platform. Given the agent's data, current session state, and blueprint candidates, decide the best way to show this information.
|
|
62
|
+
|
|
63
|
+
Respond with a JSON object:
|
|
64
|
+
{
|
|
65
|
+
"action": "create" | "update" | "compose" | "replace",
|
|
66
|
+
"reasoning": "1-2 sentences explaining why",
|
|
67
|
+
"blueprintId": "matched blueprint ID or null",
|
|
68
|
+
"targetStackItemId": "existing page to update/compose/replace, or null",
|
|
69
|
+
"contract": {
|
|
70
|
+
"intent": "Concise purpose — e.g. 'Display current weather conditions for a quick daily check'",
|
|
71
|
+
"propsSpec": {
|
|
72
|
+
"properties": {
|
|
73
|
+
"fieldName": {
|
|
74
|
+
"description": "what this field is",
|
|
75
|
+
"schema": { "type": "string" },
|
|
76
|
+
"required": true,
|
|
77
|
+
"example": "sample value"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
"adaptations": {
|
|
83
|
+
"fontSize": "compact" | "default" | "large",
|
|
84
|
+
"density": "dense" | "default" | "spacious",
|
|
85
|
+
"complexity": "simplified" | "default" | "detailed"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
INTENT RULES (most important):
|
|
90
|
+
- The "intent" field captures WHY this UI exists in one sentence.
|
|
91
|
+
- Include: the user's goal (why), what data is shown (what), and how they interact (how).
|
|
92
|
+
- Be abstract enough to match reusable patterns — "Display current weather conditions" not "Display Tokyo weather at 3pm".
|
|
93
|
+
- Same intent = same component can be reused with different data.
|
|
94
|
+
- Examples:
|
|
95
|
+
- "Display current weather conditions for a quick daily check"
|
|
96
|
+
- "Collect user feedback via a multi-field survey form"
|
|
97
|
+
- "Show real-time stock prices with live updates"
|
|
98
|
+
- "Compare two products side by side for purchase decision"
|
|
99
|
+
|
|
100
|
+
DECISION RULES:
|
|
101
|
+
- "create": No existing UI or blueprint matches this intent. Show something new.
|
|
102
|
+
- "update": An existing UI on the stack has the same intent. Update its props.
|
|
103
|
+
- "compose": An existing UI could incorporate this data as a section.
|
|
104
|
+
- "replace": Two or more related UIs would be better as a single unified view.
|
|
105
|
+
|
|
106
|
+
REUSE BIAS (critical for performance):
|
|
107
|
+
- Reusing a blueprint = INSTANT render (cached code, <1 second).
|
|
108
|
+
- Creating new = 20+ seconds of generation. The user waits.
|
|
109
|
+
- Default to reuse. Only "create" when NO candidate can reasonably serve the request.
|
|
110
|
+
- A candidate that shows the SAME KIND of data (e.g., weather, stock prices, user profiles) is a match — even if the specific data differs (Tokyo vs Seoul, AAPL vs GOOG).
|
|
111
|
+
- Ask yourself: "Can this candidate display the agent's data with different prop values?" If yes → reuse it.
|
|
112
|
+
- When reusing a blueprint, copy its contract EXACTLY as-is (including its intent). Do NOT rephrase the intent.
|
|
113
|
+
|
|
114
|
+
CONTRACT RULES:
|
|
115
|
+
- Always include an "intent" field — it's required.
|
|
116
|
+
- If reusing a blueprint: use that blueprint's contract verbatim. Do not modify intent or propsSpec.
|
|
117
|
+
- If no blueprint match: infer propsSpec.properties from the agent's data shape. Each key becomes a prop.
|
|
118
|
+
- Use the data values as examples.
|
|
119
|
+
|
|
120
|
+
ACTION SPEC — declare interactive affordances WHENEVER you see them in the data:
|
|
121
|
+
- The discrimination is local-state vs persistent-state, NOT "did the agent declare agentTools".
|
|
122
|
+
- LOCAL STATE (counter value, theme toggle, slider position, picker selection, search-as-you-type, form draft fields): contextSpec only. NO actionSpec. The slot mirror IS the wire — the agent observes context via its next ggui_consume.
|
|
123
|
+
- PERSISTENT STATE (items with identity / IDs + mutable fields, draft submissions awaiting save, deletions of agent-owned rows): actionSpec required. Each gesture is a discrete event the agent must witness.
|
|
124
|
+
- Inference signals for persistent state:
|
|
125
|
+
• Items with \`id\`/\`itemId\`/\`uuid\` fields + boolean toggle fields like \`done\`/\`completed\`/\`checked\`/\`pinned\`/\`enabled\` → toggle action (e.g. \`toggleTodo\`, \`togglePin\`).
|
|
126
|
+
• Lists where the data shape implies the agent maintains identity → add/delete actions.
|
|
127
|
+
• Form data with mutable fields + a submit gesture → submit action.
|
|
128
|
+
• A click that would cause a server-side side-effect (publish, archive, send, delete-from-database) → action.
|
|
129
|
+
- nextStep is OPTIONAL on actionSpec entries:
|
|
130
|
+
• Bind \`nextStep: "tool_name"\` ONLY when input.agentTools contains a matching tool (exact or close name match — \`todo_toggle\` matches the \`toggleTodo\` action).
|
|
131
|
+
• If no agentTools match, OMIT nextStep. Events drain via ggui_consume on the agent's next turn — the agent's reasoning loop sees the event and decides what to do.
|
|
132
|
+
- Examples:
|
|
133
|
+
• agentTools=["todo_toggle","todo_delete"] + todo data → actionSpec: { "toggleTodo": { label: "Toggle", schema: {type:"object",properties:{id:{type:"string"}},required:["id"]}, nextStep: "todo_toggle" }, "deleteTodo": {...nextStep: "todo_delete"} }
|
|
134
|
+
• agentTools=[] + todo data → SAME actionSpec entries WITHOUT nextStep. The agent's next turn reads the event and reacts.
|
|
135
|
+
• agentTools=[] + counter prompt → contextSpec.count only. NO actionSpec.
|
|
136
|
+
|
|
137
|
+
AGENT CAPABILITIES (catalog):
|
|
138
|
+
- You do NOT need to emit contract.agentCapabilities — it is populated deterministically from input.agentTools after you return.
|
|
139
|
+
- Focus on actionSpec entries + their optional nextStep bindings; the catalog auto-populates.
|
|
140
|
+
|
|
141
|
+
ANTI-PATTERNS — DO NOT EMIT (cross-ref linter rejects at push):
|
|
142
|
+
- "props" / "props.properties" as a CONTRACT field (retired contract-side spelling — the contract field is propsSpec; the wire field on push/update is still "props" but carries VALUES, not the spec)
|
|
143
|
+
- "wiredTools" / "agentTools" / "clientTools" catalog names (retired; use agentCapabilities.tools / clientCapabilities.gadgets)
|
|
144
|
+
- clientCapabilities.capabilities (retired inner key; use clientCapabilities.gadgets)
|
|
145
|
+
- ActionEntry.tool / ActionEntry.dispatch.kind (retired discriminated union; use the flat nextStep field)
|
|
146
|
+
- mode: 'host-routed' / mode: 'agent-routed' (retired; all actions are agent-routed)
|
|
147
|
+
- broadcast: { ... } as a top-level field (retired; use streamSpec[X].source instead)`;
|
|
148
|
+
|
|
149
|
+
export function buildDecisionUserMessage(input: NegotiatorDecisionInput): string {
|
|
150
|
+
const parts: string[] = [];
|
|
151
|
+
|
|
152
|
+
// Agent's data
|
|
153
|
+
if (input.agentData) {
|
|
154
|
+
parts.push(`Agent's data:\n${JSON.stringify(input.agentData, null, 2)}`);
|
|
155
|
+
}
|
|
156
|
+
if (input.agentPrompt) {
|
|
157
|
+
parts.push(`Agent's hint: "${input.agentPrompt}"`);
|
|
158
|
+
}
|
|
159
|
+
if (input.agentContext) {
|
|
160
|
+
const ctx =
|
|
161
|
+
typeof input.agentContext === 'string'
|
|
162
|
+
? input.agentContext
|
|
163
|
+
: JSON.stringify(input.agentContext);
|
|
164
|
+
parts.push(`Agent's context: ${ctx}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Session state
|
|
168
|
+
const { sessionState } = input;
|
|
169
|
+
if (sessionState.stack.length > 0) {
|
|
170
|
+
const stackSummary = sessionState.stack
|
|
171
|
+
.map(
|
|
172
|
+
(item) =>
|
|
173
|
+
` [${item.id}] ${item.prompt ?? 'no prompt'}`,
|
|
174
|
+
)
|
|
175
|
+
.join('\n');
|
|
176
|
+
parts.push(`Current UI stack (${sessionState.stack.length} items):\n${stackSummary}`);
|
|
177
|
+
} else {
|
|
178
|
+
parts.push('Current UI stack: empty');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (sessionState.conversationHistory.length > 0) {
|
|
182
|
+
const recent = sessionState.conversationHistory.slice(-5);
|
|
183
|
+
parts.push(
|
|
184
|
+
`Recent conversation:\n${recent.map((t) => ` ${t.role}: ${t.content}`).join('\n')}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Agent tools — MCP tools the agent invokes; component never calls these.
|
|
189
|
+
// When tools ARE listed, prefer binding matching actionSpec entries to them
|
|
190
|
+
// via `nextStep`. When tools are absent BUT the data implies interactivity,
|
|
191
|
+
// still declare actionSpec — events drain via ggui_consume on the next turn.
|
|
192
|
+
if (input.agentTools?.length) {
|
|
193
|
+
parts.push(
|
|
194
|
+
`Agent-side MCP tools (bind matching actionSpec entries' nextStep to these names; the agent invokes the tool on its next turn):\n` +
|
|
195
|
+
input.agentTools.map((t) => ` • ${t}`).join('\n'),
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
parts.push(
|
|
199
|
+
`Agent-side MCP tools: none declared. If the data implies interactivity (items with id + mutable fields, draft submissions, deletions of agent-owned rows), STILL declare actionSpec entries — just omit nextStep. The agent's next turn drains events via ggui_consume and reacts.`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Client-side gadget catalog — browser-capability hooks AND
|
|
204
|
+
// operator-registered 3rd-party plugins (Leaflet, Mapbox, Stripe, …)
|
|
205
|
+
// the runtime can serve for this app. Declare bindings under
|
|
206
|
+
// `clientCapabilities.gadgets` ONLY when the produced UI actually
|
|
207
|
+
// imports the hook.
|
|
208
|
+
//
|
|
209
|
+
// Routes through {@link composeAvailableGadgetsSection} so the
|
|
210
|
+
// decision LLM sees the same teaching text the synth-only path
|
|
211
|
+
// does — `description` (what), `usage` (when), with bounded
|
|
212
|
+
// per-entry + total budget. Both the decision path and the
|
|
213
|
+
// synth-only path share this one composer, so they agree on what
|
|
214
|
+
// the LLM is told about each gadget.
|
|
215
|
+
if (input.gadgets?.length) {
|
|
216
|
+
// `composeAvailableGadgetsSection` is component-aware — it
|
|
217
|
+
// flattens the package-keyed `GadgetDescriptor[]` catalog itself
|
|
218
|
+
// and renders hook AND component exports with their render idiom
|
|
219
|
+
// (call vs JSX) + package name. No pre-filter / pre-flatten here.
|
|
220
|
+
const gadgetsSection = composeAvailableGadgetsSection(input.gadgets);
|
|
221
|
+
if (gadgetsSection !== undefined) {
|
|
222
|
+
// Suffix the binding rule the previous flat-loop carried — the
|
|
223
|
+
// composer's section header doesn't say "declare under
|
|
224
|
+
// clientCapabilities.gadgets"; preserve that authoring nudge
|
|
225
|
+
// for the LLM. The permission column is intentionally dropped
|
|
226
|
+
// from the LLM's view: the operator's `App.gadgets`
|
|
227
|
+
// catalog still owns permission policy at registration time and
|
|
228
|
+
// push-time enrichment merges it back in.
|
|
229
|
+
parts.push(
|
|
230
|
+
`${gadgetsSection}\n\nDeclare each chosen gadget under clientCapabilities.gadgets[<packageName>][<exportName>] = {} — the package name keys the outer map, the export name (use-prefixed hook or PascalCase component) keys the inner map. Declare ONLY when the produced UI imports the export.`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Blueprint candidates (with contract + intents when available)
|
|
236
|
+
if (input.blueprintCandidates.length > 0) {
|
|
237
|
+
const candidates = input.blueprintCandidates
|
|
238
|
+
.map((c) => {
|
|
239
|
+
let line = ` [${c.blueprintId}] ${c.description} (${c.verdict}, ${Math.round(c.similarity * 100)}% match)`;
|
|
240
|
+
line += ` — REUSE = instant render, CREATE NEW = 20s wait`;
|
|
241
|
+
if (c.contract) {
|
|
242
|
+
line += `\n contract: ${JSON.stringify(c.contract)}`;
|
|
243
|
+
}
|
|
244
|
+
return line;
|
|
245
|
+
})
|
|
246
|
+
.join('\n');
|
|
247
|
+
parts.push(`Blueprint candidates (reuse any of these for instant render):\n${candidates}`);
|
|
248
|
+
} else {
|
|
249
|
+
parts.push('Blueprint candidates: none (no cached components — "create" will generate new)');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return parts.join('\n\n');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Tool schema for structured decision output.
|
|
257
|
+
*
|
|
258
|
+
* Matches @ggui-ai/protocol's NegotiatorDecision + DataContract types.
|
|
259
|
+
* Uses forced tool_choice to guarantee valid JSON output from the LLM.
|
|
260
|
+
*
|
|
261
|
+
* Module-internal — intentionally NOT exported. See the public-surface
|
|
262
|
+
* section of the top docstring.
|
|
263
|
+
*/
|
|
264
|
+
const DECISION_TOOL = {
|
|
265
|
+
name: 'ui_decision',
|
|
266
|
+
description: 'Output the UI decision with a full data contract.',
|
|
267
|
+
input_schema: {
|
|
268
|
+
type: 'object' as const,
|
|
269
|
+
properties: {
|
|
270
|
+
action: {
|
|
271
|
+
type: 'string',
|
|
272
|
+
enum: ['create', 'update', 'compose', 'replace'],
|
|
273
|
+
description:
|
|
274
|
+
'What to do: create (new UI), update (swap data), compose (add to stack), replace (swap UI type)',
|
|
275
|
+
},
|
|
276
|
+
reasoning: { type: 'string', description: 'Brief explanation' },
|
|
277
|
+
blueprintId: {
|
|
278
|
+
type: 'string',
|
|
279
|
+
description: 'Blueprint ID from candidates to reuse. Omit to generate new.',
|
|
280
|
+
},
|
|
281
|
+
contract: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
description: 'Data contract — defines the UI shape and the four wire surfaces (propsSpec / actionSpec / contextSpec / streamSpec)',
|
|
284
|
+
properties: {
|
|
285
|
+
intent: {
|
|
286
|
+
type: 'string',
|
|
287
|
+
description:
|
|
288
|
+
'Concise reusable purpose — e.g. "Display weather conditions". Same intent = cached component.',
|
|
289
|
+
},
|
|
290
|
+
propsSpec: {
|
|
291
|
+
type: 'object',
|
|
292
|
+
description: 'Props contract — initial render data',
|
|
293
|
+
properties: {
|
|
294
|
+
description: { type: 'string', description: 'What this data represents' },
|
|
295
|
+
properties: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
description:
|
|
298
|
+
'Per-prop definitions keyed by name. Each: { description, schema: { type }, required, example }',
|
|
299
|
+
additionalProperties: {
|
|
300
|
+
type: 'object',
|
|
301
|
+
properties: {
|
|
302
|
+
description: { type: 'string' },
|
|
303
|
+
schema: { type: 'object', properties: { type: { type: 'string' } } },
|
|
304
|
+
required: { type: 'boolean' },
|
|
305
|
+
example: {},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
actionSpec: {
|
|
312
|
+
type: 'object',
|
|
313
|
+
description:
|
|
314
|
+
'Action contract — flat map keyed by actionId. Each value describes one user-interaction event the UI can emit. Every action is agent-routed. Optional "nextStep" names the MCP tool the AGENT should invoke on its next turn after the event fires.',
|
|
315
|
+
additionalProperties: {
|
|
316
|
+
type: 'object',
|
|
317
|
+
properties: {
|
|
318
|
+
description: { type: 'string' },
|
|
319
|
+
label: { type: 'string', description: 'Button label' },
|
|
320
|
+
schema: { type: 'object' },
|
|
321
|
+
icon: { type: 'string', description: 'Icon hint (emoji or name)' },
|
|
322
|
+
nextStep: {
|
|
323
|
+
type: 'string',
|
|
324
|
+
description: 'MCP tool name the agent SHOULD call after the user fires this action (must appear in agentCapabilities.tools).',
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
streamSpec: {
|
|
330
|
+
type: 'object',
|
|
331
|
+
description:
|
|
332
|
+
'Stream contract — flat map keyed by channel name. Declares typed live channels the component consumes. Each value is a StreamChannelEntry describing schema + optional source.tool (the agent-tool name whose deliveries feed this channel).',
|
|
333
|
+
additionalProperties: { type: 'object' },
|
|
334
|
+
},
|
|
335
|
+
},
|
|
336
|
+
required: ['intent'],
|
|
337
|
+
},
|
|
338
|
+
targetStackItemId: {
|
|
339
|
+
type: 'string',
|
|
340
|
+
description: 'Page to target (update/replace actions)',
|
|
341
|
+
},
|
|
342
|
+
adaptations: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
description: 'UI adaptations based on device/context',
|
|
345
|
+
properties: {
|
|
346
|
+
fontSize: { type: 'string', enum: ['compact', 'default', 'large'] },
|
|
347
|
+
density: { type: 'string', enum: ['dense', 'default', 'spacious'] },
|
|
348
|
+
complexity: { type: 'string', enum: ['simplified', 'default', 'detailed'] },
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
required: ['action', 'reasoning', 'contract'],
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
/** Make the UI decision via one LLM call with all context. */
|
|
357
|
+
export async function makeDecision(
|
|
358
|
+
input: NegotiatorDecisionInput,
|
|
359
|
+
llmCaller: LLMCaller,
|
|
360
|
+
): Promise<{ decision: NegotiatorDecision; alternatives: NegotiatorAlternative[] }> {
|
|
361
|
+
const userMessage = buildDecisionUserMessage(input);
|
|
362
|
+
|
|
363
|
+
// Prefer structured output (tool use) — guaranteed valid JSON
|
|
364
|
+
if (llmCaller.callStructured) {
|
|
365
|
+
try {
|
|
366
|
+
const parsed = await llmCaller.callStructured<NegotiatorDecision>(
|
|
367
|
+
DECISION_SYSTEM_PROMPT,
|
|
368
|
+
userMessage,
|
|
369
|
+
DECISION_TOOL,
|
|
370
|
+
2048,
|
|
371
|
+
);
|
|
372
|
+
return {
|
|
373
|
+
decision: {
|
|
374
|
+
action: parsed.action ?? 'create',
|
|
375
|
+
reasoning: parsed.reasoning ?? 'Structured output',
|
|
376
|
+
blueprintId: parsed.blueprintId ?? undefined,
|
|
377
|
+
contract: mergeGadgets(
|
|
378
|
+
mergeAgentCapabilities(
|
|
379
|
+
{ ...parsed.contract },
|
|
380
|
+
input.agentTools,
|
|
381
|
+
),
|
|
382
|
+
input.gadgets,
|
|
383
|
+
),
|
|
384
|
+
targetStackItemId: parsed.targetStackItemId ?? undefined,
|
|
385
|
+
adaptations: parsed.adaptations ?? undefined,
|
|
386
|
+
},
|
|
387
|
+
alternatives: [],
|
|
388
|
+
};
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.warn(
|
|
391
|
+
'[decision] Structured output failed, falling back to text:',
|
|
392
|
+
(err as Error).message,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Fallback: regex JSON extraction from raw text
|
|
398
|
+
const raw = await llmCaller.call(DECISION_SYSTEM_PROMPT, userMessage, 2048);
|
|
399
|
+
const jsonMatch = raw.match(/\{[\s\S]*\}/);
|
|
400
|
+
if (!jsonMatch) {
|
|
401
|
+
return { decision: buildFallbackDecision(input), alternatives: [] };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
try {
|
|
405
|
+
const parsed = JSON.parse(jsonMatch[0]) as NegotiatorDecision;
|
|
406
|
+
return {
|
|
407
|
+
decision: {
|
|
408
|
+
action: parsed.action ?? 'create',
|
|
409
|
+
reasoning: parsed.reasoning ?? 'No reasoning provided',
|
|
410
|
+
blueprintId: parsed.blueprintId ?? undefined,
|
|
411
|
+
contract: mergeGadgets(
|
|
412
|
+
mergeAgentCapabilities(
|
|
413
|
+
{ ...parsed.contract },
|
|
414
|
+
input.agentTools,
|
|
415
|
+
),
|
|
416
|
+
input.gadgets,
|
|
417
|
+
),
|
|
418
|
+
targetStackItemId: parsed.targetStackItemId ?? undefined,
|
|
419
|
+
adaptations: parsed.adaptations ?? undefined,
|
|
420
|
+
},
|
|
421
|
+
alternatives: [],
|
|
422
|
+
};
|
|
423
|
+
} catch {
|
|
424
|
+
return { decision: buildFallbackDecision(input), alternatives: [] };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Deterministically populate `contract.agentCapabilities.tools` from
|
|
430
|
+
* `input.agentTools`.
|
|
431
|
+
*
|
|
432
|
+
* The LLM decides which agent tools become user-triggered actions
|
|
433
|
+
* (`actionSpec[*].nextStep`), but the contract-level catalog — the
|
|
434
|
+
* canonical list of tool names the agent MAY invoke — is a superset
|
|
435
|
+
* of the input and not something the LLM needs to restate. Populating
|
|
436
|
+
* it here closes the gap where the contract under-declared its
|
|
437
|
+
* agent-tool surface.
|
|
438
|
+
*
|
|
439
|
+
* Merge behavior:
|
|
440
|
+
* - If the LLM returned a `contract.agentCapabilities`, union its entries with input names.
|
|
441
|
+
* - Input-only names get a minimal `AgentToolEntry` (description only).
|
|
442
|
+
* - LLM-declared entries are preserved verbatim (may carry richer schema metadata).
|
|
443
|
+
*/
|
|
444
|
+
function mergeAgentCapabilities(
|
|
445
|
+
contract: DataContract,
|
|
446
|
+
inputAgentTools: string[] | undefined,
|
|
447
|
+
): DataContract {
|
|
448
|
+
if (!inputAgentTools?.length && !contract.agentCapabilities) return contract;
|
|
449
|
+
const existing = contract.agentCapabilities?.tools ?? {};
|
|
450
|
+
const merged: Record<string, { description?: string }> = { ...existing };
|
|
451
|
+
for (const name of inputAgentTools ?? []) {
|
|
452
|
+
if (!merged[name]) {
|
|
453
|
+
merged[name] = { description: `Agent-provided MCP tool: ${name}` };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
const spec: AgentCapabilitiesSpec = {
|
|
457
|
+
tools: merged,
|
|
458
|
+
};
|
|
459
|
+
return { ...contract, agentCapabilities: spec };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Deterministically enrich `contract.clientCapabilities.gadgets` from
|
|
464
|
+
* the per-app gadget catalog (`app.gadgets`).
|
|
465
|
+
*
|
|
466
|
+
* Per-app `gadgets` declares which browser-capability gadgets the
|
|
467
|
+
* runtime can serve for the app. The LLM authors a per-contract subset
|
|
468
|
+
* (only the gadgets the produced UI actually uses) on the
|
|
469
|
+
* package-keyed wire map (`Record<package, Record<exportName,
|
|
470
|
+
* GadgetExportUse>>`), but partial entries (no `description` / `usage`
|
|
471
|
+
* override) are common — this merge fills in the canonical teaching
|
|
472
|
+
* text from the app catalog.
|
|
473
|
+
*
|
|
474
|
+
* Merge behavior:
|
|
475
|
+
* - Each wire `(package, exportName)` use that resolves to an
|
|
476
|
+
* app-catalog export inherits the registered export's
|
|
477
|
+
* `description` / `usage` (when the wire use didn't override them).
|
|
478
|
+
* - Wire uses that do NOT resolve to any app-catalog export are
|
|
479
|
+
* preserved verbatim (the app may carry a third-party gadget the
|
|
480
|
+
* catalog passed in here doesn't include).
|
|
481
|
+
* - The contract's set of declared `(package, export)` uses stays
|
|
482
|
+
* exactly what the LLM authored — we don't append every app-catalog
|
|
483
|
+
* export, since the contract only declares what the produced UI
|
|
484
|
+
* references.
|
|
485
|
+
* - LLM-authored fields win on conflict (operator may have authored a
|
|
486
|
+
* richer description/usage for the use at this specific call site).
|
|
487
|
+
*
|
|
488
|
+
* Identity keying routes through `gadgetIdentityKey` so the merge
|
|
489
|
+
* agrees byte-for-byte with the push-time gates on what "the same
|
|
490
|
+
* gadget export" means.
|
|
491
|
+
*/
|
|
492
|
+
function mergeGadgets(
|
|
493
|
+
contract: DataContract,
|
|
494
|
+
appGadgets: readonly GadgetDescriptor[] | undefined,
|
|
495
|
+
): DataContract {
|
|
496
|
+
if (!appGadgets?.length || !contract.clientCapabilities?.gadgets) {
|
|
497
|
+
return contract;
|
|
498
|
+
}
|
|
499
|
+
// Index every registered export by its `(name, package)` identity
|
|
500
|
+
// tuple. A descriptor-side `GadgetExport` carries no package
|
|
501
|
+
// identity, so combine `gadgetExportName(exp)` with the descriptor's
|
|
502
|
+
// own `package` to form the key the wire side keys by too.
|
|
503
|
+
const byExport = new Map<string, GadgetExport>();
|
|
504
|
+
for (const pkg of appGadgets) {
|
|
505
|
+
for (const exp of pkg.exports) {
|
|
506
|
+
byExport.set(
|
|
507
|
+
gadgetIdentityKey({
|
|
508
|
+
name: gadgetExportName(exp),
|
|
509
|
+
package: pkg.package,
|
|
510
|
+
}),
|
|
511
|
+
exp,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
const enriched: Record<string, GadgetPackageUse> = {};
|
|
516
|
+
for (const [pkgName, packageUse] of Object.entries(
|
|
517
|
+
contract.clientCapabilities.gadgets,
|
|
518
|
+
)) {
|
|
519
|
+
const enrichedPackage: Record<string, GadgetExportUse> = {};
|
|
520
|
+
for (const [exportName, use] of Object.entries(packageUse)) {
|
|
521
|
+
const canonical = byExport.get(
|
|
522
|
+
gadgetIdentityKey({ name: exportName, package: pkgName }),
|
|
523
|
+
);
|
|
524
|
+
if (canonical) {
|
|
525
|
+
enrichedPackage[exportName] = {
|
|
526
|
+
// Registered export's teaching text as the base...
|
|
527
|
+
...(canonical.description !== undefined
|
|
528
|
+
? { description: canonical.description }
|
|
529
|
+
: {}),
|
|
530
|
+
...(canonical.usage !== undefined ? { usage: canonical.usage } : {}),
|
|
531
|
+
// ...then the wire use — LLM-authored fields win on conflict.
|
|
532
|
+
...use,
|
|
533
|
+
};
|
|
534
|
+
} else {
|
|
535
|
+
enrichedPackage[exportName] = use;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
enriched[pkgName] = enrichedPackage;
|
|
539
|
+
}
|
|
540
|
+
return {
|
|
541
|
+
...contract,
|
|
542
|
+
clientCapabilities: { gadgets: enriched },
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function buildFallbackDecision(input: NegotiatorDecisionInput): NegotiatorDecision {
|
|
547
|
+
const props = input.agentData
|
|
548
|
+
? Object.fromEntries(
|
|
549
|
+
Object.entries(input.agentData).map(([key, value]) => [
|
|
550
|
+
key,
|
|
551
|
+
{
|
|
552
|
+
description: key,
|
|
553
|
+
schema: { type: inferType(value) as 'string' },
|
|
554
|
+
required: true,
|
|
555
|
+
example: value as JsonValue,
|
|
556
|
+
},
|
|
557
|
+
]),
|
|
558
|
+
)
|
|
559
|
+
: {};
|
|
560
|
+
|
|
561
|
+
const contract: DataContract = {
|
|
562
|
+
propsSpec: { properties: props },
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
action: 'create',
|
|
567
|
+
reasoning: 'Fallback: creating new UI (decision LLM failed to parse)',
|
|
568
|
+
contract: mergeGadgets(
|
|
569
|
+
mergeAgentCapabilities(contract, input.agentTools),
|
|
570
|
+
input.gadgets,
|
|
571
|
+
),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function inferType(value: unknown): string {
|
|
576
|
+
if (typeof value === 'number') return 'number';
|
|
577
|
+
if (typeof value === 'boolean') return 'boolean';
|
|
578
|
+
if (Array.isArray(value)) return 'array';
|
|
579
|
+
if (typeof value === 'object' && value !== null) return 'object';
|
|
580
|
+
return 'string';
|
|
581
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ggui-ai/negotiator — open-source UI decision engine for ggui.
|
|
3
|
+
*
|
|
4
|
+
* Decides which UI to render (create/update/compose/replace) given agent
|
|
5
|
+
* signal (data/prompt/context/agentTools) and current session state.
|
|
6
|
+
*
|
|
7
|
+
* Composes the storage seams defined in `@ggui-ai/mcp-server-core`
|
|
8
|
+
* (`EmbeddingProvider`, `VectorStore`, `Negotiator`). The decision
|
|
9
|
+
* semantics are open here; concrete cloud-vendor bindings (e.g. a
|
|
10
|
+
* managed embedding service or vector store) live behind those seams
|
|
11
|
+
* so this package stays deployment-agnostic.
|
|
12
|
+
*
|
|
13
|
+
* This barrel stays narrow — it exports only the minimum surface
|
|
14
|
+
* consumers need. Each additive export carries semver weight.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export { hashContract, buildVariant } from './contract-hash.js';
|
|
18
|
+
export { computeIntentId, shouldSuppressSuggestion } from './intent.js';
|
|
19
|
+
export { detectDataPatterns, buildSuggestion } from './suggestion.js';
|
|
20
|
+
export type { NegotiatorSuggestion } from './suggestion.js';
|
|
21
|
+
export { inferInteractionMode, inferJsonSchemaType } from './pure.js';
|
|
22
|
+
export { ragSearch } from './rag-search.js';
|
|
23
|
+
export type {
|
|
24
|
+
RagSearchDeps,
|
|
25
|
+
RagSearchInput,
|
|
26
|
+
RagSearchResult,
|
|
27
|
+
} from './rag-search.js';
|
|
28
|
+
export type { NegotiatorOption } from './types.js';
|
|
29
|
+
export type { LLMCaller, LLMCallerConfig, ToolSchema } from './llm-caller.js';
|
|
30
|
+
export type { SessionState, SessionStackEntry } from './session.js';
|
|
31
|
+
export type { NegotiatorDecisionInput } from './decision-input.js';
|
|
32
|
+
export {
|
|
33
|
+
DECISION_SYSTEM_PROMPT,
|
|
34
|
+
buildDecisionUserMessage,
|
|
35
|
+
makeDecision,
|
|
36
|
+
} from './decision.js';
|
|
37
|
+
export { negotiate } from './negotiate.js';
|
|
38
|
+
export type {
|
|
39
|
+
NegotiateDeps,
|
|
40
|
+
NegotiateInput,
|
|
41
|
+
NegotiateConfig,
|
|
42
|
+
NegotiateResult,
|
|
43
|
+
} from './negotiate.js';
|
|
44
|
+
export { rerankCandidates } from './llm-rerank.js';
|
|
45
|
+
export type {
|
|
46
|
+
RerankCandidate,
|
|
47
|
+
RerankDecision,
|
|
48
|
+
RerankQuery,
|
|
49
|
+
} from './llm-rerank.js';
|
|
50
|
+
export { synthesizeContract } from './synthesize-contract.js';
|
|
51
|
+
export type { SynthesizeContractResult } from './synthesize-contract.js';
|
|
52
|
+
export {
|
|
53
|
+
validateContractStructure,
|
|
54
|
+
validateContractNovelty,
|
|
55
|
+
formatValidationFindings,
|
|
56
|
+
} from './contract-validators.js';
|
|
57
|
+
export type {
|
|
58
|
+
ContractValidationFinding,
|
|
59
|
+
ContractValidationFindingKind,
|
|
60
|
+
ContractValidationResult,
|
|
61
|
+
ContractValidationNoveltyDeps,
|
|
62
|
+
ContractValidationNoveltyOptions,
|
|
63
|
+
} from './contract-validators.js';
|
package/src/intent.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic identifier for a negotiation intent.
|
|
5
|
+
*
|
|
6
|
+
* Used by the suggestion engine to deduplicate auto-suggested UIs
|
|
7
|
+
* within a session — two prompts that would produce the same intent
|
|
8
|
+
* collapse to a single suggestion. SHA-256 truncated to 16 hex chars
|
|
9
|
+
* (64 bits) gives collision-resistant ids without being wastefully
|
|
10
|
+
* large in logs.
|
|
11
|
+
*
|
|
12
|
+
* @param sessionId Scope. Intent ids are session-local.
|
|
13
|
+
* @param data Data shape (keys only — values ignored). Undefined → 'no-data'.
|
|
14
|
+
* @param action Optional action verb. Defaults to 'create'.
|
|
15
|
+
*/
|
|
16
|
+
export function computeIntentId(
|
|
17
|
+
sessionId: string,
|
|
18
|
+
data: Record<string, unknown> | undefined,
|
|
19
|
+
action?: string,
|
|
20
|
+
): string {
|
|
21
|
+
const dataShape = data ? Object.keys(data).sort().join(',') : 'no-data';
|
|
22
|
+
const raw = `${sessionId}:${dataShape}:${action ?? 'create'}`;
|
|
23
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* True if the given intent id is already being handled in this session.
|
|
28
|
+
*
|
|
29
|
+
* The suggestion engine uses this to avoid re-suggesting the same UI
|
|
30
|
+
* while the agent is already preparing one.
|
|
31
|
+
*/
|
|
32
|
+
export function shouldSuppressSuggestion(
|
|
33
|
+
intentId: string,
|
|
34
|
+
activeIntentIds: Set<string>,
|
|
35
|
+
): boolean {
|
|
36
|
+
return activeIntentIds.has(intentId);
|
|
37
|
+
}
|