@nocobase/app-plugin-agents 1.0.0-beta.27 → 1.0.0-beta.29

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 (189) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +40 -22
  3. package/dist/client/chat/api.d.ts +18 -1
  4. package/dist/client/chat/api.d.ts.map +1 -1
  5. package/dist/client/chat/api.js +37 -0
  6. package/dist/client/chat/api.js.map +1 -1
  7. package/dist/client/chat/coalesced-invalidation.d.ts +15 -0
  8. package/dist/client/chat/coalesced-invalidation.d.ts.map +1 -0
  9. package/dist/client/chat/coalesced-invalidation.js +29 -0
  10. package/dist/client/chat/coalesced-invalidation.js.map +1 -0
  11. package/dist/client/chat/controller.d.ts +8 -1
  12. package/dist/client/chat/controller.d.ts.map +1 -1
  13. package/dist/client/chat/controller.js +28 -4
  14. package/dist/client/chat/controller.js.map +1 -1
  15. package/dist/client/chat/message-model.d.ts.map +1 -1
  16. package/dist/client/chat/message-model.js +4 -0
  17. package/dist/client/chat/message-model.js.map +1 -1
  18. package/dist/client/chat/submissions.d.ts +3 -0
  19. package/dist/client/chat/submissions.d.ts.map +1 -1
  20. package/dist/client/chat/submissions.js +1 -0
  21. package/dist/client/chat/submissions.js.map +1 -1
  22. package/dist/client/chat/use-chat-scroll.d.ts +8 -1
  23. package/dist/client/chat/use-chat-scroll.d.ts.map +1 -1
  24. package/dist/client/chat/use-chat-scroll.js +29 -5
  25. package/dist/client/chat/use-chat-scroll.js.map +1 -1
  26. package/dist/client/chat/use-chat.d.ts +2 -0
  27. package/dist/client/chat/use-chat.d.ts.map +1 -1
  28. package/dist/client/chat/use-chat.js +17 -7
  29. package/dist/client/chat/use-chat.js.map +1 -1
  30. package/dist/client/components/agent-prompt-callout.d.ts +19 -0
  31. package/dist/client/components/agent-prompt-callout.d.ts.map +1 -0
  32. package/dist/client/components/agent-prompt-callout.js +28 -0
  33. package/dist/client/components/agent-prompt-callout.js.map +1 -0
  34. package/dist/client/locales/chat.en-US.d.ts +1 -0
  35. package/dist/client/locales/chat.en-US.d.ts.map +1 -1
  36. package/dist/client/locales/chat.en-US.js +1 -0
  37. package/dist/client/locales/chat.en-US.js.map +1 -1
  38. package/dist/client/locales/chat.zh-CN.d.ts.map +1 -1
  39. package/dist/client/locales/chat.zh-CN.js +1 -0
  40. package/dist/client/locales/chat.zh-CN.js.map +1 -1
  41. package/dist/client/locales/de-DE.d.ts.map +1 -1
  42. package/dist/client/locales/de-DE.js +1 -0
  43. package/dist/client/locales/de-DE.js.map +1 -1
  44. package/dist/client/locales/es-ES.d.ts.map +1 -1
  45. package/dist/client/locales/es-ES.js +1 -0
  46. package/dist/client/locales/es-ES.js.map +1 -1
  47. package/dist/client/locales/fr-FR.d.ts.map +1 -1
  48. package/dist/client/locales/fr-FR.js +1 -0
  49. package/dist/client/locales/fr-FR.js.map +1 -1
  50. package/dist/client/locales/id-ID.d.ts.map +1 -1
  51. package/dist/client/locales/id-ID.js +1 -0
  52. package/dist/client/locales/id-ID.js.map +1 -1
  53. package/dist/client/locales/ja-JP.d.ts.map +1 -1
  54. package/dist/client/locales/ja-JP.js +1 -0
  55. package/dist/client/locales/ja-JP.js.map +1 -1
  56. package/dist/client/locales/pt-BR.d.ts.map +1 -1
  57. package/dist/client/locales/pt-BR.js +1 -0
  58. package/dist/client/locales/pt-BR.js.map +1 -1
  59. package/dist/client/locales/runtimes.en-US.d.ts +4 -1
  60. package/dist/client/locales/runtimes.en-US.d.ts.map +1 -1
  61. package/dist/client/locales/runtimes.en-US.js +6 -3
  62. package/dist/client/locales/runtimes.en-US.js.map +1 -1
  63. package/dist/client/locales/runtimes.zh-CN.d.ts.map +1 -1
  64. package/dist/client/locales/runtimes.zh-CN.js +6 -3
  65. package/dist/client/locales/runtimes.zh-CN.js.map +1 -1
  66. package/dist/client/locales/vi-VN.d.ts.map +1 -1
  67. package/dist/client/locales/vi-VN.js +1 -0
  68. package/dist/client/locales/vi-VN.js.map +1 -1
  69. package/dist/client/locales/zh-TW.d.ts.map +1 -1
  70. package/dist/client/locales/zh-TW.js +1 -0
  71. package/dist/client/locales/zh-TW.js.map +1 -1
  72. package/dist/client/pages/runtimes/connect.d.ts.map +1 -1
  73. package/dist/client/pages/runtimes/connect.js +16 -10
  74. package/dist/client/pages/runtimes/connect.js.map +1 -1
  75. package/dist/client/pages/skills/index.js +2 -2
  76. package/dist/client/pages/skills/index.js.map +1 -1
  77. package/dist/database/migrations/.manifest.json +8 -0
  78. package/dist/database/migrations/202611010001_ag_conversation_runner_attention.d.ts +4 -0
  79. package/dist/database/migrations/202611010001_ag_conversation_runner_attention.d.ts.map +1 -0
  80. package/dist/database/migrations/202611010001_ag_conversation_runner_attention.js +30 -0
  81. package/dist/database/migrations/202611010001_ag_conversation_runner_attention.js.map +1 -0
  82. package/dist/database/migrations/202611010002_ag_create_conversation_links.d.ts +4 -0
  83. package/dist/database/migrations/202611010002_ag_create_conversation_links.d.ts.map +1 -0
  84. package/dist/database/migrations/202611010002_ag_create_conversation_links.js +30 -0
  85. package/dist/database/migrations/202611010002_ag_create_conversation_links.js.map +1 -0
  86. package/dist/package.json +1 -1
  87. package/dist/server/composition.d.ts.map +1 -1
  88. package/dist/server/composition.js +1 -0
  89. package/dist/server/composition.js.map +1 -1
  90. package/dist/server/core/conversations/attachments.d.ts +5 -0
  91. package/dist/server/core/conversations/attachments.d.ts.map +1 -1
  92. package/dist/server/core/conversations/attachments.js +10 -0
  93. package/dist/server/core/conversations/attachments.js.map +1 -1
  94. package/dist/server/core/conversations/conversation.service.d.ts +3 -1
  95. package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
  96. package/dist/server/core/conversations/conversation.service.js +693 -32
  97. package/dist/server/core/conversations/conversation.service.js.map +1 -1
  98. package/dist/server/core/conversations/conversation.store.d.ts +24 -0
  99. package/dist/server/core/conversations/conversation.store.d.ts.map +1 -1
  100. package/dist/server/core/conversations/conversation.store.js +10 -0
  101. package/dist/server/core/conversations/conversation.store.js.map +1 -1
  102. package/dist/server/core/conversations/index.d.ts +1 -1
  103. package/dist/server/core/conversations/index.d.ts.map +1 -1
  104. package/dist/server/core/conversations/index.js.map +1 -1
  105. package/dist/server/core/conversations/links.d.ts +27 -0
  106. package/dist/server/core/conversations/links.d.ts.map +1 -0
  107. package/dist/server/core/conversations/links.js +188 -0
  108. package/dist/server/core/conversations/links.js.map +1 -0
  109. package/dist/server/core/conversations/ports.d.ts +107 -3
  110. package/dist/server/core/conversations/ports.d.ts.map +1 -1
  111. package/dist/server/core/runs/claim.d.ts.map +1 -1
  112. package/dist/server/core/runs/claim.js +47 -3
  113. package/dist/server/core/runs/claim.js.map +1 -1
  114. package/dist/server/core/runs/lifecycle.d.ts.map +1 -1
  115. package/dist/server/core/runs/lifecycle.js +28 -3
  116. package/dist/server/core/runs/lifecycle.js.map +1 -1
  117. package/dist/server/core/runs/ports.d.ts +15 -0
  118. package/dist/server/core/runs/ports.d.ts.map +1 -1
  119. package/dist/server/core/runs/ports.js.map +1 -1
  120. package/dist/server/core/runs/run-data-purge.d.ts +7 -0
  121. package/dist/server/core/runs/run-data-purge.d.ts.map +1 -1
  122. package/dist/server/core/runs/run-data-purge.js +13 -0
  123. package/dist/server/core/runs/run-data-purge.js.map +1 -1
  124. package/dist/server/core/runs/run.store.d.ts +6 -2
  125. package/dist/server/core/runs/run.store.d.ts.map +1 -1
  126. package/dist/server/core/runs/run.store.js +28 -10
  127. package/dist/server/core/runs/run.store.js.map +1 -1
  128. package/dist/server/kernel/errors.d.ts +1 -1
  129. package/dist/server/kernel/errors.d.ts.map +1 -1
  130. package/dist/server/kernel/errors.js +2 -0
  131. package/dist/server/kernel/errors.js.map +1 -1
  132. package/dist/server/kernel/events.d.ts +19 -0
  133. package/dist/server/kernel/events.d.ts.map +1 -1
  134. package/dist/server/kernel/events.js.map +1 -1
  135. package/dist/server/online/agent/contracts.d.ts +5 -0
  136. package/dist/server/online/agent/contracts.d.ts.map +1 -1
  137. package/dist/server/online/agent/native/session.d.ts +1 -0
  138. package/dist/server/online/agent/native/session.d.ts.map +1 -1
  139. package/dist/server/online/agent/native/session.js +4 -1
  140. package/dist/server/online/agent/native/session.js.map +1 -1
  141. package/dist/server/online/agent/persistence/store.d.ts.map +1 -1
  142. package/dist/server/online/agent/persistence/store.js +8 -1
  143. package/dist/server/online/agent/persistence/store.js.map +1 -1
  144. package/dist/server/online/agent/runner/backend.d.ts +15 -0
  145. package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
  146. package/dist/server/online/agent/runner/backend.js +310 -70
  147. package/dist/server/online/agent/runner/backend.js.map +1 -1
  148. package/dist/server/online/agent/runner/projection.d.ts +24 -2
  149. package/dist/server/online/agent/runner/projection.d.ts.map +1 -1
  150. package/dist/server/online/agent/runner/projection.js +330 -26
  151. package/dist/server/online/agent/runner/projection.js.map +1 -1
  152. package/dist/server/online/agent/runner/types.d.ts +8 -1
  153. package/dist/server/online/agent/runner/types.d.ts.map +1 -1
  154. package/dist/server/online/agent/types.d.ts +2 -0
  155. package/dist/server/online/agent/types.d.ts.map +1 -1
  156. package/dist/server/providers/agents.d.ts +7 -0
  157. package/dist/server/providers/agents.d.ts.map +1 -1
  158. package/dist/server/providers/agents.js +3 -1
  159. package/dist/server/providers/agents.js.map +1 -1
  160. package/dist/server/providers/runners.d.ts.map +1 -1
  161. package/dist/server/providers/runners.js +12 -1
  162. package/dist/server/providers/runners.js.map +1 -1
  163. package/dist/server/routes/chat.d.ts.map +1 -1
  164. package/dist/server/routes/chat.js +270 -2
  165. package/dist/server/routes/chat.js.map +1 -1
  166. package/dist/server/routes/run.d.ts.map +1 -1
  167. package/dist/server/routes/run.js +44 -0
  168. package/dist/server/routes/run.js.map +1 -1
  169. package/dist/server/routes/schemas.d.ts +15 -1
  170. package/dist/server/routes/schemas.d.ts.map +1 -1
  171. package/dist/server/routes/schemas.js +116 -1
  172. package/dist/server/routes/schemas.js.map +1 -1
  173. package/dist/server/runners/workspaces.d.ts.map +1 -1
  174. package/dist/server/runners/workspaces.js +46 -2
  175. package/dist/server/runners/workspaces.js.map +1 -1
  176. package/dist/server/tokens.d.ts +1 -1
  177. package/dist/server/tokens.d.ts.map +1 -1
  178. package/dist/server/tokens.js.map +1 -1
  179. package/dist/shared/conversations.d.ts +107 -1
  180. package/dist/shared/conversations.d.ts.map +1 -1
  181. package/dist/shared/conversations.js +19 -0
  182. package/dist/shared/conversations.js.map +1 -1
  183. package/dist/shared/ui-message.d.ts +30 -0
  184. package/dist/shared/ui-message.d.ts.map +1 -1
  185. package/dist/shared/ui-message.js +17 -0
  186. package/dist/shared/ui-message.js.map +1 -1
  187. package/package.json +9 -9
  188. package/skills/nocobase-app-plugin-agents/references/extension-points.md +19 -19
  189. package/skills/nocobase-app-plugin-agents/references/online-agent.md +16 -2
@@ -1,10 +1,10 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { runnerMessages, runnerUIPage, runnerMessageId } from './projection.js';
2
+ import { runnerMessages, runnerToolParts, runnerUIPage, runnerMessageId, runnerPlan, } from './projection.js';
3
3
  import { isDeepStrictEqual } from 'node:util';
4
4
  import { outputBlock } from '@nocobase/agent-protocol';
5
5
  import { conversationsRepo, findConversation, lockConversation, messagesRepo, toMessage, } from '../../../core/conversations/conversation.store.js';
6
6
  import { openRunsOnEach } from '../../../core/runs/index.js';
7
- import { findRunRecord, toRun } from '../../../core/runs/run.store.js';
7
+ import { eventsRepo, findRunRecord, toRun, } from '../../../core/runs/run.store.js';
8
8
  import { conflict, forbidden, invalid, notFound, } from '../../../kernel/errors.js';
9
9
  import { asJson, jsonObject } from '../../../kernel/values.js';
10
10
  import { unsupportedCapability } from '../errors.js';
@@ -43,6 +43,42 @@ function overrides(options) {
43
43
  const { sessionId: _id, source: _source, title: _title, showInChatList: _showInChatList, selectedModel: _selectedModel, ...pending } = options;
44
44
  return Object.keys(pending).length ? pending : undefined;
45
45
  }
46
+ /** The response without its turn footer, which history shows once the run has ended; it is never streamed. */
47
+ function withoutTurn(message) {
48
+ if (!message)
49
+ return message;
50
+ const kept = message.parts.flatMap((part, index) => part.type === 'data-turn' ? [] : [index]);
51
+ if (kept.length === message.parts.length)
52
+ return message;
53
+ return {
54
+ ...message,
55
+ parts: kept.map((index) => message.parts[index]),
56
+ metadata: {
57
+ ...message.metadata,
58
+ sourceRecords: (message.metadata?.sourceRecords ?? []).filter((source) => !source.partId.startsWith('turn:')),
59
+ },
60
+ };
61
+ }
62
+ /** The text of the one user message Runner chat takes per send. */
63
+ function userText(messages, input) {
64
+ if (input?.selectedModel)
65
+ throw unsupportedCapability('runner', 'native models');
66
+ if (messages.length !== 1 || messages[0].role !== 'user')
67
+ throw invalid('Runner chat accepts one user message per turn.');
68
+ if (messages[0].providerOptions !== undefined)
69
+ throw unsupportedCapability('runner', 'provider-specific message options');
70
+ const content = messages[0].content;
71
+ if (Array.isArray(content) &&
72
+ content.some((part) => part.type !== 'text' || part.providerOptions !== undefined))
73
+ throw unsupportedCapability('runner', 'native binary parts; send attachments by attachmentIds');
74
+ if ((input?.attachmentIds?.length ?? 0) > 1)
75
+ throw invalid('Attachments must belong to the single user message.');
76
+ return typeof content === 'string'
77
+ ? content
78
+ : content.map((part) => (part.type === 'text' ? part.text : '')).join('');
79
+ }
80
+ /** The most parts one streamed response carries: its text and reasoning blocks and its tool calls. */
81
+ const MAX_STREAM_PARTS = 256;
46
82
  /** One trusted facade, with pending configuration and only its last accepted response pinned. */
47
83
  export class RunnerRealtimeOnlineAgent {
48
84
  host;
@@ -132,21 +168,43 @@ export class RunnerRealtimeOnlineAgent {
132
168
  .catch(() => { });
133
169
  return task;
134
170
  }
171
+ async queue(messages, input) {
172
+ const { deps, state } = this.host;
173
+ if (state.stopping)
174
+ throw conflict('OnlineAgent is stopping.');
175
+ const text = userText(messages, input);
176
+ credentials(this.context);
177
+ if (input?.overrides || input?.agentId)
178
+ throw invalid('Queued messages keep the configuration of the work.');
179
+ // A first message still being admitted is the work this one joins.
180
+ while (state.admitting.has(this.sessionId))
181
+ await Promise.allSettled([...state.admissions]);
182
+ const before = await owned(deps, this.context.user.id, this.sessionId);
183
+ const current = runnerBinding(before.metadata)?.config;
184
+ if (!current)
185
+ throw invalid('This is not a Runner OnlineAgent conversation.');
186
+ if (current.agentId !== this.agentId)
187
+ throw conflict('This conversation now uses another agent; restore it before queueing.');
188
+ const accepted = await deps.conversations.sendRealtime(this.context.user.id, this.sessionId, {
189
+ content: text,
190
+ attachmentIds: input?.attachmentIds?.[0],
191
+ clientId: input?.clientId,
192
+ }, {
193
+ config: current,
194
+ expectedConfig: current,
195
+ workContext: input?.workContext,
196
+ business: this.context.business,
197
+ queue: true,
198
+ });
199
+ const execution = state.active.get(this.sessionId);
200
+ if (execution)
201
+ void this.host.refresh(execution).catch((error) => deps.onError(error));
202
+ return accepted.seq;
203
+ }
135
204
  async submit(messages, input) {
136
205
  const { deps, state } = this.host;
137
206
  credentials(this.context);
138
- if (input?.selectedModel)
139
- throw unsupportedCapability('runner', 'native models');
140
- if (messages.length !== 1 || messages[0].role !== 'user')
141
- throw invalid('Runner chat accepts one user message per turn.');
142
- if (messages[0].providerOptions !== undefined)
143
- throw unsupportedCapability('runner', 'provider-specific message options');
144
- const content = messages[0].content;
145
- if (Array.isArray(content) &&
146
- content.some((part) => part.type !== 'text' || part.providerOptions !== undefined))
147
- throw unsupportedCapability('runner', 'native binary parts; send attachments by attachmentIds');
148
- if ((input?.attachmentIds?.length ?? 0) > 1)
149
- throw invalid('Attachments must belong to the single user message.');
207
+ const text = userText(messages, input);
150
208
  const before = await owned(deps, this.context.user.id, this.sessionId);
151
209
  if (state.active.has(this.sessionId) ||
152
210
  runnerBinding(before.metadata)?.closingRunId ||
@@ -157,11 +215,14 @@ export class RunnerRealtimeOnlineAgent {
157
215
  if (!current)
158
216
  throw invalid('This is not a Runner OnlineAgent conversation.');
159
217
  const requested = input?.overrides ?? this.pendingOptions;
160
- const config = requested
218
+ const resolved = requested
161
219
  ? await deps.configurator.resolve(this.context, requested, current, this.boundary === 'http')
162
220
  : current;
163
- if (config.agentId !== this.agentId)
221
+ if (resolved.agentId !== this.agentId)
164
222
  throw conflict('Start a new conversation to choose another agent.');
223
+ const config = input?.agentId
224
+ ? { ...resolved, agentId: input.agentId }
225
+ : resolved;
165
226
  supported(config);
166
227
  if (state.active.size >= 1024)
167
228
  throw conflict('OnlineAgent execution limit exceeded.');
@@ -176,6 +237,8 @@ export class RunnerRealtimeOnlineAgent {
176
237
  textLengths: new Map(),
177
238
  previewDigests: new Map(),
178
239
  previewParts: new Map(),
240
+ toolStates: new Map(),
241
+ stepSeq: 0,
179
242
  ready: false,
180
243
  started: false,
181
244
  again: false,
@@ -184,11 +247,8 @@ export class RunnerRealtimeOnlineAgent {
184
247
  let accepted;
185
248
  try {
186
249
  accepted = await deps.conversations.sendRealtime(this.context.user.id, this.sessionId, {
187
- content: typeof content === 'string'
188
- ? content
189
- : content
190
- .map((part) => (part.type === 'text' ? part.text : ''))
191
- .join(''),
250
+ agentId: input?.agentId,
251
+ content: text,
192
252
  attachmentIds: input?.attachmentIds?.[0],
193
253
  clientId: input?.clientId,
194
254
  }, {
@@ -206,6 +266,7 @@ export class RunnerRealtimeOnlineAgent {
206
266
  }
207
267
  // Nothing after durable acceptance may turn it into a failed/retried business admission.
208
268
  this.response = writer;
269
+ this.agentId = config.agentId;
209
270
  this.pendingOptions = undefined;
210
271
  execution.runId = accepted.run.id;
211
272
  execution.cursor = accepted.seq;
@@ -232,6 +293,10 @@ export class RunnerOnlineAgentAdapter {
232
293
  constructor(deps) {
233
294
  this.deps = deps;
234
295
  }
296
+ /** A configuration change must not race an admission or unfinished stream finalization. */
297
+ busy(sessionId) {
298
+ return (this.state.admitting.has(sessionId) || this.state.active.has(sessionId));
299
+ }
235
300
  available(context) {
236
301
  credentials(context);
237
302
  if (this.state.stopping)
@@ -354,22 +419,9 @@ export class RunnerOnlineAgentAdapter {
354
419
  if (execution.published.has(id))
355
420
  return;
356
421
  const type = event.type === 'thinking' ? 'reasoning' : 'text';
357
- if (!execution.started) {
358
- execution.writer.write({
359
- type: 'start',
360
- messageId: runnerMessageId(runId),
361
- messageMetadata: {
362
- conversationId: execution.id,
363
- revision: 0,
364
- createdAt: this.deps.now(),
365
- executionId: runId,
366
- turnId: runId,
367
- },
368
- });
369
- execution.started = true;
370
- }
422
+ this.ensureStarted(execution);
371
423
  if (!execution.previewParts.has(id)) {
372
- if (execution.previewParts.size >= 64)
424
+ if (execution.previewParts.size >= MAX_STREAM_PARTS)
373
425
  return;
374
426
  execution.previewParts.set(id, {
375
427
  index: execution.previewParts.size,
@@ -382,31 +434,7 @@ export class RunnerOnlineAgentAdapter {
382
434
  execution.textLengths.set(id, 0);
383
435
  execution.previewDigests.set(id, createHash('sha256'));
384
436
  }
385
- const sources = [...execution.previewParts].map(([partId, part]) => ({
386
- recordId: partId,
387
- partId,
388
- partIndex: part.index,
389
- seq: execution.cursor + 1,
390
- createdAt: this.deps.now(),
391
- output: {
392
- blockId: partId.slice(7),
393
- phase: execution.published.has(partId)
394
- ? 'end'
395
- : 'delta',
396
- },
397
- }));
398
- execution.writer.write({
399
- type: 'message-metadata',
400
- messageMetadata: {
401
- ...execution.snapshot?.metadata,
402
- revision: execution.snapshot?.metadata?.revision ?? 0,
403
- conversationId: execution.id,
404
- createdAt: execution.snapshot?.metadata?.createdAt ?? this.deps.now(),
405
- sourceRecords: sources,
406
- executionId: runId,
407
- turnId: runId,
408
- },
409
- });
437
+ this.writeSources(execution);
410
438
  let content = event.content ?? '';
411
439
  if (output.phase === 'end' && event.content !== undefined) {
412
440
  const length = this.prefixLength(execution, id, content);
@@ -441,7 +469,7 @@ export class RunnerOnlineAgentAdapter {
441
469
  digest.copy().digest('hex') !==
442
470
  createHash('sha256').update(canonical.slice(0, length)).digest('hex'))) {
443
471
  for (const [partId, part] of execution.previewParts)
444
- if (!execution.published.has(partId)) {
472
+ if (!execution.published.has(partId) && part.type !== 'tool') {
445
473
  execution.writer.write({
446
474
  type: part.type === 'text' ? 'text-end' : 'reasoning-end',
447
475
  id: partId,
@@ -455,6 +483,208 @@ export class RunnerOnlineAgentAdapter {
455
483
  }
456
484
  return length;
457
485
  }
486
+ /** The run's tool calls and results saved since the last read, as tool parts of the response, in order. */
487
+ async liveTools(execution) {
488
+ const events = await eventsRepo(this.deps.tx.read()).findMany({
489
+ filter: (f) => f.and([
490
+ f.string('runId').eq(execution.runId),
491
+ f.or([
492
+ f.string('type').eq('toolUse'),
493
+ f.string('type').eq('toolResult'),
494
+ f.and([
495
+ f.string('type').eq('status'),
496
+ f.text('content').eq('plan'),
497
+ ]),
498
+ ]),
499
+ f.number('seq').gt(execution.stepSeq),
500
+ ]),
501
+ sort: (s) => s.field('seq').asc(),
502
+ limit: 200,
503
+ });
504
+ if (events.length === 0)
505
+ return;
506
+ execution.stepSeq = Number(events.at(-1).seq);
507
+ // The agent's plan, as it last reported it, replaces the one shown.
508
+ const plan = runnerPlan(events.filter((event) => event.type === 'status'));
509
+ const planId = `plan:${execution.runId}`;
510
+ if (plan &&
511
+ (execution.previewParts.has(planId) ||
512
+ execution.previewParts.size < MAX_STREAM_PARTS)) {
513
+ this.ensureStarted(execution);
514
+ if (!execution.previewParts.has(planId))
515
+ execution.previewParts.set(planId, {
516
+ index: execution.previewParts.size,
517
+ type: 'tool',
518
+ });
519
+ execution.writer.write({
520
+ type: 'data-plan',
521
+ id: planId,
522
+ data: plan.plan,
523
+ });
524
+ execution.published.add(planId);
525
+ this.writeSources(execution);
526
+ }
527
+ // A result whose call was read earlier still needs the call's input: read the calls of these results too.
528
+ const earlier = events
529
+ .filter((event) => event.type === 'toolResult')
530
+ .map((event) => jsonObject(event.meta).toolUseId)
531
+ .filter((id) => typeof id === 'string' && execution.toolStates.has(`tool:${id}`));
532
+ const calls = earlier.length
533
+ ? await eventsRepo(this.deps.tx.read()).findMany({
534
+ filter: (f) => f.and([
535
+ f.string('runId').eq(execution.runId),
536
+ f.string('type').eq('toolUse'),
537
+ f.or(earlier.map((id) => f.json('meta').path(['toolUseId']).eq(id))),
538
+ ]),
539
+ sort: (s) => s.field('seq').asc(),
540
+ limit: earlier.length,
541
+ })
542
+ : [];
543
+ const parts = runnerToolParts([
544
+ ...calls.filter((call) => earlier.includes(String(jsonObject(call.meta).toolUseId))),
545
+ ...events,
546
+ ]);
547
+ let wrote = false;
548
+ for (const [id, { part }] of parts)
549
+ wrote = this.writeTool(execution, id, part) || wrote;
550
+ if (wrote)
551
+ this.writeSources(execution);
552
+ }
553
+ /**
554
+ * Before the response has output, says where it stands (`data-phase`): waiting for a runner, starting the agent, or
555
+ * the agent thinking. The page shows it in place of a blank response; output replaces it.
556
+ */
557
+ async livePhase(execution, run) {
558
+ if (execution.phase === 'done')
559
+ return;
560
+ if ([...execution.previewParts.keys()].some((id) => id !== 'phase')) {
561
+ execution.phase = 'done';
562
+ return;
563
+ }
564
+ let phase = run.status === 'queued' ? 'waiting' : 'starting';
565
+ if (run.status === 'running' && execution.phase !== 'thinking') {
566
+ const started = await eventsRepo(this.deps.tx.read()).findOne({
567
+ filter: (f) => f.and([
568
+ f.string('runId').eq(run.id),
569
+ f.string('type').eq('status'),
570
+ f.text('content').eq('started'),
571
+ ]),
572
+ });
573
+ if (started)
574
+ phase = 'thinking';
575
+ }
576
+ else if (execution.phase === 'thinking')
577
+ phase = 'thinking';
578
+ if (phase === execution.phase)
579
+ return;
580
+ this.ensureStarted(execution);
581
+ if (!execution.previewParts.has('phase'))
582
+ execution.previewParts.set('phase', {
583
+ index: execution.previewParts.size,
584
+ type: 'tool',
585
+ });
586
+ execution.published.add('phase');
587
+ execution.writer.write({
588
+ type: 'data-phase',
589
+ id: 'phase',
590
+ data: { phase },
591
+ });
592
+ execution.phase = phase;
593
+ this.writeSources(execution);
594
+ }
595
+ /** Writes what of a tool part the stream has not had; true when something was written. */
596
+ writeTool(execution, id, part) {
597
+ let state = execution.toolStates.get(id);
598
+ if (state === 'done')
599
+ return false;
600
+ let wrote = false;
601
+ if (!state) {
602
+ if (execution.previewParts.size >= MAX_STREAM_PARTS)
603
+ return false;
604
+ this.ensureStarted(execution);
605
+ execution.previewParts.set(id, {
606
+ index: execution.previewParts.size,
607
+ type: 'tool',
608
+ });
609
+ execution.writer.write({
610
+ type: 'tool-input-available',
611
+ toolCallId: part.toolCallId,
612
+ toolName: part.toolName,
613
+ input: part.input,
614
+ dynamic: true,
615
+ });
616
+ state = 'input';
617
+ execution.toolStates.set(id, state);
618
+ wrote = true;
619
+ }
620
+ if (part.state === 'output-available' || part.state === 'output-error') {
621
+ execution.writer.write(part.state === 'output-available'
622
+ ? {
623
+ type: 'tool-output-available',
624
+ toolCallId: part.toolCallId,
625
+ output: part.output,
626
+ dynamic: true,
627
+ }
628
+ : {
629
+ type: 'tool-output-error',
630
+ toolCallId: part.toolCallId,
631
+ errorText: part.errorText,
632
+ dynamic: true,
633
+ });
634
+ execution.toolStates.set(id, 'done');
635
+ execution.published.add(id);
636
+ wrote = true;
637
+ }
638
+ return wrote;
639
+ }
640
+ ensureStarted(execution) {
641
+ if (execution.started)
642
+ return;
643
+ execution.writer.write({
644
+ type: 'start',
645
+ messageId: runnerMessageId(execution.runId),
646
+ messageMetadata: {
647
+ conversationId: execution.id,
648
+ revision: 0,
649
+ createdAt: this.deps.now(),
650
+ executionId: execution.runId,
651
+ turnId: execution.runId,
652
+ },
653
+ });
654
+ execution.started = true;
655
+ }
656
+ /** Where each part of the response streamed so far stands, so the page keeps them in order. */
657
+ writeSources(execution) {
658
+ const runId = execution.runId;
659
+ execution.writer.write({
660
+ type: 'message-metadata',
661
+ messageMetadata: {
662
+ ...execution.snapshot?.metadata,
663
+ revision: execution.snapshot?.metadata?.revision ?? 0,
664
+ conversationId: execution.id,
665
+ createdAt: execution.snapshot?.metadata?.createdAt ?? this.deps.now(),
666
+ sourceRecords: [...execution.previewParts].map(([partId, part]) => ({
667
+ recordId: partId,
668
+ partId,
669
+ partIndex: part.index,
670
+ seq: execution.cursor + 1,
671
+ createdAt: this.deps.now(),
672
+ ...(partId.startsWith('output:')
673
+ ? {
674
+ output: {
675
+ blockId: partId.slice(7),
676
+ phase: execution.published.has(partId)
677
+ ? 'end'
678
+ : 'delta',
679
+ },
680
+ }
681
+ : {}),
682
+ })),
683
+ executionId: runId,
684
+ turnId: runId,
685
+ },
686
+ });
687
+ }
458
688
  refresh(execution) {
459
689
  if (this.state.detaching)
460
690
  return execution.pending ?? Promise.resolve();
@@ -494,7 +724,9 @@ export class RunnerOnlineAgentAdapter {
494
724
  // A reclaimed attempt can be queued again without finishing the conversation stream.
495
725
  if (run.status === 'queued' || run.status === 'dispatched')
496
726
  for (const [id, part] of execution.previewParts)
497
- if (id.startsWith('output:') && !execution.published.has(id)) {
727
+ if (id.startsWith('output:') &&
728
+ part.type !== 'tool' &&
729
+ !execution.published.has(id)) {
498
730
  execution.writer.write({
499
731
  type: part.type === 'text' ? 'text-end' : 'reasoning-end',
500
732
  id,
@@ -502,6 +734,8 @@ export class RunnerOnlineAgentAdapter {
502
734
  execution.published.add(id);
503
735
  execution.previewDigests.delete(id);
504
736
  }
737
+ await this.liveTools(execution);
738
+ await this.livePhase(execution, run);
505
739
  // Synchronization commits first. Independent notices are delivered by durable business rereads.
506
740
  const row = await owned(this.deps, execution.userId, execution.id);
507
741
  if (Number(row.lastSeq) > execution.cursor) {
@@ -513,7 +747,7 @@ export class RunnerOnlineAgentAdapter {
513
747
  },
514
748
  sort: (s) => s.field('seq').asc(),
515
749
  });
516
- const message = (await runnerMessages(this.deps.tx, records.map((record) => toMessage(record, this.deps.basePath()))))[0];
750
+ const message = withoutTurn((await runnerMessages(this.deps.tx, records.map((record) => toMessage(record, this.deps.basePath()))))[0]);
517
751
  if (message) {
518
752
  const sourceParts = new Map(message.metadata.sourceRecords.map((source) => [
519
753
  source.partId,
@@ -523,11 +757,14 @@ export class RunnerOnlineAgentAdapter {
523
757
  for (const source of message.metadata.sourceRecords) {
524
758
  let part = execution.previewParts.get(source.partId);
525
759
  if (!part) {
760
+ const type = sourceParts.get(source.partId)?.type;
526
761
  part = {
527
762
  index: execution.previewParts.size,
528
- type: sourceParts.get(source.partId)?.type === 'reasoning'
763
+ type: type === 'reasoning'
529
764
  ? 'reasoning'
530
- : 'text',
765
+ : type === 'dynamic-tool' || type?.startsWith('data-')
766
+ ? 'tool'
767
+ : 'text',
531
768
  };
532
769
  execution.previewParts.set(source.partId, part);
533
770
  }
@@ -595,6 +832,10 @@ export class RunnerOnlineAgentAdapter {
595
832
  execution.previewDigests.delete(source.partId);
596
833
  }
597
834
  }
835
+ else if (part.type === 'dynamic-tool') {
836
+ this.writeTool(execution, source.partId, part);
837
+ continue;
838
+ }
598
839
  else if (part.type === 'file')
599
840
  execution.writer.write({
600
841
  type: 'file',
@@ -612,7 +853,7 @@ export class RunnerOnlineAgentAdapter {
612
853
  if (!this.terminal(run))
613
854
  return;
614
855
  for (const [id, part] of execution.previewParts)
615
- if (!execution.published.has(id)) {
856
+ if (!execution.published.has(id) && part.type !== 'tool') {
616
857
  execution.writer.write({
617
858
  type: part.type === 'text' ? 'text-end' : 'reasoning-end',
618
859
  id,
@@ -658,9 +899,8 @@ export class RunnerOnlineAgentAdapter {
658
899
  if (!current || current.userId !== userId)
659
900
  throw notFound('Conversation');
660
901
  const binding = runnerBinding(current.metadata);
661
- if (binding?.closingRunId && binding.closingRunId !== runId)
662
- throw conflict('Another execution is being finalized.');
663
- if (!binding?.closingRunId)
902
+ // A later run (one the application woke) closes on its own.
903
+ if (!binding?.closingRunId || binding.closingRunId !== runId)
664
904
  return;
665
905
  const { closingRunId: _closed, ...kept } = binding;
666
906
  await conversationsRepo(conn).updateMany({