@plurnk/plurnk-agui 3.1.0 → 4.0.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.
Files changed (74) hide show
  1. package/.env.defaults +17 -8
  2. package/README.md +33 -13
  3. package/SPEC.md +589 -268
  4. package/dist/AguiPlus.d.ts +24 -29
  5. package/dist/AguiPlus.d.ts.map +1 -1
  6. package/dist/AguiPlus.js +25 -36
  7. package/dist/AguiPlus.js.map +1 -1
  8. package/dist/AguiSurface.d.ts +1 -2
  9. package/dist/AguiSurface.d.ts.map +1 -1
  10. package/dist/AguiSurface.js +23 -16
  11. package/dist/AguiSurface.js.map +1 -1
  12. package/dist/AuthorizationDocuments.d.ts +33 -0
  13. package/dist/AuthorizationDocuments.d.ts.map +1 -0
  14. package/dist/AuthorizationDocuments.js +84 -0
  15. package/dist/AuthorizationDocuments.js.map +1 -0
  16. package/dist/BuiltinActions.d.ts +11 -5
  17. package/dist/BuiltinActions.d.ts.map +1 -1
  18. package/dist/BuiltinActions.js +73 -125
  19. package/dist/BuiltinActions.js.map +1 -1
  20. package/dist/EventRouter.d.ts +11 -3
  21. package/dist/EventRouter.d.ts.map +1 -1
  22. package/dist/EventRouter.js +17 -32
  23. package/dist/EventRouter.js.map +1 -1
  24. package/dist/MessageAddress.d.ts +7 -5
  25. package/dist/MessageAddress.d.ts.map +1 -1
  26. package/dist/MessageAddress.js +14 -12
  27. package/dist/MessageAddress.js.map +1 -1
  28. package/dist/Module.d.ts +5 -4
  29. package/dist/Module.d.ts.map +1 -1
  30. package/dist/Module.js +221 -82
  31. package/dist/Module.js.map +1 -1
  32. package/dist/OAuthServer.d.ts +30 -0
  33. package/dist/OAuthServer.d.ts.map +1 -0
  34. package/dist/OAuthServer.js +607 -0
  35. package/dist/OAuthServer.js.map +1 -0
  36. package/dist/Portal.d.ts +39 -9
  37. package/dist/Portal.d.ts.map +1 -1
  38. package/dist/Portal.js +414 -141
  39. package/dist/Portal.js.map +1 -1
  40. package/dist/ProposalHitl.d.ts +17 -4
  41. package/dist/ProposalHitl.d.ts.map +1 -1
  42. package/dist/ProposalHitl.js +30 -43
  43. package/dist/ProposalHitl.js.map +1 -1
  44. package/dist/RunHandler.d.ts +18 -8
  45. package/dist/RunHandler.d.ts.map +1 -1
  46. package/dist/RunHandler.js +214 -96
  47. package/dist/RunHandler.js.map +1 -1
  48. package/dist/ThreadAddress.d.ts +8 -0
  49. package/dist/ThreadAddress.d.ts.map +1 -0
  50. package/dist/ThreadAddress.js +18 -0
  51. package/dist/ThreadAddress.js.map +1 -0
  52. package/dist/Translator.d.ts +52 -2
  53. package/dist/Translator.d.ts.map +1 -1
  54. package/dist/Translator.js +390 -117
  55. package/dist/Translator.js.map +1 -1
  56. package/dist/client.d.ts +2 -0
  57. package/dist/client.d.ts.map +1 -0
  58. package/dist/client.js +2 -0
  59. package/dist/client.js.map +1 -0
  60. package/dist/config.d.ts +22 -2
  61. package/dist/config.d.ts.map +1 -1
  62. package/dist/config.js +65 -13
  63. package/dist/config.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +2 -0
  67. package/dist/index.js.map +1 -1
  68. package/dist/run-events.d.ts +1 -0
  69. package/dist/run-events.d.ts.map +1 -1
  70. package/dist/run-events.js +4 -2
  71. package/dist/run-events.js.map +1 -1
  72. package/dist/types.d.ts +4 -22
  73. package/dist/types.d.ts.map +1 -1
  74. package/package.json +13 -9
@@ -1,14 +1,15 @@
1
- import { TurnDisposition } from "@plurnk/plurnk-contracts";
2
1
  // The projection — plurnk's log-shaped wire onto AG-UI's event vocabulary. PURE: one daemon
3
2
  // notification in, zero-or-more AG-UI events out, with per-worker turn tracking as the only state.
4
3
  // The mapping ({§agui-projection}):
5
4
  // log/entry response (model) → optional standard reasoning lifecycle, then TEXT_MESSAGE triple
6
- // (assistant speech; the signal rides plurnk.send)
7
- // log/entry rejected emission → forensic row only, no conversational event
5
+ // (assistant speech, a reply or a WAIT's body)
6
+ // log/entry rejected emission → CUSTOM plurnk.ambient, no message event
8
7
  // log/entry other (model) → TOOL_CALL_START/ARGS/END + TOOL_CALL_RESULT (an op row IS a
9
- // tool call: tx is the args, rx the result, coordinate the id)
10
- // log/entry (plurnk) → CUSTOM plurnk.ambient (foists, deltas, narrations — the
11
- // environment speaking; generic UIs skip, rich UIs render)
8
+ // tool call: tx is the args, rx the result, coordinate the id; a bodiless WAIT too)
9
+ // log/entry arrival (plurnk) → TEXT_MESSAGE triple, role user, named by its source (another client's
10
+ // prompt, a worker's message), except the Run's own submission
11
+ // log/entry (plurnk) → CUSTOM plurnk.ambient (deltas, narrations — the environment
12
+ // speaking; generic UIs skip, rich UIs render)
12
13
  // outside/event → CUSTOM plurnk.outside (the thread's worker only: text the model
13
14
  // emitted outside every operation, never assistant speech)
14
15
  // turn_id advances → STEP_FINISHED/STEP_STARTED
@@ -17,11 +18,13 @@ import { TurnDisposition } from "@plurnk/plurnk-contracts";
17
18
  // Numbers are passed through verbatim, never recomputed — the daemon's gauge is the gauge
18
19
  // ({§agui-numbers-passthrough}).
19
20
  import { EventType, } from "./types.js";
21
+ import { aggregateTokenUsage, tokenUsageFromAiSdkUsage } from "@ag-ui/core";
20
22
  import { derivationActivity } from "./AguiPlus.js";
21
23
  import MessageAddress from "./MessageAddress.js";
22
24
  import { Validator, lifecycleOfLoopStatus } from "@plurnk/plurnk-contracts";
23
25
  export default class Translator {
24
26
  #threadId;
27
+ #replyThreads;
25
28
  #runId; // AG-UI's Run id (echoed from RunAgentInput.runId) — the standard face
26
29
  #currentTurn = null;
27
30
  #stepOpen = false;
@@ -29,12 +32,23 @@ export default class Translator {
29
32
  #workspaceId;
30
33
  #activeReasoning = new Map();
31
34
  #completedReasoning = new Map();
35
+ // {§agui-snapshot} The messages this Run submitted: its client already holds them.
36
+ #submitted = new Set();
37
+ // {§agui-call-result} The calls this Run's client holds without their result: a gate's call it presented, or a
38
+ // pending one its snapshot held. A later delivery of that row only answers it.
39
+ #held = new Set();
40
+ // {§agui-subagents} A delegation this Run observes: the subagent run its events are attributed to, and the worker
41
+ // whose rows' identities it namespaces; null for the Run's own worker.
42
+ #subagent;
32
43
  constructor(args) {
44
+ this.#subagent = args.subagent ?? null;
33
45
  this.#threadId = args.threadId;
46
+ this.#replyThreads = args.replyThreads ?? [args.threadId];
34
47
  this.#runId = args.runId;
35
48
  this.#currentTurn = args.continuation?.currentTurn ?? null;
36
49
  this.#modelWorkerId = args.continuation?.modelWorkerId ?? args.modelWorkerId ?? null;
37
50
  this.#completedReasoning = new Map(args.continuation?.completedReasoning.map(([turnId, values]) => [turnId, [...values]]) ?? []);
51
+ this.#held = args.held ?? new Set(args.continuation?.held ?? []);
38
52
  this.#workspaceId = args.workspaceId ?? null;
39
53
  }
40
54
  #continuation() {
@@ -45,6 +59,7 @@ export default class Translator {
45
59
  currentTurn: this.#currentTurn,
46
60
  modelWorkerId: this.#modelWorkerId,
47
61
  completedReasoning: [...this.#completedReasoning].map(([turnId, values]) => [turnId, [...values]]),
62
+ held: [...this.#held],
48
63
  };
49
64
  }
50
65
  runStarted(state) {
@@ -62,6 +77,10 @@ export default class Translator {
62
77
  }
63
78
  return events;
64
79
  }
80
+ // A message this Run submits arrives in its worker's log like any other; its own client already holds it.
81
+ submitted(messageId) {
82
+ this.#submitted.add(messageId);
83
+ }
65
84
  // {§agui-gate-deferral} — a stopped-world cannot split this lifecycle; the Portal holds it until the end.
66
85
  get reasoningOpen() { return this.#activeReasoning.size > 0; }
67
86
  interrupt() {
@@ -86,79 +105,136 @@ export default class Translator {
86
105
  this.#currentTurn = null;
87
106
  this.#stepOpen = false;
88
107
  this.#completedReasoning.clear();
89
- return events;
108
+ return this.#attributed(events);
109
+ }
110
+ // {§agui-readable-reasoning} A Run ended before its request is (revoked, stopped, failed) still
111
+ // balances what it opened; the request itself streams on to the Runs still observing it.
112
+ fail() {
113
+ const events = [];
114
+ for (const { messageId } of this.#activeReasoning.values()) {
115
+ events.push({ type: EventType.REASONING_MESSAGE_END, messageId }, { type: EventType.REASONING_END, messageId });
116
+ }
117
+ this.#activeReasoning.clear();
118
+ return [...events, ...this.finish()];
90
119
  }
91
120
  logEntry(n) {
121
+ return this.#attributed(this.#project(n));
122
+ }
123
+ // {§agui-subagents} The translator of a subagent run this Run presents: its own namespace, this Run's client.
124
+ subagentTranslator(args) {
125
+ return new Translator({ threadId: this.#threadId, runId: this.#runId, modelWorkerId: args.modelWorkerId, workspaceId: this.#workspaceId,
126
+ subagent: args.subagent, held: this.#held, replyThreads: this.#replyThreads });
127
+ }
128
+ // {§agui-proposal-resolve} A gate's call: the operation awaiting its decision, built as its row builds it and so
129
+ // unanswered. Its client then holds it, and the settled row updates and answers it ({§agui-call-result}).
130
+ gateCall(entry) {
131
+ if (this.#held.has(this.#id(entry)))
132
+ return [];
133
+ const events = this.#project({ entry });
134
+ this.#held.add(this.#id(entry));
135
+ return this.#attributed(events);
136
+ }
137
+ #project(n) {
92
138
  const e = n.entry;
93
139
  const events = [];
94
- const clientEntry = e;
95
140
  // {§agui-topology-scope} — the workspace broadcast carries EVERY worker's rows (workers, the
96
141
  // plurnk worker, siblings); only the THREAD's model worker projects onto the core vocabulary.
97
- // Everything else rides plurnk.row/plurnk.ambient — visible to rich clients as topology,
98
- // never interleaved into the conversation a generic frontend renders.
142
+ // Another worker's row is workspace activity, never interleaved into the messages a generic frontend renders.
99
143
  const workerId = e.worker_id;
100
- // Lazy binding: workspace.create returns the CLIENT worker's id — the model worker is born at
101
- // loop worker's drain, so a fresh thread adopts its first model-origin row's worker as the
102
- // model worker (workers spawn FROM it later; reattach seeds it from workspace.workers instead).
144
+ // Lazy binding: a Run that binds no model worker adopts its first model-origin row's worker as the
145
+ // model worker (workers spawn FROM it later).
103
146
  if (this.#modelWorkerId === null && e.origin === "model" && typeof workerId === "number")
104
147
  this.#modelWorkerId = workerId;
105
148
  const foreign = this.#modelWorkerId !== null && typeof workerId === "number" && workerId !== this.#modelWorkerId;
106
- // {§agui-row-channel} — the complete client-facing row rides plurnk.row alongside the core projection:
107
- // curation metadata, durable tags, coordinates — everything clients render that
108
- // the core vocabulary can't hold. Rich clients render the original operations.
109
- const row = { type: EventType.CUSTOM, name: "plurnk.row", value: clientEntry };
110
- if (foreign) {
111
- events.push(row);
112
- events.push({ type: EventType.CUSTOM, name: "plurnk.ambient", value: clientEntry });
113
- return events;
114
- }
115
- // A family client renders the SEND from plurnk.row rather than duplicating
116
- // TEXT_MESSAGE. Delay that one mirror until after the standard reasoning
117
- // lifecycle so both generic and family clients observe reasoning before speech.
118
- const response = Translator.isResponse(e, this.#threadId);
119
- const lifecycle = typeof e.op === "string" && TurnDisposition.isOp(e.op);
120
- const reasoningRow = response || lifecycle || e.op === "NOTE";
121
- const delayedSendRow = e.origin === "model" && reasoningRow;
122
- if (!delayedSendRow)
123
- events.push(row);
149
+ // {§agui-ambient} A row that builds nothing in the conversation rides whole.
150
+ const ambient = { type: EventType.CUSTOM, name: "plurnk.ambient", value: e };
151
+ if (foreign)
152
+ return [ambient];
124
153
  if (typeof e.turn_id === "number")
125
154
  events.push(...this.#enterTurn(e.turn_id));
126
- if (e.origin !== "model" && !response) {
127
- events.push({ type: EventType.CUSTOM, name: "plurnk.ambient", value: clientEntry });
155
+ const arrival = this.#arrival(e);
156
+ if (arrival !== null) {
157
+ // {§agui-plurnk-metadata} The Run that submitted a message already holds it: it receives only the row's facts,
158
+ // which the reducer folds onto that message.
159
+ if (this.#submitted.has(arrival.id)) {
160
+ events.push({ type: EventType.TEXT_MESSAGE_START, messageId: arrival.id, role: "user", metadata: Translator.#rowMetadata(e) });
161
+ events.push({ type: EventType.TEXT_MESSAGE_END, messageId: arrival.id });
162
+ return events;
163
+ }
164
+ events.push({ type: EventType.TEXT_MESSAGE_START, messageId: arrival.id, role: "user", ...(arrival.name === undefined ? {} : { name: arrival.name }), metadata: Translator.#rowMetadata(e) });
165
+ events.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: arrival.id, delta: arrival.content });
166
+ events.push({ type: EventType.TEXT_MESSAGE_END, messageId: arrival.id });
128
167
  return events;
129
168
  }
130
- const id = e.coordinate ?? String(e.id);
131
- if (reasoningRow) {
132
- const text = Translator.#txBody(e.tx);
133
- events.push(...Translator.#readableReasoningEvents(id, Translator.#claimReasoning(this.#completedReasoning, e.turn_id, e.reasoning)));
134
- if (delayedSendRow)
135
- events.push(row);
136
- // {§agui-projection} — a reply SEND speaks; so does a WAIT that carries a body (#1003): the card
137
- // teaches both as the worker's words to the user. A bodiless WAIT is lifecycle alone.
138
- const waitSpeech = e.op === "WAIT" && e.origin === "model" && text.trim().length > 0;
139
- if (response || waitSpeech) {
140
- events.push({ type: EventType.TEXT_MESSAGE_START, messageId: id, role: "assistant" });
141
- events.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, delta: text });
169
+ const response = Translator.isResponse(e, this.#replyThreads);
170
+ if (e.origin !== "model" && !response) {
171
+ const answered = this.#answered(e);
172
+ if (answered !== null) {
173
+ events.push({ type: EventType.TOOL_CALL_RESULT, toolCallId: answered, messageId: this.#id(e), content: Translator.#delivered(e), metadata: Translator.#rowMetadata(e) });
174
+ return events;
175
+ }
176
+ // {§agui-progress-reply} Progress on a message this worker sent is a message from the one who reported it.
177
+ if (typeof e.answering === "string") {
178
+ const id = this.#id(e);
179
+ const text = Translator.#txBody(e.tx);
180
+ events.push({ type: EventType.TEXT_MESSAGE_START, messageId: id, role: "user", ...(typeof e.source === "string" ? { name: e.source } : {}), metadata: Translator.#rowMetadata(e) });
181
+ if (text.length > 0)
182
+ events.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, delta: text });
142
183
  events.push({ type: EventType.TEXT_MESSAGE_END, messageId: id });
184
+ return events;
143
185
  }
144
- if (response || lifecycle) {
145
- events.push({ type: EventType.CUSTOM, name: "plurnk.send", value: { signal: e.signal, status: e.status_rx, coordinate: e.coordinate } });
186
+ events.push(ambient);
187
+ return events;
188
+ }
189
+ const id = this.#id(e);
190
+ // {§agui-proposal-resolve} The client holds this gated call: its settlement is a repeated START and END, which the
191
+ // reducer folds onto the call it holds, its arguments untouched, then its answer unless something later answers it
192
+ // ({§agui-call-result}).
193
+ if (this.#held.has(id)) {
194
+ if (e.state === "proposed")
146
195
  return events;
196
+ this.#held.delete(id);
197
+ const metadata = Translator.#rowMetadata(e);
198
+ events.push({ type: EventType.TOOL_CALL_START, toolCallId: id, toolCallName: Translator.#callOf(e).name, metadata });
199
+ events.push({ type: EventType.TOOL_CALL_END, toolCallId: id });
200
+ if (!Translator.#answeredLater(e)) {
201
+ events.push({ type: EventType.TOOL_CALL_RESULT, toolCallId: id, messageId: `${id}/result`, content: Translator.#asText(e.rx), metadata });
147
202
  }
203
+ return events;
204
+ }
205
+ if (e.op !== null) {
206
+ events.push(...Translator.#readableReasoningEvents(Translator.#reasoningId(e, id), Translator.#claimReasoning(this.#completedReasoning, e.turn_id, e.reasoning)));
207
+ }
208
+ // {§agui-projection} — a reply SEND speaks; so does a WAIT that carries a body (#1003): the card
209
+ // teaches both as the worker's words to the user. A bodiless WAIT is an operation like any other.
210
+ const text = Translator.#txBody(e.tx);
211
+ if (response || Translator.#waitSpeech(e, text)) {
212
+ events.push({ type: EventType.TEXT_MESSAGE_START, messageId: id, role: "assistant", metadata: Translator.#speechMetadata(e) });
213
+ events.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: id, delta: text });
214
+ events.push({ type: EventType.TEXT_MESSAGE_END, messageId: id });
215
+ return events;
148
216
  }
149
217
  if (e.op === null) {
150
- const kind = Translator.#modelArtifactKind(e.attrs);
151
- if (kind === null) {
218
+ // {§rejected-emission-entry} An emission the daemon rejected builds no part.
219
+ if (Translator.#modelArtifactKind(e.attrs) === null) {
152
220
  throw new TypeError("An actionless model-origin row must carry attrs.kind=emissionAttempt.");
153
221
  }
222
+ events.push(ambient);
154
223
  return events;
155
224
  }
156
- events.push({ type: EventType.TOOL_CALL_START, toolCallId: id, toolCallName: e.op });
157
- events.push({ type: EventType.TOOL_CALL_ARGS, toolCallId: id, delta: Translator.#argsFor(e) });
225
+ // {§agui-parse-refusal} A refusal is the call the worker attempted; one that names no attempt builds no part.
226
+ const call = Translator.#callOf(e);
227
+ if (call === null) {
228
+ events.push(ambient);
229
+ return events;
230
+ }
231
+ const metadata = Translator.#rowMetadata(e);
232
+ events.push({ type: EventType.TOOL_CALL_START, toolCallId: id, toolCallName: call.name, metadata });
233
+ events.push({ type: EventType.TOOL_CALL_ARGS, toolCallId: id, delta: call.args });
158
234
  events.push({ type: EventType.TOOL_CALL_END, toolCallId: id });
159
- const rxText = Translator.#asText(e.rx);
160
- if (rxText.length > 0) {
161
- events.push({ type: EventType.TOOL_CALL_RESULT, toolCallId: id, messageId: `${id}/result`, content: rxText });
235
+ // {§agui-call-result} A call is answered by its receipt, unless something later answers it.
236
+ if (!Translator.#answeredLater(e)) {
237
+ events.push({ type: EventType.TOOL_CALL_RESULT, toolCallId: id, messageId: `${id}/result`, content: Translator.#asText(e.rx), metadata });
162
238
  }
163
239
  return events;
164
240
  }
@@ -167,7 +243,7 @@ export default class Translator {
167
243
  this.#modelWorkerId = event.workerId;
168
244
  if (event.workerId !== this.#modelWorkerId)
169
245
  return [];
170
- const messageId = `model-call-${event.modelCallId}/request-${event.requestSequence}/reasoning`;
246
+ const messageId = Translator.#requestReasoningId(event.modelCallId, event.requestSequence);
171
247
  if (event.phase === "start") {
172
248
  if (this.#activeReasoning.has(event.modelCallId)) {
173
249
  throw new TypeError(`Reasoning model call ${event.modelCallId} already started a physical request.`);
@@ -184,7 +260,10 @@ export default class Translator {
184
260
  return events;
185
261
  }
186
262
  const active = this.#activeReasoning.get(event.modelCallId);
187
- if (active === undefined || active.requestSequence !== event.requestSequence) {
263
+ // {§agui-readable-reasoning} A Run that joined after this request began presents none of it.
264
+ if (active === undefined)
265
+ return [];
266
+ if (active.requestSequence !== event.requestSequence) {
188
267
  throw new TypeError(`Reasoning model call ${event.modelCallId} request ${event.requestSequence} emitted ${event.phase} without a start.`);
189
268
  }
190
269
  if (active.turnId !== event.turnId || active.loopId !== event.loopId) {
@@ -226,9 +305,10 @@ export default class Translator {
226
305
  events.push({
227
306
  type: EventType.STATE_DELTA,
228
307
  delta: [
229
- { op: "replace", path: "/plurnk/status/lifecycle", value: result.status === 200 ? "completed" : "failed" },
308
+ // {§loop-lifecycle-vocabulary} — a loop that concluded with any result below 400 completed.
309
+ { op: "replace", path: "/plurnk/status/lifecycle", value: result.status < 400 ? "completed" : "failed" },
230
310
  { op: "replace", path: "/plurnk/status/loopId", value: n.loopId },
231
- { op: "replace", path: "/plurnk/status/activity", value: null },
311
+ { op: "replace", path: "/plurnk/status/terminatedAt", value: n.terminatedAt },
232
312
  { op: "replace", path: "/plurnk/status/waitUntil", value: null },
233
313
  { op: "replace", path: "/budget/curationWeight", value: n.usage.curationWeight },
234
314
  { op: "replace", path: "/budget/curationBudget", value: n.usage.curationBudget },
@@ -236,13 +316,6 @@ export default class Translator {
236
316
  { op: "replace", path: "/budget/contextCapacity", value: n.usage.contextCapacity },
237
317
  ],
238
318
  });
239
- // Family channel — the full terminal truth the core STATE_DELTA can't hold
240
- // (loopId, turnIds, physical-request accounting, usage meta, attribution) PLUS the daemon workspaceId, so a
241
- // plurnk client rebuilds its json record from the stream with ONE schema
242
- // and no second round-trip. Numbers verbatim
243
- // ({§agui-numbers-passthrough}). Generic frontends ignore it; the RUN_FINISHED/
244
- // RUN_ERROR below is their terminal signal.
245
- events.push({ type: EventType.CUSTOM, name: "plurnk.terminated", value: { ...n, workspaceId: this.#workspaceId } });
246
319
  // The standard RAW channel (§475): the provider's NATIVE completion frame rides
247
320
  // usage.meta (finish_reason, model, timings, id, …) — AG-UI's RAW is exactly this,
248
321
  // a passthrough of an external system's own event with a source tag. Generic
@@ -250,77 +323,130 @@ export default class Translator {
250
323
  if (n.usage.meta !== undefined && n.usage.meta !== null && Object.keys(n.usage.meta).length > 0) {
251
324
  events.push({ type: EventType.RAW, event: n.usage.meta, source: "provider" });
252
325
  }
253
- if (result.status === 200) {
254
- events.push({ type: EventType.RUN_FINISHED, threadId: this.#threadId, runId: this.#runId, outcome: { type: "success" } });
326
+ // {§agui-run-outcome} A loop that completed finishes the Run with its result; one stopped without failing (499)
327
+ // finishes it cancelled; any other failure is the Run's error. Usage is each physical request's own counts, and
328
+ // the end's metadata is the loop's complete terminal record with the daemon's workspaceId, numbers verbatim, so
329
+ // a plurnk client rebuilds its record from the stream alone ({§agui-plurnk-metadata}, {§agui-numbers-passthrough}).
330
+ const usage = Translator.#tokenUsage(n.usage.accounting);
331
+ const metadata = { plurnk: { terminal: { ...n, workspaceId: this.#workspaceId } } };
332
+ if (result.status < 400) {
333
+ events.push({ type: EventType.RUN_FINISHED, threadId: this.#threadId, runId: this.#runId, outcome: { type: "success" }, result, ...usage, metadata });
334
+ }
335
+ else if (result.status === 499) {
336
+ events.push({ type: EventType.RUN_FINISHED, threadId: this.#threadId, runId: this.#runId, outcome: { type: "cancelled" }, ...usage, metadata });
255
337
  }
256
338
  else {
257
339
  events.push({
258
340
  type: EventType.RUN_ERROR,
259
341
  message: result.problem.detail,
260
342
  code: result.problem.type,
343
+ ...usage,
344
+ metadata,
261
345
  });
262
346
  }
263
347
  return events;
264
348
  }
265
- // {§agui-replay} — delivered model responses become assistant messages. Everything else
266
- // stays reachable through live plurnk.row rendering. Wire rows arrive as the
267
- // log.read projection (tx parsed).
349
+ // {§agui-run-outcome} AG-UI's token usage of the loop's physical requests, by the standard's own AI SDK mapping and
350
+ // per-model aggregation: the daemon's counts, never its gauge ({§agui-numbers-passthrough}). Unreported counts stay absent.
351
+ static #tokenUsage(accounting) {
352
+ const usage = aggregateTokenUsage(accounting.requests.flatMap(({ provider, model, usage: counts }) => tokenUsageFromAiSdkUsage(counts, { provider, model }) ?? []));
353
+ return usage.length === 0 ? {} : { usage };
354
+ }
355
+ // {§agui-replay} {§agui-snapshot} — the messages as the live projection built them: arrivals, each turn's
356
+ // reasoning, speech, and every other model operation as its tool call and result, under the
357
+ // live identities. Wire rows arrive as the log.read projection (tx parsed).
268
358
  replay(entries, currentUser) {
359
+ return Translator.replayAll([{ translator: this, entries }], currentUser);
360
+ }
361
+ // {§agui-snapshot} {§agui-subagents} One conversation from several logs: every row in the order the workspace
362
+ // committed it, each projected by its own worker's translator into one message list.
363
+ static replayAll(parts, currentUser) {
269
364
  const messages = [];
270
- const deliveredReasoning = new Map();
271
- const chronological = entries.toSorted((left, right) => {
272
- const leftId = typeof left.id === "number" ? left.id : Number.MAX_SAFE_INTEGER;
273
- const rightId = typeof right.id === "number" ? right.id : Number.MAX_SAFE_INTEGER;
274
- return leftId - rightId;
275
- });
276
- for (const e of chronological) {
277
- const id = String(e.coordinate ?? e.id);
278
- // {§message-arrival} — an arrival replays as a user message; another actor's arrival
279
- // carries its source as the message name ({§message-causal-source}).
280
- if (e.origin === "_plurnk" && e.op === "SEND" && Translator.#attrKind(e.attrs) === "message") {
281
- messages.push({ id: MessageAddress.messageId(e.source, this.#threadId) ?? id,
282
- role: "user", content: Translator.#txBody(e.tx), ...(typeof e.source === "string" ? { name: e.source } : {}) });
283
- continue;
284
- }
285
- const response = Translator.isResponse(e, this.#threadId);
286
- if (e.origin !== "model" && !response)
287
- continue;
288
- const text = Translator.#txBody(e.tx);
289
- if (response || e.op === "SEND" || e.op === "NOTE" || typeof e.op === "string" && TurnDisposition.isOp(e.op)) {
290
- const reasoning = Translator.#claimReasoning(deliveredReasoning, e.turn_id, e.reasoning);
291
- if (reasoning.length > 0)
292
- messages.push({ id: `${id}/reasoning`, role: "reasoning", content: reasoning });
293
- }
294
- if (response) {
295
- const message = { id, role: "assistant", content: text };
296
- messages.push(message);
297
- }
298
- if (e.op === null) {
299
- if (Translator.#modelArtifactKind(e.attrs) === null) {
300
- throw new TypeError("An actionless model-origin replay row must carry attrs.kind=emissionAttempt.");
301
- }
302
- }
365
+ const order = (e) => typeof e.id === "number" ? e.id : Number.MAX_SAFE_INTEGER;
366
+ const rows = parts.flatMap(({ translator, entries }) => entries.map((e) => ({ translator, e })))
367
+ .toSorted((left, right) => order(left.e) - order(right.e));
368
+ const delivered = new Map();
369
+ for (const { translator, e } of rows) {
370
+ const reasoning = delivered.get(translator) ?? new Map();
371
+ delivered.set(translator, reasoning);
372
+ translator.#replayRow(e, messages, reasoning);
303
373
  }
304
374
  if (currentUser !== undefined && !messages.some(({ id }) => id === currentUser.id)) {
305
375
  messages.push(currentUser);
306
376
  }
307
377
  return [{ type: EventType.MESSAGES_SNAPSHOT, messages }];
308
378
  }
309
- notice(notice) {
379
+ #replayRow(e, messages, deliveredReasoning) {
380
+ const attributed = (message) => this.#subagent === null ? message : { ...message, subagentRunId: this.#subagent.runId };
381
+ const id = this.#id(e);
382
+ const metadata = Translator.#rowMetadata(e);
383
+ const arrival = this.#arrival(e);
384
+ if (arrival !== null) {
385
+ messages.push(attributed({ id: arrival.id, role: "user", content: arrival.content, ...(arrival.name === undefined ? {} : { name: arrival.name }), metadata }));
386
+ return;
387
+ }
388
+ const response = Translator.isResponse(e, this.#replyThreads);
389
+ if (e.origin !== "model" && !response) {
390
+ const answered = this.#answered(e);
391
+ if (answered !== null)
392
+ Translator.#placeResult(messages, attributed({ id, role: "tool", toolCallId: answered, content: Translator.#delivered(e), metadata }));
393
+ // {§agui-progress-reply}
394
+ else if (typeof e.answering === "string") {
395
+ messages.push(attributed({ id, role: "user", content: Translator.#txBody(e.tx), ...(typeof e.source === "string" ? { name: e.source } : {}), metadata }));
396
+ }
397
+ return;
398
+ }
399
+ if (typeof e.op !== "string") {
400
+ if (Translator.#modelArtifactKind(e.attrs) === null) {
401
+ throw new TypeError("An actionless model-origin replay row must carry attrs.kind=emissionAttempt.");
402
+ }
403
+ return;
404
+ }
405
+ const reasoning = Translator.#claimReasoning(deliveredReasoning, e.turn_id, e.reasoning);
406
+ if (reasoning.length > 0)
407
+ messages.push(attributed({ id: Translator.#reasoningId(e, id), role: "reasoning", content: reasoning }));
408
+ const text = Translator.#txBody(e.tx);
409
+ if (response || Translator.#waitSpeech(e, text)) {
410
+ messages.push(attributed({ id, role: "assistant", content: text, metadata: Translator.#speechMetadata(e) }));
411
+ return;
412
+ }
413
+ const call = Translator.#callOf(e);
414
+ if (call === null)
415
+ return;
416
+ messages.push(attributed({ id, role: "assistant", toolCalls: [{ id, type: "function", function: { name: call.name, arguments: call.args }, metadata }] }));
417
+ if (e.state === "proposed")
418
+ this.#held.add(id);
419
+ if (Translator.#answeredLater(e))
420
+ return;
421
+ Translator.#placeResult(messages, attributed({ id: `${id}/result`, role: "tool", toolCallId: id, content: Translator.#asText(e.rx), metadata }));
422
+ }
423
+ notice(notice, loopId = null) {
310
424
  const diagnostic = { type: EventType.CUSTOM, name: "plurnk.notice", value: notice };
311
425
  const value = notice;
312
- // {§loop-status-notice} — lifecycle and its durable deadline, never a diagnostic or chat message.
426
+ // {§loop-status-notice} {§agui-status-timing} — the loop's lifecycle with its identity, packet count,
427
+ // stamps and deadline in one delta, never a diagnostic or chat message.
313
428
  if (value.source === "engine:lifecycle" && value.kind === "loop_status") {
314
429
  if (value.status !== 100 && value.status !== 102 && value.status !== 202)
315
430
  throw new TypeError("A loop_status notice carries 100, 102 or 202.");
316
431
  if (value.waitUntil !== null && (typeof value.waitUntil !== "number" || !Number.isFinite(value.waitUntil) || value.waitUntil < 0)) {
317
432
  throw new TypeError("A loop_status notice carries a nullable Unix-millisecond waitUntil.");
318
433
  }
434
+ if (!Number.isSafeInteger(value.packetCount) || value.packetCount < 0)
435
+ throw new TypeError("A loop_status notice carries its loop's packet count.");
436
+ if (value.claimedAt !== null && (!Number.isSafeInteger(value.claimedAt) || value.claimedAt <= 0)) {
437
+ throw new TypeError("A loop_status notice carries a nullable Unix-millisecond claimedAt.");
438
+ }
439
+ if (loopId === null || loopId <= 0)
440
+ throw new TypeError("A loop_status notice names its loop.");
319
441
  return [{
320
442
  type: EventType.STATE_DELTA,
321
443
  delta: [
322
444
  { op: "replace", path: "/plurnk/status/lifecycle", value: lifecycleOfLoopStatus(value.status) },
445
+ { op: "replace", path: "/plurnk/status/loopId", value: loopId },
446
+ { op: "replace", path: "/plurnk/status/packetCount", value: value.packetCount },
323
447
  { op: "replace", path: "/plurnk/status/waitUntil", value: value.status === 202 ? value.waitUntil : null },
448
+ { op: "replace", path: "/plurnk/status/claimedAt", value: value.claimedAt },
449
+ { op: "replace", path: "/plurnk/status/terminatedAt", value: null },
324
450
  ],
325
451
  }];
326
452
  }
@@ -328,7 +454,7 @@ export default class Translator {
328
454
  return [diagnostic];
329
455
  const events = [{
330
456
  type: EventType.STATE_DELTA,
331
- delta: [{ op: "replace", path: "/plurnk/status/activity", value: derivationActivity(notice) }],
457
+ delta: [{ op: "replace", path: "/plurnk/workspace/activity", value: derivationActivity(notice) }],
332
458
  }];
333
459
  if (value.level === "error" || value.level === "warn")
334
460
  events.push(diagnostic);
@@ -384,10 +510,9 @@ export default class Translator {
384
510
  }
385
511
  // {§agui-readable-reasoning} Durable reasoning precedes the turn's first
386
512
  // speech or note projection when live delivery has not already supplied it.
387
- static #readableReasoningEvents(sendId, value) {
513
+ static #readableReasoningEvents(messageId, value) {
388
514
  if (typeof value !== "string" || value.length === 0)
389
515
  return [];
390
- const messageId = `${sendId}/reasoning`;
391
516
  return [
392
517
  { type: EventType.REASONING_START, messageId },
393
518
  { type: EventType.REASONING_MESSAGE_START, messageId, role: "reasoning" },
@@ -411,7 +536,7 @@ export default class Translator {
411
536
  return kind === "emissionAttempt" ? kind : null;
412
537
  }
413
538
  // {§loop-response-messages}: share admission with reattach orientation.
414
- static isResponse(entry, threadId) {
539
+ static isResponse(entry, replyThreads) {
415
540
  if (entry.op !== "SEND" && entry.op !== "KILL")
416
541
  return false;
417
542
  const deliveredReply = Translator.#attrKind(entry.attrs) === "reply";
@@ -430,7 +555,7 @@ export default class Translator {
430
555
  const answers = rx.answers;
431
556
  if (!Array.isArray(answers))
432
557
  return false;
433
- return answers.some((address) => MessageAddress.messageId(address, threadId) !== null);
558
+ return answers.some((address) => replyThreads.some((threadId) => MessageAddress.messageId(address, threadId) !== null));
434
559
  }
435
560
  // The model-facing textual statement body out of the tx. The real
436
561
  // wire ships tx PARSED (an object); a string is tolerated and parsed for robustness.
@@ -459,6 +584,9 @@ export default class Translator {
459
584
  return body.raw;
460
585
  return "";
461
586
  }
587
+ static #waitSpeech(e, text) {
588
+ return e.op === "WAIT" && e.origin === "model" && text.trim().length > 0;
589
+ }
462
590
  // A wire value (object or string) as display text.
463
591
  static #asText(v) {
464
592
  if (typeof v === "string")
@@ -467,14 +595,159 @@ export default class Translator {
467
595
  return "";
468
596
  return JSON.stringify(v);
469
597
  }
470
- // Tool-call args: the op's addressing + body as one JSON string (AG-UI streams args as deltas;
471
- // a dispatched plurnk op is atomic, so the whole args object arrives as one delta).
598
+ // {§agui-snapshot} A row's message identity is its log coordinate L/T/S ({§methods-log-coordinate}).
599
+ static idOf(e) {
600
+ const { loop_seq: loop, turn_seq: turn, sequence } = e;
601
+ if (!Number.isSafeInteger(loop) || !Number.isSafeInteger(turn) || !Number.isSafeInteger(sequence)) {
602
+ throw new TypeError("A log row carries its loop, turn and sequence ordinals.");
603
+ }
604
+ return `${loop}/${turn}/${sequence}`;
605
+ }
606
+ // {§agui-readable-reasoning} Reasoning is the request that streamed it, live and on replay alike; reasoning a
607
+ // FORK inherited was streamed by no request of this worker and is its row's.
608
+ static #requestReasoningId(modelCallId, requestSequence) {
609
+ return `model-call-${modelCallId}/request-${requestSequence}/reasoning`;
610
+ }
611
+ static #reasoningId(e, id) {
612
+ const { reasoning_model_call_id: call, reasoning_request_sequence: request } = e;
613
+ return typeof call === "number" && typeof request === "number" ? Translator.#requestReasoningId(call, request) : `${id}/reasoning`;
614
+ }
615
+ // {§message-arrival} An arrival is a user message, live and on replay alike. One a client on this thread sent is that
616
+ // client's own, under its message id and as it sent it; any other is identified by its coordinate and named by its
617
+ // source ({§message-causal-source}).
618
+ #arrival(e) {
619
+ if (e.origin !== "_plurnk" || e.op !== "SEND" || Translator.#attrKind(e.attrs) !== "message")
620
+ return null;
621
+ const messageId = MessageAddress.messageId(e.source, this.#threadId);
622
+ if (messageId !== null)
623
+ return { id: messageId, content: Translator.#txBody(e.tx) };
624
+ return {
625
+ id: this.#id(e),
626
+ content: Translator.#txBody(e.tx),
627
+ ...(typeof e.source === "string" ? { name: e.source } : {}),
628
+ };
629
+ }
630
+ // {§agui-call-result} A call its receipt does not answer: a gated one awaits its own settlement ({§proposal-202-pauses}),
631
+ // and one that opened an obligation the delivery that discharges it.
632
+ static #answeredLater(e) {
633
+ return e.state === "proposed" || Translator.#opensObligation(e);
634
+ }
635
+ // {§agui-call-result} A call that opened an obligation is answered by the delivery that discharges it, never by its
636
+ // receipt, which rides the row: an execution its stream, a message the reply or conclusion ({§worker-obligations}).
637
+ static #opensObligation(e) {
638
+ const attrs = typeof e.attrs === "string" ? JSON.parse(e.attrs) : e.attrs;
639
+ const opened = attrs !== null && typeof attrs === "object" ? attrs : {};
640
+ return (typeof opened.stream === "string" || typeof opened.message === "string") && typeof e.status_rx === "number" && e.status_rx < 400;
641
+ }
642
+ // {§agui-call-result} What the worker received for its call: a reply's words, as an arrival's ({§agui-snapshot}),
643
+ // or an observation's result.
644
+ static #delivered(e) {
645
+ return e.op === "SEND" ? Translator.#txBody(e.tx) : Translator.#asText(e.rx);
646
+ }
647
+ // {§agui-call-result} The call a discharging row answers: the operation it names ({§methods-invocation}), in the
648
+ // namespace of the worker whose log holds both.
649
+ #answered(e) {
650
+ if (e.invocation === undefined)
651
+ return null;
652
+ const coordinate = Translator.callOf(e.invocation);
653
+ return this.#subagent === null ? coordinate : `${Translator.subagentIds(this.#subagent.worker)}#${coordinate}`;
654
+ }
655
+ // {§agui-plurnk-metadata} What the row that builds a message, tool call or result holds beyond the standard fields: its
656
+ // durable id, log coordinate, worker, origin, operation, source, address, signal, status, attrs and state, the same live
657
+ // and on snapshot.
658
+ static #rowMetadata(e) {
659
+ return { plurnk: { id: e.id, coordinate: Translator.idOf(e), worker: e.worker_id, origin: e.origin, op: e.op, source: e.source,
660
+ address: Translator.addressOf(e), signal: e.signal, status: e.status_rx, attrs: e.attrs, state: e.state } };
661
+ }
662
+ // {§agui-plurnk-metadata} The address a row recorded: `scheme://host[:port]path[?query][#fragment]`, or the bare path a
663
+ // file target records, with its channel; null when the row named none.
664
+ static addressOf(e) {
665
+ const text = (value) => typeof value === "string" ? value : null;
666
+ const pathname = text(e.pathname);
667
+ if (pathname === null)
668
+ return null;
669
+ const fragment = text(e.fragment) === null ? "" : `#${text(e.fragment)}`;
670
+ const scheme = text(e.scheme);
671
+ if (scheme === null)
672
+ return `${pathname}${fragment}`;
673
+ const port = typeof e.port === "number" ? `:${e.port}` : "";
674
+ const query = text(e.query) === null ? "" : `?${text(e.query)}`;
675
+ return `${scheme}://${text(e.hostname) ?? ""}${port}${pathname}${query}${fragment}`;
676
+ }
677
+ // {§agui-plurnk-metadata} Speech holds the body; its heading's aside and its receipt have no field in a text message.
678
+ static #speechMetadata(e) {
679
+ const statement = Translator.#parsed(e.tx);
680
+ const aside = statement !== null && typeof statement === "object" ? statement.aside ?? null : null;
681
+ return { plurnk: { ...Translator.#rowMetadata(e).plurnk, aside, receipt: Translator.#parsed(e.rx) } };
682
+ }
683
+ // A wire field as its value: the live wire ships it parsed, a replayed one may carry its JSON text.
684
+ static #parsed(value) {
685
+ if (value === undefined || value === "")
686
+ return null;
687
+ if (typeof value !== "string")
688
+ return value;
689
+ try {
690
+ return JSON.parse(value);
691
+ }
692
+ catch {
693
+ return value;
694
+ }
695
+ }
696
+ // {§methods-invocation} The coordinate of the operation a log address names in its own worker's log.
697
+ static callOf(invocation) {
698
+ const match = typeof invocation === "string" ? /^log:\/\/\/(\d+)\/(\d+)\/(\d+)\/[^/]+$/u.exec(invocation) : null;
699
+ if (match === null)
700
+ throw new TypeError(`An invocation is an operation's log address: ${String(invocation)}`);
701
+ return `${match[1]}/${match[2]}/${match[3]}`;
702
+ }
703
+ // {§agui-subagents} A delegated worker's rows are identified within its own namespace, so two logs never collide.
704
+ static subagentIds(worker) {
705
+ return `worker://${worker}`;
706
+ }
707
+ #id(e) {
708
+ const coordinate = Translator.idOf(e);
709
+ return this.#subagent === null ? coordinate : `${Translator.subagentIds(this.#subagent.worker)}#${coordinate}`;
710
+ }
711
+ // {§agui-subagents} Everything a delegation's conversation produces is attributed to its subagent run, its metadata
712
+ // included; only the events that describe a Run itself belong to no subagent.
713
+ #attributed(events) {
714
+ const subagent = this.#subagent;
715
+ if (subagent === null)
716
+ return events;
717
+ return events.map((event) => Translator.#runScoped.has(event.type) ? event : { ...event, subagentRunId: subagent.runId });
718
+ }
719
+ static #runScoped = new Set([EventType.RUN_STARTED, EventType.RUN_FINISHED, EventType.RUN_ERROR, EventType.MESSAGES_SNAPSHOT]);
720
+ // {§agui-snapshot} A result sits where the official reducer puts one that arrives live: after its call and the results
721
+ // already beside it, or last when the call is not in view.
722
+ static #placeResult(messages, result) {
723
+ const call = messages.findIndex((message) => message.role === "assistant" && message.toolCalls?.some(({ id }) => id === result.toolCallId) === true);
724
+ if (call === -1) {
725
+ messages.push(result);
726
+ return;
727
+ }
728
+ let at = call + 1;
729
+ while (at < messages.length && messages[at].role === "tool")
730
+ at++;
731
+ messages.splice(at, 0, result);
732
+ }
733
+ // {§agui-parse-refusal} The call a model operation row is: its operation and its statement, or for a parse refusal the
734
+ // statement the worker attempted, its verb and its text as written; null for a refusal that names no attempt.
735
+ static #callOf(e) {
736
+ if (e.op !== "error")
737
+ return { name: e.op, args: Translator.#argsFor(e) };
738
+ const attempt = Translator.#parsed(e.attrs)?.attempt;
739
+ return typeof attempt?.verb === "string" && typeof attempt.text === "string"
740
+ ? { name: attempt.verb, args: JSON.stringify({ text: attempt.text }) } : null;
741
+ }
742
+ // {§agui-projection} A tool call's arguments are the operation as authored: its durable statement, every operand
743
+ // exactly as parsed ({§log-sensitive-request-evidence}), without the verb that is the tool's name or the position it
744
+ // was written at. AG-UI streams arguments as deltas; a dispatched operation is atomic, so they arrive as one.
472
745
  static #argsFor(e) {
473
- return JSON.stringify({
474
- target: e.scheme !== null && e.scheme !== undefined ? `${e.scheme}://${e.pathname ?? ""}` : e.pathname ?? null,
475
- body: Translator.#txBody(e.tx) || undefined,
476
- signal: e.signal ?? undefined,
477
- });
746
+ const statement = typeof e.tx === "string" && e.tx.length > 0 ? JSON.parse(e.tx) : e.tx;
747
+ if (statement === null || typeof statement !== "object" || Array.isArray(statement))
748
+ return JSON.stringify({});
749
+ const { op: _op, position: _position, ...operands } = statement;
750
+ return JSON.stringify(operands);
478
751
  }
479
752
  }
480
753
  //# sourceMappingURL=Translator.js.map