@terreno/mcp 57.9.0 → 57.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/docs/upgrades/57.10.1.md +54 -0
- package/dist/docs/upgrades/57.11.0.md +80 -0
- package/dist/docs/versioned/next/README.md +1 -0
- package/dist/docs/versioned/next/explanation/README.md +4 -0
- package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/dist/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/dist/docs/versioned/next/how-to/README.md +7 -0
- package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/dist/docs/versioned/next/how-to/circleci.md +5 -3
- package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/dist/docs/versioned/next/reference/README.md +3 -0
- package/dist/docs/versioned/next/reference/admin-config.md +2 -0
- package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/dist/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/dist/docs/versioned/next/reference/ai.md +251 -25
- package/dist/docs/versioned/next/reference/api.md +110 -2
- package/dist/docs/versioned/next/reference/blocks.md +178 -0
- package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
- package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
- package/dist/docs/versioned/next/reference/syncdb.md +156 -6
- package/dist/docs/versioned/next/reference/test.md +11 -2
- package/dist/docs/versioned/next/reference/ui.md +331 -6
- package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
- package/dist/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +37 -0
- package/dist/tools.js.map +1 -1
- package/package.json +3 -2
- package/src/docs/upgrades/57.10.1.md +54 -0
- package/src/docs/upgrades/57.11.0.md +80 -0
- package/src/docs/versioned/next/README.md +1 -0
- package/src/docs/versioned/next/explanation/README.md +4 -0
- package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
- package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
- package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
- package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
- package/src/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
- package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
- package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
- package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
- package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
- package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
- package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
- package/src/docs/versioned/next/how-to/README.md +7 -0
- package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
- package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
- package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
- package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
- package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
- package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
- package/src/docs/versioned/next/how-to/circleci.md +5 -3
- package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
- package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
- package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
- package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
- package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
- package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
- package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
- package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
- package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
- package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
- package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
- package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
- package/src/docs/versioned/next/reference/README.md +3 -0
- package/src/docs/versioned/next/reference/admin-config.md +2 -0
- package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
- package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
- package/src/docs/versioned/next/reference/ai-harness.md +1974 -0
- package/src/docs/versioned/next/reference/ai.md +251 -25
- package/src/docs/versioned/next/reference/api.md +110 -2
- package/src/docs/versioned/next/reference/blocks.md +178 -0
- package/src/docs/versioned/next/reference/environment-variables.md +2 -1
- package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
- package/src/docs/versioned/next/reference/mcp-server.md +17 -0
- package/src/docs/versioned/next/reference/syncdb.md +156 -6
- package/src/docs/versioned/next/reference/test.md +11 -2
- package/src/docs/versioned/next/reference/ui.md +331 -6
- package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
- package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
- package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
- package/src/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# Agent UI Asks
|
|
2
|
+
|
|
3
|
+
An ask lets an agent stop in the middle of a turn, ask the user a typed question inside the chat,
|
|
4
|
+
and continue with the answer as a tool result. This page explains why asks are client-side tool
|
|
5
|
+
calls on the existing chat stream, how one round trip works, how a watch or another small client
|
|
6
|
+
answers them, and how asks and Agent UI Blocks divide the work. Fields, limits, events, and error
|
|
7
|
+
codes are in the [reference](../reference/agent-ui-asks.md).
|
|
8
|
+
|
|
9
|
+
Asks ship today as five kinds: `choice`, to pick one option or several with an optional answer
|
|
10
|
+
of the user's own (Other), `confirm`, to approve or deny one action, `markdown`, to edit a draft
|
|
11
|
+
the agent wrote and send it back, `form`, to fill in a few typed fields at once, and `files`, to
|
|
12
|
+
send images or documents the agent reads in the same turn. The chat asks
|
|
13
|
+
on `POST /gpt/prompt` and shows asks in `GPTChat`. Watches and other small clients answer
|
|
14
|
+
select-one choices and confirms on two JSON endpoints with the compact surface, and can approve a
|
|
15
|
+
markdown draft or submit a form's defaults as is. A host tool can also require an approval that
|
|
16
|
+
the server enforces, shown as a `confirm`. The sandboxed HTML block and the `callout`, `image`,
|
|
17
|
+
and `details` display blocks follow the
|
|
18
|
+
[implementation plan](https://github.com/TerrenoLabs/terreno/blob/master/docs/implementationPlans/agent-ui-asks.md).
|
|
19
|
+
|
|
20
|
+
## The problem
|
|
21
|
+
|
|
22
|
+
Agents often need one decision before they can go on: which plan, which record, whether to
|
|
23
|
+
proceed. In plain chat the agent asks in prose and the user types a reply. The agent then parses
|
|
24
|
+
free text that may not match any option, nothing ties the reply to the question, and nothing stops
|
|
25
|
+
the user from answering with something that was never offered.
|
|
26
|
+
|
|
27
|
+
## Why client-side tool calls
|
|
28
|
+
|
|
29
|
+
Each ask kind is an AI SDK tool, such as `ask_choice`, with a Zod input schema, an output schema,
|
|
30
|
+
and no `execute` function. Most of the design follows from that choice:
|
|
31
|
+
|
|
32
|
+
- **The model is held to the schema.** Provider function calling makes the model write a JSON
|
|
33
|
+
object for the tool, and the AI SDK checks it against the Zod schema. An invalid ask goes back
|
|
34
|
+
to the model as a tool error it can fix in its next step. It never reaches the user.
|
|
35
|
+
- **The turn pauses by itself.** A tool without `execute` ends the AI SDK step loop, so the
|
|
36
|
+
server needs no waiting state inside the model call. It saves the paused turn and ends the
|
|
37
|
+
stream.
|
|
38
|
+
- **The answer is tied to the question.** The tool call id is the key. The answer names it, and
|
|
39
|
+
the server accepts an answer only for the ask the conversation waits on.
|
|
40
|
+
- **The model already knows how to read the answer.** It comes back as the tool's result, which
|
|
41
|
+
every provider represents natively, so no prompt wording has to explain what the user chose.
|
|
42
|
+
|
|
43
|
+
This is the same pattern as AI SDK client-side tools, AG-UI interrupts, and CopilotKit
|
|
44
|
+
human-in-the-loop. The answer envelope (`accept`, `decline`, `cancel`) is the MCP elicitation
|
|
45
|
+
shape.
|
|
46
|
+
|
|
47
|
+
Other approaches were rejected:
|
|
48
|
+
|
|
49
|
+
| Approach | Why not |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Move the chat to the AI SDK `useChat` UI message stream | Rewrites the SSE contract every host parses, and the history storage |
|
|
52
|
+
| Form blocks inside the reply document, like Slack Block Kit inputs | Values come back as user text with nothing tying them to a question, and they need blocks turned on |
|
|
53
|
+
| Interactive HTML mini-apps, like MCP Apps | No React Native host exists, and they open the widest phishing and data-leak surface |
|
|
54
|
+
|
|
55
|
+
## One round trip
|
|
56
|
+
|
|
57
|
+
1. **Ask.** The model calls `ask_choice` with a prompt and options. The AI SDK checks the input;
|
|
58
|
+
an invalid ask goes back to the model as a tool error.
|
|
59
|
+
2. **Pause.** The step loop ends. The server derives the ask's simple card and saves the paused
|
|
60
|
+
turn on the history as `pendingAsk`, including the AI SDK messages the turn produced. It adds a
|
|
61
|
+
row for the ask with status `pending`, then sends `{ask}` with the conversation's `historyId`
|
|
62
|
+
and `{done}` with `pendingAsk`. Because `{ask}` names the conversation, a client can answer a
|
|
63
|
+
new chat's ask before `{done}` arrives.
|
|
64
|
+
3. **Answer.** The client shows the ask and posts the answer with the ask's tool call id to
|
|
65
|
+
`POST /gpt/prompt`.
|
|
66
|
+
4. **Check.** The server confirms the history belongs to the caller (403), that this is the ask
|
|
67
|
+
it waits on (409), and that the answer fits the ask (400, and the model is not called).
|
|
68
|
+
5. **Resume.** One atomic update stores the answer row, marks the ask answered, and clears
|
|
69
|
+
`pendingAsk`. The server replays the saved messages with the answer as the tool result and
|
|
70
|
+
streams the rest of the turn, starting with `{askResolved}`.
|
|
71
|
+
|
|
72
|
+
The server replays the saved messages instead of rebuilding them from history rows because the AI
|
|
73
|
+
SDK messages carry provider metadata, such as Gemini thought signatures, that the rows do not
|
|
74
|
+
keep. Replaying them resumes the exact turn the provider started. In later turns, the history goes
|
|
75
|
+
through `buildMessages`, which keeps each ask and its answer so the model remembers what it asked
|
|
76
|
+
and what the user chose. Host tool rows stay out of the model's messages, as before.
|
|
77
|
+
|
|
78
|
+
The update is atomic because two answers to one ask, from a double tap or two devices, must resume
|
|
79
|
+
the turn once. It matches on the pending ask's tool call id, so the second answer finds nothing to
|
|
80
|
+
update and gets 409.
|
|
81
|
+
|
|
82
|
+
The answer stays stored even when the model call that continues the turn fails. The user did
|
|
83
|
+
answer, so the next message continues from the ask and its answer instead of asking again. Any
|
|
84
|
+
turn that fails after its stream starts still ends with `{error}` then `{done}` and saves what the
|
|
85
|
+
user saw, so a client always learns the turn ended and which conversation holds it.
|
|
86
|
+
|
|
87
|
+
## One ask at a time
|
|
88
|
+
|
|
89
|
+
A conversation waits on at most one ask. When the model asks twice in one step, the first ask
|
|
90
|
+
pauses the turn and the others are answered with `cancel` and the reason `one_ask_at_a_time`,
|
|
91
|
+
which the model sees when the turn resumes. Two turns on one conversation, say from two open tabs,
|
|
92
|
+
follow the same rule. Setting the pending ask is a conditional update, so the ask saved first
|
|
93
|
+
keeps waiting and the other turn's ask gets the same `cancel` instead of overwriting it.
|
|
94
|
+
|
|
95
|
+
The user is never stuck behind a question. Typing a message instead of answering records `cancel`
|
|
96
|
+
with the reason `user_sent_message`, then adds the message. The model sees both and can respond to
|
|
97
|
+
what the user actually said. A message is never refused because its ask is gone: when another tab
|
|
98
|
+
answered the ask first, the message goes ahead on the conversation as that answer left it. Only an
|
|
99
|
+
answer can be stale, so only an answer gets 409.
|
|
100
|
+
|
|
101
|
+
Turns never write back a whole copy of the conversation. Each turn appends its rows with one
|
|
102
|
+
atomic update when it ends, and ratings set one field, so two turns or a rating racing a turn
|
|
103
|
+
cannot drop each other's rows. A paused turn remembers how many rows come before and include its
|
|
104
|
+
own user message, and its resume replays exactly those.
|
|
105
|
+
|
|
106
|
+
## Approvals the server enforces
|
|
107
|
+
|
|
108
|
+
`ask_confirm` works only when the model remembers to call it. A prompt injection, or a model that
|
|
109
|
+
skips the step, can still call a destructive tool directly. So a host tool can set the AI SDK's
|
|
110
|
+
`needsApproval: true`, and the server, not the model, makes the confirm.
|
|
111
|
+
|
|
112
|
+
When the model calls such a tool, the AI SDK stops before `execute` and emits a
|
|
113
|
+
`tool-approval-request`. The server turns it into a `confirm` ask with `origin: "approval"` and
|
|
114
|
+
pauses the turn exactly as for a model ask: same pending slot, same simple card, same answers
|
|
115
|
+
through `/gpt/prompt` and `turn`. The answer becomes the SDK's `tool-approval-response`. On
|
|
116
|
+
resume the SDK runs the tool once when approved, or gives the model a denial when not, so the
|
|
117
|
+
tool's own code never has to check.
|
|
118
|
+
|
|
119
|
+
The approval uses the one pending slot rather than a second one. A client then has one question
|
|
120
|
+
to show and one way to answer, and a watch needs no new card type. When a step holds a model ask
|
|
121
|
+
and an approval, or two approvals, the first pauses the turn and each other approval is denied
|
|
122
|
+
with `one_ask_at_a_time`. The model sees the denial and can ask again in its next turn.
|
|
123
|
+
|
|
124
|
+
The host words the card with `asks.approvals`, because only the host knows what a call changes.
|
|
125
|
+
The default, "Allow <toolName>?" with the tool's description, is safe but vague, so a
|
|
126
|
+
destructive tool should name the effect and set `destructive: true`.
|
|
127
|
+
|
|
128
|
+
Approval rows are for display. Later turns do not replay them, just as they do not replay host
|
|
129
|
+
tool rows: the model's memory of the call is what it wrote in its reply.
|
|
130
|
+
|
|
131
|
+
## Strict by design
|
|
132
|
+
|
|
133
|
+
Every ask kind is a strict schema with hard limits. The limits live in one constant,
|
|
134
|
+
`ASK_LIMITS`, which the schemas, the system prompt section, the tests, and a doc-parity test all
|
|
135
|
+
read. The model can only ask for controls a client can render, and the user can only send answers
|
|
136
|
+
the ask allows. The same pure validators run on the server and in clients, so a client can enable
|
|
137
|
+
Submit exactly when the server will accept the answer.
|
|
138
|
+
|
|
139
|
+
## Simple cards
|
|
140
|
+
|
|
141
|
+
Every ask comes with a simple card: short text and at most three buttons, each holding the exact
|
|
142
|
+
answer it sends. A client that knows nothing about ask kinds, such as a watch, a notification, or
|
|
143
|
+
a chat bot, can render the card and answer with one tap by sending back the tapped button's answer.
|
|
144
|
+
When the buttons cannot show every option the ask offers, the card sets `handoff` so the client
|
|
145
|
+
can send the user to the full app. A card with a button for every option does not set it, even
|
|
146
|
+
when Skip is left out to make room.
|
|
147
|
+
|
|
148
|
+
The server derives the card once, when the ask is made, and stores it with the pending ask. A card
|
|
149
|
+
already on a screen never changes, even if the derivation rules change later.
|
|
150
|
+
|
|
151
|
+
Each button carries its exact answer, not a label for the client to turn into one. A client that
|
|
152
|
+
knows no ask kinds cannot build a valid answer: it would need each kind's answer shape and rules.
|
|
153
|
+
With the answer on the button, those rules stay on the server, and a test checks that every
|
|
154
|
+
button's answer passes `validateAskResponse` for its ask. A client can also send only the pressed
|
|
155
|
+
button's `id`. The server looks the id up on the card it stored and answers with that button's
|
|
156
|
+
answer, so a client that sends only ids cannot send an answer the card did not offer.
|
|
157
|
+
|
|
158
|
+
## In the chat
|
|
159
|
+
|
|
160
|
+
`GPTChat` shows an ask where the model asked it: the ask's tool call row becomes an `AskCard` in
|
|
161
|
+
the transcript, and the continuation streams in below it. The host still owns the network.
|
|
162
|
+
`GPTChat` calls `onAskSubmit`, and the host posts the answer and reads the continuation with the
|
|
163
|
+
same stream reader it uses for prompts. An answer the server rejects comes back to the card as
|
|
164
|
+
`askErrors`, next to the control that caused it.
|
|
165
|
+
|
|
166
|
+
A `choice` whose options all fit its simple card renders as that card's buttons, and a tap answers.
|
|
167
|
+
A phone then shows the same buttons a watch would, and the most common ask, two or three short
|
|
168
|
+
options, takes one tap. Longer lists get radio buttons or a searchable select with Submit.
|
|
169
|
+
|
|
170
|
+
A `select: "many"` choice renders as checkboxes with Submit, because one tap cannot pick several.
|
|
171
|
+
Its Other field lets the user answer with something the agent did not list, without leaving the
|
|
172
|
+
ask for free text: the answer still names the offered ids in `selected`, and only the typed part
|
|
173
|
+
is in `other`. Other counts as one choice against `maxSelected`, so "pick up to three" means three
|
|
174
|
+
things in total, however the user picks them. Other is offered only with `"many"`; an agent that
|
|
175
|
+
wants "one option or your own" asks `"many"` with `maxSelected: 1`, so every ask with free text
|
|
176
|
+
goes through the same control and the same count rule. On a small screen a many-select card can
|
|
177
|
+
offer only its suggested set and Skip, so it always hands off, and the compact surface does not
|
|
178
|
+
offer `"many"` at all.
|
|
179
|
+
|
|
180
|
+
A `confirm` guards one action the agent is about to take, such as deleting data or sending a
|
|
181
|
+
message for the user. Its answer is `{confirmed: true}` or `{confirmed: false}`, so "no" is a real
|
|
182
|
+
answer the agent must respect, not a skip: deny is the second button, and Skip appears only when
|
|
183
|
+
the ask sets `allowDecline`. The card shows the simple card's two buttons in the same order on a
|
|
184
|
+
phone and a watch, approve first and deny last. A `destructive` ask draws the approve button in
|
|
185
|
+
the destructive style, and a watch never makes it the Double Tap button, so a gesture cannot
|
|
186
|
+
approve an action that cannot be undone. The confirm is a pause in the turn, not a lock: the host's
|
|
187
|
+
tool should still check that the action is allowed.
|
|
188
|
+
|
|
189
|
+
A `markdown` ask hands the user a draft, such as an announcement or an email, in a markdown editor
|
|
190
|
+
with a preview. It is one round trip, not a shared document: the user edits and sends the text
|
|
191
|
+
back, and the agent continues with it. The answer carries `changed`, and the server checks it
|
|
192
|
+
against the text, so `changed: false` reliably means "approved as is" and the agent need not diff
|
|
193
|
+
the text itself. Editing needs a keyboard and room, so the compact surface never offers `markdown`.
|
|
194
|
+
A watch still sees a markdown ask the phone's chat made: its card offers Approve draft, when the
|
|
195
|
+
draft already meets the length rules, and Cancel, and it always hands off to the phone. A long
|
|
196
|
+
answer collapses to a preview in the transcript, so one draft does not push the conversation out
|
|
197
|
+
of view.
|
|
198
|
+
|
|
199
|
+
A `form` asks for several values in one step, such as the details for an invoice, instead of a
|
|
200
|
+
string of questions. Its fields are flat and typed, and the same rules check a default when the
|
|
201
|
+
ask is made and a value when the answer arrives, so the agent never receives a value its own form
|
|
202
|
+
would reject and never offers a default the user could not send back. Blank means unanswered.
|
|
203
|
+
Dates travel as ISO strings in fixed shapes (`YYYY-MM-DD`, 24-hour `HH:mm`, and datetimes with an
|
|
204
|
+
offset) so the agent never has to guess a time zone. Each error names its field
|
|
205
|
+
(`content.values.<id>`), so the card shows it where the user can fix it. A small screen cannot
|
|
206
|
+
fill in fields, so the compact surface never offers `form`; a watch can still send the defaults
|
|
207
|
+
when every required field has one, and otherwise hands off to the phone.
|
|
208
|
+
|
|
209
|
+
A `files` ask gets the user's files to the model as the model's own input types: images as image
|
|
210
|
+
parts, PDFs as file parts, and text, CSV, and JSON as text cut at 100 KB. The declared type is not
|
|
211
|
+
trusted. The server reads the bytes and rejects a file whose bytes are another type, so a renamed
|
|
212
|
+
file cannot reach the model as something it is not. Files travel as uploads when the host has
|
|
213
|
+
file storage and as data URLs when it does not, so an app without a bucket still works. Either
|
|
214
|
+
way the history keeps only each file's name, type, and size: the bytes reach the model once, on
|
|
215
|
+
the answering turn, and a long conversation does not grow by the size of its files. Picking files
|
|
216
|
+
needs a phone or a computer, so the compact surface never offers `files`.
|
|
217
|
+
|
|
218
|
+
After an ask ends, the card collapses to one line, such as "You chose: Team" or "You declined:
|
|
219
|
+
Keep them". A long conversation
|
|
220
|
+
then reads as a record of what the agent asked and what the user decided. The answer's
|
|
221
|
+
`tool-result` row stays in the message list but is hidden. Ratings address messages by their index
|
|
222
|
+
in the stored rows, so the client keeps one message per row, and after a reload the summary reads
|
|
223
|
+
the answer from that row.
|
|
224
|
+
|
|
225
|
+
A pending ask takes focus when it appears, so keyboard and screen reader users land on the question
|
|
226
|
+
instead of hunting for it.
|
|
227
|
+
|
|
228
|
+
## Small screens and the watch
|
|
229
|
+
|
|
230
|
+
A watch shows about three short buttons. A full `choice` can offer 50 options, and on a watch its
|
|
231
|
+
card can only hand off to the phone. So a small client says where the user is with
|
|
232
|
+
`surface: "compact"`, and the server narrows what the agent may ask on that turn: a `choice`
|
|
233
|
+
with `select: "one"` and 2–3 options whose labels fit a button uncut and differ from each other,
|
|
234
|
+
or a `confirm`, whose two labels already fit. Every card made on a
|
|
235
|
+
compact turn can be answered from the watch, and the system prompt asks for replies of at most two
|
|
236
|
+
short sentences. The narrowing is in the tool's input schema, not only in the prompt, so the model
|
|
237
|
+
cannot ask what the watch cannot show. A compact ask that breaks the rules goes back to the model
|
|
238
|
+
as a tool error, like any invalid ask.
|
|
239
|
+
|
|
240
|
+
The surface belongs to a turn, not to the conversation. One conversation can start in the phone's
|
|
241
|
+
chat and continue on the watch, and each turn gets the rules of the screen it was sent from.
|
|
242
|
+
|
|
243
|
+
A watch app, a notification action, or a chat bot wants to send one request and read one
|
|
244
|
+
response, not a server-sent event stream. The `turn` action runs the same turn as `/gpt/prompt`,
|
|
245
|
+
through the same turn runner, and collects its events into one JSON result. Both save the same
|
|
246
|
+
rows, so the phone's chat shows what was answered on the watch. The turn keeps running when the
|
|
247
|
+
client disconnects, which matters on a watch, where the system can suspend the app mid-request.
|
|
248
|
+
The turn is saved, and the client reads the reply or the next ask later. `pendingAsks` lists what
|
|
249
|
+
waits on the user across every conversation, so a watch can show it without opening each one.
|
|
250
|
+
|
|
251
|
+
How an Apple Watch can plug in:
|
|
252
|
+
|
|
253
|
+
| Path | What it needs | Status |
|
|
254
|
+
| --- | --- | --- |
|
|
255
|
+
| SwiftUI watch app | A native watchOS target, because React Native does not run on watchOS. `@bacons/apple-targets` adds one to an Expo app. The phone hands the session token over WatchConnectivity, and the app calls the two endpoints with `URLSession`, always with `surface: "compact"`. The [how-to](../how-to/agent-ui-asks.md#answer-asks-from-an-apple-watch-or-another-small-client) sketches one. | Protocol ready; a sample app is Future Work |
|
|
256
|
+
| Actionable push, no watch app | iOS forwards iPhone notifications with up to 4 action buttons to the watch. It needs `categoryId` on comms push, `data` on `notify()`, templated categories (iOS fixes action labels when the app registers a category), actions that do not open the app, a small native iOS handler (expo-notifications completes before a JavaScript `fetch` finishes), and a one-time action token per ask. | Future Work |
|
|
257
|
+
|
|
258
|
+
Other follow-ups: finishing long turns in a `@terreno/jobs` background job, so a headless answer
|
|
259
|
+
returns at once instead of waiting for the agent, and Wear OS.
|
|
260
|
+
|
|
261
|
+
## Asks and blocks
|
|
262
|
+
|
|
263
|
+
Asks and [UI blocks](agent-ui-blocks.md) split the work by where the
|
|
264
|
+
result goes. Body components are Phase 5 of the asks plan; the design record remains
|
|
265
|
+
[agent-ui-blocks](../implementationPlans/agent-ui-blocks.md).
|
|
266
|
+
|
|
267
|
+
| Need | Mechanism |
|
|
268
|
+
| --- | --- |
|
|
269
|
+
| Show data, such as a chart, table, or metric | A block in the reply |
|
|
270
|
+
| Suggest a follow-up message | A block button that sends a reply |
|
|
271
|
+
| Run host code, such as an export | A block button with a host callback |
|
|
272
|
+
| Get an answer the agent needs to continue | An ask: tool call, pause, answer, resume |
|
|
273
|
+
| Answer from a watch or another small client | The ask's simple card, through `pendingAsks` and `turn` |
|
|
274
|
+
|
|
275
|
+
Blocks display, and their buttons start something new: a message or host code. Asks collect, and
|
|
276
|
+
their answer goes back to the agent as the result of the call that asked. An ask's `prompt` is
|
|
277
|
+
plain text, so an ask cannot show a chart or table above its control yet. Both live in
|
|
278
|
+
`@terreno/blocks`, which holds the schemas for what the agent can show and ask.
|
|
279
|
+
|
|
280
|
+
## Trust boundaries
|
|
281
|
+
|
|
282
|
+
- **Only the server writes the paused turn.** The saved messages are replayed to the model
|
|
283
|
+
verbatim, so a client must not be able to plant or edit them. The history REST API drops
|
|
284
|
+
`pendingAsk` from create and update bodies, and the answer a client sends is only the envelope,
|
|
285
|
+
checked against the stored ask.
|
|
286
|
+
- **A button id answers only with the stored card.** The server resolves `buttonId` on the card
|
|
287
|
+
it saved when the ask was made. An id that is not on the card gets 400 `UNKNOWN_BUTTON`, and the
|
|
288
|
+
ask stays pending.
|
|
289
|
+
- **A client can only answer an approval, never make one.** The approval request and the
|
|
290
|
+
`tool-approval-response` live only in the server's stored messages. A client sends the answer
|
|
291
|
+
envelope for the pending ask's `toolCallId`, and the server builds the response for the
|
|
292
|
+
`approvalId` it stored. A crafted approval response in the body gets 400, and another tool
|
|
293
|
+
call's id gets 409.
|
|
294
|
+
- **A turn speaks as the conversation's owner.** Only the owner can send a `turn`. An admin gets
|
|
295
|
+
403, because the model would read the admin's message or answer as the owner's.
|
|
296
|
+
- **Asks do not collect secrets.** The system prompt tells the model never to ask for passwords,
|
|
297
|
+
payment card numbers, API keys, or other secrets.
|
|
298
|
+
- **HTML is display-only and opt-in.** An `html` block can be used for XSS, phishing, exfiltration, or clickjacking if it runs as part of the host page. The server allows it only when `uiBlocks.html` is on, strips scripts, event handlers, forms, frames, `meta`/`base`/`link`, anchor `href`s, and every URL except `data:image`, then stores that cleaned document. The client draws it in a sandboxed frame with no JavaScript and no navigation, labeled as an agent-generated preview, and only after the reply has finished.
|
|
299
|
+
- **Image hosts are an allowlist.** An `image` block can leak conversation data by requesting a URL the model chooses. `data:image` URLs and `file:` refs are always allowed. An `https` URL loads only when its hostname is in `uiBlocks.imageHosts`. The default list is empty, so a hostile prompt cannot point the image at a server the app did not name.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Agent UI blocks
|
|
2
|
+
|
|
3
|
+
When a chat turns UI blocks on, the assistant reply is one YAML document of Terreno
|
|
4
|
+
components, not free HTML or JSX. This page explains why the catalog is closed and why the
|
|
5
|
+
whole reply is the document. Field tables and error codes are in the
|
|
6
|
+
[reference](../reference/blocks.md). Asks, which return a typed answer to the agent, stay
|
|
7
|
+
in [Agent UI Asks](agent-ui-asks.md).
|
|
8
|
+
|
|
9
|
+
The design record is
|
|
10
|
+
[agent-ui-blocks.md](../implementationPlans/agent-ui-blocks.md). Pick executes that work as
|
|
11
|
+
Phase 5 of [Agent UI Asks](../implementationPlans/agent-ui-asks.md).
|
|
12
|
+
|
|
13
|
+
## Why a closed catalog
|
|
14
|
+
|
|
15
|
+
An agent that can emit arbitrary components can also emit styles, scripts, and layout the
|
|
16
|
+
app theme does not own. The catalog is the list of `@terreno/ui` components the chat can
|
|
17
|
+
paint: heading, text, metric, badge, divider, context, chart, table, actions, and the two
|
|
18
|
+
layout blocks `columns` and `card`. Unknown
|
|
19
|
+
keys and unknown block types fail validation, so a reply cannot smuggle a `style` object
|
|
20
|
+
or a hex color.
|
|
21
|
+
|
|
22
|
+
The same pure functions run wherever a document is checked. `parseBlocks` and
|
|
23
|
+
`validateBlocks` live in `@terreno/blocks`, which has no React and no Express. The renderer
|
|
24
|
+
and the chat route will call them; they do not grow a second grammar.
|
|
25
|
+
|
|
26
|
+
## Why the whole reply is YAML
|
|
27
|
+
|
|
28
|
+
The document is the reply. There is no prose before it and no fenced block inside
|
|
29
|
+
markdown. A leading or trailing fence is stripped so a model that adds one still parses.
|
|
30
|
+
JSON is accepted because it is a YAML 1.2 subset and is what a structured-output call
|
|
31
|
+
emits.
|
|
32
|
+
|
|
33
|
+
Key order is `v`, then `datasets`, then `blocks`. Datasets come first so a streaming
|
|
34
|
+
renderer can know the data before the first block. `parseBlocksPartial` returns the
|
|
35
|
+
top-level blocks that have already finished and marks the cut tail pending. The finished
|
|
36
|
+
document is still checked with `validateBlocks`.
|
|
37
|
+
|
|
38
|
+
Text in the same step as a tool call is not the reply, except when that text is a block document and no later step has text. An actions block that left off `id` receives one before storage, and the chat is sent the corrected document.
|
|
39
|
+
|
|
40
|
+
A reply that is not a mapping with `v` is not a document. The chat can still show it:
|
|
41
|
+
`wrapAsTextDocument` turns the raw text into one `text` block. The error
|
|
42
|
+
`NOT_A_DOCUMENT` is how the caller tells that case apart from a document that failed a
|
|
43
|
+
field check.
|
|
44
|
+
|
|
45
|
+
YAML anchors, aliases, and explicit tags are rejected. They let one node stand in for
|
|
46
|
+
another, which makes a small document able to expand in ways the limits cannot see.
|
|
47
|
+
|
|
48
|
+
## Ownership
|
|
49
|
+
|
|
50
|
+
| Layer | Owns |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `@terreno/blocks` | Schema, parse, partial parse, validate, JSON Schema, prompt section, CLI, limits, error codes |
|
|
53
|
+
| `@terreno/ui` | `BlocksView` for heading, text, metric, badge, divider, context, chart, table, actions, columns, and card. A select action changes the target chart or table dataset in that view. A segmented control starts on the option that matches that dataset. A callback stays disabled when `hostActions` is set and does not include its name. `GPTChat` `uiBlocks` renders assistant messages through `BlocksView`, including a spinner while the reply is still streaming. A `file:` image stays as alt text until `resolveImage` returns a URL. |
|
|
54
|
+
| `@terreno/ai` | When `uiBlocks` is on: the chat system prompt, the post-stream `{blocks}` check, `AIDataset` storage, and `POST /gpt/actions`. `AIService.generateBlocks` returns one validated document outside a chat turn. |
|
|
55
|
+
|
|
56
|
+
Asks use the same package under `src/asks/`. A block shows something. An ask collects an
|
|
57
|
+
answer and returns it to the agent.
|
|
58
|
+
|
|
59
|
+
A callback button is the Block Kit `block_actions` path. The document names the callback.
|
|
60
|
+
The host owns the code.
|
|
61
|
+
|
|
62
|
+
| | Block Kit | Terreno |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| Who runs the click | Slack posts `block_actions` to the app | The client posts `POST /gpt/actions` |
|
|
65
|
+
| Payload check | The app trusts the platform body | The host Zod schema runs before the handler |
|
|
66
|
+
| What comes back | `chat.update` replaces the message | `{replace, blocks}` replaces the block, or `{text}` appends a message |
|
|
67
|
+
| Time limit | The platform's request window | 10 seconds, then 504 |
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# AI observability
|
|
2
|
+
|
|
3
|
+
Terreno ships Langfuse-like prompt versioning, nested traces, evaluators, datasets, experiments, and a local human review queue **inside `@terreno/ai`**, with operator UI in **`admin-frontend` only**.
|
|
4
|
+
|
|
5
|
+
## Why not Langfuse-only
|
|
6
|
+
|
|
7
|
+
Apps must iterate prompts without a required vendor account. `LangfuseApp` stays a second plugin. Traces and scores **fan out** to every registered sink (local Mongo, Langfuse, OTLP/OpenInference). Prompts, datasets, experiments, and the review queue have **one writer** each (a primary). The review queue is **local-only**.
|
|
8
|
+
|
|
9
|
+
That split is the two planes:
|
|
10
|
+
|
|
11
|
+
| Plane | Write | Admin read |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| Telemetry (traces, scores) | All sinks, best-effort | Local if the local plugin is on, else Langfuse |
|
|
14
|
+
| Control (prompts, datasets, experiments, queue) | One primary per capability | That primary |
|
|
15
|
+
|
|
16
|
+
`experiments.primary` must equal `datasets.primary`. Defaults are all `local`.
|
|
17
|
+
|
|
18
|
+
`ObservabilityApp` fails boot (constructor throws) when:
|
|
19
|
+
|
|
20
|
+
| Error | Cause |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `experiments.primary must equal datasets.primary` | Mixed dataset/experiment writers |
|
|
23
|
+
| `reviewQueue.primary must be local` | `reviewQueue` set to `langfuse` |
|
|
24
|
+
| `<capability> primary "<id>" has no plugin` | No registered plugin with that `id`, capability, and store |
|
|
25
|
+
|
|
26
|
+
Telemetry sinks still fan out even when a control primary is local-only.
|
|
27
|
+
|
|
28
|
+
Admin chrome lives in `admin-frontend`. One sidebar group **AI Observability** holds Prompts,
|
|
29
|
+
Traces, Evaluators, Datasets, Experiments, and Review (Review is omitted when the local plugin is
|
|
30
|
+
off). Existing **AI Requests** stays under **Screens**. Every observability screen shows
|
|
31
|
+
breadcrumbs `Admin / AI Observability / <Section> / <leaf>` and a status chip from
|
|
32
|
+
`GET /ai/observability/status`. Prompt detail is a hub (Overview, Versions, Traces, Experiments);
|
|
33
|
+
versions are immutable in admin (`Save as vN+1`); apps never inline the string.
|
|
34
|
+
|
|
35
|
+
## Why this shape for product work
|
|
36
|
+
|
|
37
|
+
Flourish AI features follow an 8-step loop: gold dataset → labels → prompt versions → evaluators → experiments with gates → `production` label → live traces and in-app feedback → weak traces back into the dataset.
|
|
38
|
+
|
|
39
|
+
That loop is the product requirement, not an optional dashboard. Operator steps: [Develop an AI feature](../how-to/ai-feature-development.md). Registration and env: [Observe LLM calls](../how-to/observe-llm-calls.md). Models and routes: [AI reference](../reference/ai.md). Design lock: [implementation plan](../implementationPlans/ai-observability.md).
|
|
40
|
+
|
|
41
|
+
## RBAC (domain-neutral)
|
|
42
|
+
|
|
43
|
+
Observability HTTP routes use Terreno RBAC resources (`aiPrompt`, `aiTrace`, `aiReview`,
|
|
44
|
+
`aiDataset`, `aiExperiment`, `aiEvaluator`) instead of domain-specific approval vocabulary.
|
|
45
|
+
Pass the same `accessControl` instance on `ObservabilityApp` and `AdminApp` so
|
|
46
|
+
`GET /ai/observability/status` permissions match `/admin/config` screen filtering. Admin widgets
|
|
47
|
+
hide write controls from status flags (fail closed while status is loading or errored). Seeded
|
|
48
|
+
`admin` receives every observability action; `auditor` receives `list` and
|
|
49
|
+
`read` on each observability resource through the read-only sentinel (but not `admin:access` —
|
|
50
|
+
pair auditor with a shell grant in a composed consumer role); `superadmin` receives `*`. Existing
|
|
51
|
+
customized `admin` and `auditor` roles gain only missing observability actions on re-seed. Legacy
|
|
52
|
+
`user.admin` remains a full-access fallback when RBAC is enabled. See
|
|
53
|
+
[API reference](../reference/api.md#ai-observability-rbac). The example backend registers
|
|
54
|
+
`accessControl: access` on `ObservabilityApp` and seeds optional `aiObservabilityViewer` and
|
|
55
|
+
`aiObservabilityOperator` roles (not auto-assigned) in `example-backend/src/rbacRoles.ts`.
|
|
56
|
+
|
|
57
|
+
`AIRequest` remains the cheap per-call log. Observability traces are the nested, scored, user/session/cost record used in the SOP.
|
|
58
|
+
|
|
59
|
+
## Prompt hub relationships (phase 4)
|
|
60
|
+
|
|
61
|
+
Prompts may carry an optional domain-neutral `description` (folder semantics stay consumer-defined). `GET /ai/observability/prompts/:name` composes bounded recent **traces** and **experiments** for that prompt name so the admin hub does not scan full trace or experiment lists on the client. Trace evidence is version-aware: list filters accept `prompt` plus optional `promptVersion`, and relationship trace rows include the matching `promptVersion` from `ObsTrace.prompts[]`. The same optional positive integer `promptVersion` on prompt detail filters hub traces with `$elemMatch` on `prompts` (name and version). Omit it to keep the unfiltered prompt-name match. Invalid or non-positive versions return **400**. Experiment relationships filter server-side by `promptName` on `GET /ai/observability/experiments?promptName=…` (omit or pass an empty value for no filter). The experiment list endpoint returns summary rows without per-item hydration; use experiment detail for full item results. Reading relationships uses the same `aiPrompt:read` grant as prompt detail.
|
|
62
|
+
|
|
63
|
+
## Phase 1 reference loop
|
|
64
|
+
|
|
65
|
+
The example backend always registers the local plugin. Its idempotent seed creates
|
|
66
|
+
`examples/example-summarize` with production on v1 and an experimental v2, the human
|
|
67
|
+
`correctness-human` evaluator, the automatic `schema-assert` evaluator, and a two-item
|
|
68
|
+
`example-gold` dataset. It also creates `chat-safety-synthetic`, a proofread set of
|
|
69
|
+
two-person chats for routing, toxicity, privacy, and dismissive-reply experiments.
|
|
70
|
+
This makes the review and experiment loops walkable without Langfuse:
|
|
71
|
+
resolve the production prompt → emit a trace → inspect spans and sensitive I/O → send the
|
|
72
|
+
trace to Review → record a human score.
|
|
73
|
+
|
|
74
|
+
The example frontend supplies that first step from real product usage: **Todos → Summarize**
|
|
75
|
+
calls `/ai/example-summarize`, which resolves `example-summarize` by name and `production`
|
|
76
|
+
label server-side. Prompt selection stays on the server, while the client only contributes
|
|
77
|
+
identity (`x-ai-session-id`) and, when the backend has no provider credentials, the user's own
|
|
78
|
+
key.
|
|
79
|
+
|
|
80
|
+
`AI_OBS_PRICE_MAP_JSON` belongs to deployment configuration because prices change
|
|
81
|
+
independently of prompt versions. A missing model price preserves token counts and omits
|
|
82
|
+
USD cost; it never invents `$0`.
|
|
83
|
+
|
|
84
|
+
## Phase 2 backend (evaluators, datasets, experiments)
|
|
85
|
+
|
|
86
|
+
With the local plugin registered and primaries set to `local`, the control plane now includes:
|
|
87
|
+
|
|
88
|
+
- **`llm-judge` and `json-assert` evaluators** — judges call `AIService.generateJsonObject` through a named registry prompt (`judgePromptName`); create/update rejects a judge when the prompt `outputSchema` omits a required dimension (the error names that key). `json-assert` supports path/constraint checks and a built-in mode that validates output against the prompt version `outputSchema`. Parse or generation failures record an error outcome instead of throwing to the caller. Custom creates score a full trace; generation span and dataset item targets are reserved. Seeded templates install `correctness` / `hallucination` / `helpfulness` / `toxicity` as `llm-judge`, `schema-assert` as `json-assert`, and `*-human` variants for the review queue.
|
|
89
|
+
- **Human review evaluators** — sending traces to review requires a `human` evaluator. Its dimensions become the review item's score fields and its instructions tell the reviewer how to apply them. `llm-judge` and `json-assert` evaluators run automatically and are rejected from the human queue.
|
|
90
|
+
- **Datasets** — CRUD plus item provenance (`origin`, `proofread`, `sourceTraceId`, tags, outcome class). Import accepts **JSON** (bare objects or structured rows) and **CSV** (quoted fields; `input.*` / `expectedOutput.*` column prefixes). Rows validate against the bound prompt input schema when configured. Adding from a trace copies I/O; sensitive traces always land `proofread: false`. Deleting an item never mutates the trace.
|
|
91
|
+
- **Experiments** — compare 2–3 prompt versions on a dataset with optional `modelOverride` (requires `aiServiceFactory` on `ObservabilityApp`), evaluator thresholds (defaulting to the SOP gates), **per-version gate tiles** (`gates[].version`), outlier/low-confidence item ids, and version-scoped promote (**409** when the selected version's gates fail). Unproofread items are excluded unless `includeUnproofread` is true. Local runs compile exact prompt versions and call `AIService.generateText` with compiled `prompt` + `systemPrompt` (no `promptName`/`promptLabel`). `ObservabilityApp` wires the local experiment runner from `aiService` at register time.
|
|
92
|
+
|
|
93
|
+
Operator UI for datasets and experiments ships in tasks 2.7–2.9; routes and stores are live for API clients and tests today.
|
|
94
|
+
|
|
95
|
+
## GPT tool spans and nested traces
|
|
96
|
+
|
|
97
|
+
`/gpt/prompt` streams tool rounds to the client **and** records them in observability traces. Each provider `tool-call` / `tool-result` pair becomes a `TOOL` span (input args, cleaned output, timing, `parentSpanId` on the chain root). When tools run, the exported trace uses a `CHAIN` root wrapping those `TOOL` children instead of a lone `LLM` span. Plain `AIService.generate*` calls without tool children still log a single `LLM` root.
|
|
98
|
+
|
|
99
|
+
## Admin multi-stage trace smoke test
|
|
100
|
+
|
|
101
|
+
`POST /ai/observability/traces/test-multi-stage` (admin-only) exercises a real multi-stage workflow through the configured `ObservabilityApp.aiService`: two `generateJsonObject` LLM passes (`skipTrace: true`) with named output schemas, a deterministic local `text-metrics` tool stage, then a final synthesis `generateJsonObject` call against a combining schema. System prompts live in `OBS_TEST_MULTI_STAGE_*` constants in `ai/src/service/prompts.ts` (never inline). Each LLM span records `{prompt, schemaName, outputSchema}` as input and the parsed object as output. The handler exports one parent `TraceRecord` via `ObservabilityApp.exportTrace` with child span order `LLM`, `LLM`, `TOOL`, `LLM` and returns `{traceId, output, stages}` so operators can open the persisted trace in admin. Missing `aiService` → **503**; child failure exports an error trace then propagates the error.
|