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

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 +42 -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 +21 -0
  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,4 +1,4 @@
1
- import { outputBlock } from '@nocobase/agent-protocol';
1
+ import { outputBlock, TERMINAL_RUN_STATUSES } from '@nocobase/agent-protocol';
2
2
  import { historicalPageContext, hasContent, renderPageContext, } from './page-context.js';
3
3
  import { nativeExecutionStatus, runnerBinding, } from '../../online/agent/persistence/state.js';
4
4
  /**
@@ -28,14 +28,19 @@ import { asJson, jsonObject } from '../../kernel/values.js';
28
28
  import { findAgent } from '../agents/index.js';
29
29
  import { agentNamesOf } from '../agents/agent.store.js';
30
30
  import { eventsAfter, hasSession, lastSummary, openRunsOnEach, queuedRun, } from '../runs/index.js';
31
- import { findRunRecord, toRun } from '../runs/run.store.js';
31
+ import { eventsRepo, findRunRecord, inputsRepo, runsRepo, sessionsRepo, toRun, } from '../runs/run.store.js';
32
32
  import { readAttachmentIds, storedAttachments, } from './attachments.js';
33
33
  import { availabilityOf, wakeable } from './availability.js';
34
- import { appendMessage, conversationsRepo, findConversation, findDraft, metadataOf, rewriteMessage, lockConversation, messagesRepo, toMessage, updateMessageRun, } from './conversation.store.js';
34
+ import { appendMessage, conversationsRepo, findConversation, findDraft, metadataOf, queuedRevision, rewriteMessage, lockConversation, messagesRepo, toMessage, updateMessageRun, } from './conversation.store.js';
35
35
  import { attachmentLines, conversationContext, conversationKey, conversationSystemLayer, conversationTask, conversationTurn, conversationUrl, } from './prompt.js';
36
36
  import { autoTitle, cleanTitle } from './titles.js';
37
+ import { createConversationLinks } from './links.js';
37
38
  /** Events read per page while turning a run's events into messages. */
38
39
  const SYNC_PAGE = 500;
40
+ /** Archived conversations one purge pass reads at a time, the most it deletes, and how long it may take. */
41
+ const ARCHIVED_PURGE_BATCH = 50;
42
+ const ARCHIVED_PURGE_LIMIT = 1000;
43
+ const ARCHIVED_PURGE_TIME_MS = 5 * 60_000;
39
44
  function conversationConflict(reason, message) {
40
45
  return new ProtocolError('CONVERSATION_CONFLICT', message, { reason });
41
46
  }
@@ -100,6 +105,8 @@ function noticeText(notice, names) {
100
105
  return `${name(notice.fromAgentId)} has no runner for you now, so ${name(notice.agentId)} answers until you switch back.`;
101
106
  case 'onlineFallbackUnavailable':
102
107
  return `The online fallback agent cannot answer (${notice.reason}). This conversation stays with ${name(notice.fromAgentId)} while it waits for a runner.`;
108
+ case 'switchedAgent':
109
+ return `This conversation now uses ${name(notice.agentId)}. The conversation history is preserved.`;
103
110
  case 'switchedBack':
104
111
  return `This conversation is back with ${name(notice.agentId)}.`;
105
112
  case 'runFailed':
@@ -150,6 +157,39 @@ function onlineExecution(record, open = [], active = false) {
150
157
  ...(call.requiresEffectReview ? { requiresEffectReview: true } : {}),
151
158
  };
152
159
  }
160
+ /** At most this many characters of what an agent asks its owner are kept. */
161
+ const QUESTION_MAX = 500;
162
+ function needsYouOf(record) {
163
+ const reason = record.needsYou;
164
+ if (!reason ||
165
+ record.needsYouSeq === null ||
166
+ record.needsYouSeq === undefined)
167
+ return null;
168
+ return {
169
+ reason,
170
+ seq: Number(record.needsYouSeq),
171
+ text: record.needsYouText ?? null,
172
+ };
173
+ }
174
+ /** What settles what waits for the owner. */
175
+ const SETTLED = {
176
+ needsYou: null,
177
+ needsYouSeq: null,
178
+ needsYouText: null,
179
+ };
180
+ function runnerOf(id, runner) {
181
+ if (!runner)
182
+ return { id, name: null, status: 'missing' };
183
+ return {
184
+ id,
185
+ name: runner.name,
186
+ status: runner.status === 'online'
187
+ ? 'online'
188
+ : runner.status === 'revoked'
189
+ ? 'revoked'
190
+ : 'offline',
191
+ };
192
+ }
153
193
  export function createConversationService(deps) {
154
194
  const { tx, ids, clock } = deps;
155
195
  const now = () => clock.now().toISOString();
@@ -157,6 +197,13 @@ export function createConversationService(deps) {
157
197
  const viewOf = (record) => toMessage(record, deps.basePath?.() ?? '');
158
198
  const rules = [];
159
199
  const sources = createSources();
200
+ const links = createConversationLinks({
201
+ tx,
202
+ ids,
203
+ now,
204
+ refOf,
205
+ changed: (unit, record) => changed(unit, record),
206
+ });
160
207
  // -------------------------------------------------------------------------------------------------------------------
161
208
  // Reading
162
209
  // -------------------------------------------------------------------------------------------------------------------
@@ -203,6 +250,10 @@ export function createConversationService(deps) {
203
250
  if (id && !agents.has(id))
204
251
  agents.set(id, await findAgent(conn, id));
205
252
  const open = await openRunsOnEach(conn, CONVERSATION_SUBJECT, records.map((record) => record.id));
253
+ const runnerIds = [
254
+ ...new Set(records.flatMap((record) => (record.runnerId ? [record.runnerId] : []))),
255
+ ];
256
+ const machines = new Map((runnerIds.length ? await deps.runners.all(conn, runnerIds) : []).map((runner) => [runner.id, runner]));
206
257
  return Promise.all(records.map(async (record) => ({
207
258
  id: record.id,
208
259
  title: record.title,
@@ -232,6 +283,12 @@ export function createConversationService(deps) {
232
283
  createdAt: record.createdAt,
233
284
  updatedAt: record.updatedAt,
234
285
  run: runRef(open.filter((run) => run.subject.id === record.id)),
286
+ runner: record.runnerId
287
+ ? runnerOf(record.runnerId, machines.get(record.runnerId))
288
+ : null,
289
+ lastSeq: Number(record.lastSeq),
290
+ seenSeq: Number(record.seenSeq ?? 0),
291
+ needsYou: needsYouOf(record),
235
292
  })));
236
293
  }
237
294
  async function systemDefault(conn, userId) {
@@ -305,12 +362,16 @@ export function createConversationService(deps) {
305
362
  // -------------------------------------------------------------------------------------------------------------------
306
363
  // Writing
307
364
  // -------------------------------------------------------------------------------------------------------------------
308
- function changed(unit, record, lastSeq) {
365
+ function changed(unit, record, at) {
309
366
  unit.emit({
310
367
  type: 'conversation.changed',
311
368
  conversationId: record.id,
312
369
  userId: record.userId,
313
- ...(lastSeq === undefined ? {} : { lastSeq }),
370
+ ...(at === undefined
371
+ ? {}
372
+ : typeof at === 'number'
373
+ ? { lastSeq: at }
374
+ : at),
314
375
  });
315
376
  }
316
377
  async function append(unit, record, message) {
@@ -348,7 +409,8 @@ export function createConversationService(deps) {
348
409
  }
349
410
  const agentIds = value.code === 'switchedToDefault' ||
350
411
  value.code === 'switchedToOnline' ||
351
- value.code === 'switchedBack'
412
+ value.code === 'switchedBack' ||
413
+ value.code === 'switchedAgent'
352
414
  ? [value.agentId, value.fromAgentId]
353
415
  : value.code === 'onlineFallbackUnavailable'
354
416
  ? [value.fromAgentId]
@@ -427,10 +489,13 @@ export function createConversationService(deps) {
427
489
  * Moves the conversation to `agent`: its queued work is withdrawn, the thread moves on (a new session), and the
428
490
  * messages nobody answered go to the new agent.
429
491
  */
430
- async function rebind(unit, record, agent, fallbackFromAgentId, value) {
431
- if (runnerBinding(record.metadata))
492
+ async function rebind(unit, record, agent, fallbackFromAgentId, value, idleOnly = false) {
493
+ const binding = runnerBinding(record.metadata);
494
+ if (binding && !idleOnly)
432
495
  throw conflict('Start a new conversation to choose another agent.');
433
- const withdrawn = await deps.runs.withdrawQueued({ subject: subjectOf(record.id), byUserId: record.userId }, unit);
496
+ const withdrawn = idleOnly
497
+ ? []
498
+ : await deps.runs.withdrawQueued({ subject: subjectOf(record.id), byUserId: record.userId }, unit);
434
499
  await conversationsRepo(unit.conn).updateMany({
435
500
  filter: { id: record.id },
436
501
  values: {
@@ -439,11 +504,27 @@ export function createConversationService(deps) {
439
504
  modelService: null,
440
505
  model: null,
441
506
  fallbackFromAgentId,
507
+ ...(binding
508
+ ? {
509
+ metadata: asJson({
510
+ ...jsonObject(record.metadata),
511
+ runnerOnlineAgent: {
512
+ ...binding,
513
+ config: { ...binding.config, agentId: agent.id },
514
+ },
515
+ }),
516
+ }
517
+ : {}),
442
518
  thread: Number(record.thread) + 1,
519
+ runnerId: null,
443
520
  updatedAt: now(),
444
521
  },
445
522
  });
446
523
  const moved = (await findConversation(unit.conn, record.id));
524
+ if (idleOnly) {
525
+ await notice(unit, moved, value);
526
+ return moved;
527
+ }
447
528
  const runIds = withdrawn.map((run) => run.id);
448
529
  const unanswered = await messagesRepo(unit.conn).findMany({
449
530
  filter: (f) => f.and([
@@ -458,6 +539,7 @@ export function createConversationService(deps) {
458
539
  });
459
540
  await notice(unit, moved, value);
460
541
  await wake(unit, moved, unanswered);
542
+ return moved;
461
543
  }
462
544
  // -------------------------------------------------------------------------------------------------------------------
463
545
  // Turning run events into messages
@@ -487,6 +569,10 @@ export function createConversationService(deps) {
487
569
  const events = await eventsAfter(unit.conn, run.id, after, SYNC_PAGE);
488
570
  for (const event of events) {
489
571
  after = Number(event.seq);
572
+ if (event.type === 'input') {
573
+ await pickUp(unit, record, jsonObject(event.meta).inputId);
574
+ continue;
575
+ }
490
576
  const text = event.type === 'text' ? (event.content ?? '') : '';
491
577
  const meta = jsonObject(event.meta);
492
578
  const output = event.type === 'text' ? outputBlock(meta) : undefined;
@@ -521,6 +607,118 @@ export function createConversationService(deps) {
521
607
  values: { syncRunId: run.id, syncSeq: after },
522
608
  });
523
609
  }
610
+ /** A queued message the agent took (its run's `input` event): it is no longer waiting. */
611
+ async function pickUp(unit, record, inputId) {
612
+ if (typeof inputId !== 'string')
613
+ return;
614
+ const [message] = await messagesRepo(unit.conn).findMany({
615
+ filter: (f) => f.and([
616
+ f.string('conversationId').eq(record.id),
617
+ f.string('role').eq('user'),
618
+ f.json('metadata').path(['inputId']).eq(inputId),
619
+ ]),
620
+ limit: 1,
621
+ });
622
+ const metadata = message ? jsonObject(message.metadata) : {};
623
+ if (!message || !metadata.queued || metadata.pickedUp)
624
+ return;
625
+ await messagesRepo(unit.conn).updateMany({
626
+ filter: { id: message.id },
627
+ values: {
628
+ metadata: asJson({ ...metadata, pickedUp: true }),
629
+ updatedAt: now(),
630
+ },
631
+ });
632
+ changed(unit, record, {
633
+ messageId: message.id,
634
+ revision: queuedRevision(message.seq, 'pickedUp'),
635
+ });
636
+ }
637
+ /** Deletes the runs of `ids` with everything they recorded. */
638
+ async function deleteRuns(conn, ids) {
639
+ for (let start = 0; start < ids.length; start += 200) {
640
+ const chunk = ids.slice(start, start + 200);
641
+ for (const name of [
642
+ 'agRunEvents',
643
+ 'agRunUsage',
644
+ 'agRunInputs',
645
+ 'agRunTokens',
646
+ 'agRunRepos',
647
+ 'agRunBriefs',
648
+ ])
649
+ await conn.repository(name).deleteMany({
650
+ filter: (f) => f.or(chunk.map((id) => f.string('runId').eq(id))),
651
+ });
652
+ await runsRepo(conn).deleteMany({
653
+ filter: (f) => f.or(chunk.map((id) => f.string('id').eq(id))),
654
+ });
655
+ }
656
+ }
657
+ /** Ended runs of conversations deleted while those runs were still stopping, a batch at a time. */
658
+ async function purgeOrphanRuns() {
659
+ const conn = tx.read();
660
+ const ended = await runsRepo(conn).findMany({
661
+ filter: (f) => f.and([
662
+ f.string('subjectKind').eq(CONVERSATION_SUBJECT),
663
+ f.or(TERMINAL_RUN_STATUSES.map((status) => f.string('status').eq(status))),
664
+ ]),
665
+ sort: (sort) => sort.field('id').asc(),
666
+ limit: 500,
667
+ });
668
+ const subjects = [...new Set(ended.map((run) => String(run.subjectId)))];
669
+ if (subjects.length === 0)
670
+ return;
671
+ const kept = new Set((await conversationsRepo(conn).findMany({
672
+ filter: (f) => f.or(subjects.map((id) => f.string('id').eq(id))),
673
+ })).map((record) => String(record.id)));
674
+ const orphans = ended
675
+ .filter((run) => !kept.has(String(run.subjectId)))
676
+ .map((run) => String(run.id));
677
+ if (orphans.length)
678
+ await tx.run((unit) => deleteRuns(unit.conn, orphans));
679
+ }
680
+ /**
681
+ * Deletes `record` inside `unit`: cancels its open runs (as `by`), deletes its messages, files, links and kept
682
+ * sessions and the conversation, and announces the change (which settles its inbox notices). Answers what removes
683
+ * the files' bytes once the transaction committed.
684
+ */
685
+ async function remove(unit, record, by) {
686
+ for (const run of await openRunsOnEach(unit.conn, CONVERSATION_SUBJECT, [
687
+ record.id,
688
+ ]))
689
+ await deps.runs.cancel(run.id, by, unit);
690
+ const released = deps.attachments
691
+ ? await deps.attachments.release(unit.conn, record.id)
692
+ : async () => { };
693
+ await messagesRepo(unit.conn).deleteMany({
694
+ filter: { conversationId: record.id },
695
+ });
696
+ await unit.conn
697
+ .repository('agConversationLinks')
698
+ .deleteMany({ filter: { conversationId: record.id } });
699
+ // Runners remove the directories and session files on their next report: the conversation is gone (`settled`).
700
+ await sessionsRepo(unit.conn).deleteMany({
701
+ filter: { subjectKind: CONVERSATION_SUBJECT, subjectId: record.id },
702
+ });
703
+ // Its finished runs and what they recorded; one still stopping goes once it ended (`purgeOrphanRuns`).
704
+ await deleteRuns(unit.conn, (await runsRepo(unit.conn).findMany({
705
+ filter: (f) => f.and([
706
+ f.string('subjectKind').eq(CONVERSATION_SUBJECT),
707
+ f.string('subjectId').eq(record.id),
708
+ f.or(TERMINAL_RUN_STATUSES.map((status) => f.string('status').eq(status))),
709
+ ]),
710
+ })).map((run) => String(run.id)));
711
+ await conversationsRepo(unit.conn).deleteMany({
712
+ filter: { id: record.id },
713
+ });
714
+ changed(unit, record);
715
+ unit.emit({
716
+ type: 'conversation.deleted',
717
+ conversationId: record.id,
718
+ userId: record.userId,
719
+ });
720
+ return released;
721
+ }
524
722
  // -------------------------------------------------------------------------------------------------------------------
525
723
  // The run subject
526
724
  // -------------------------------------------------------------------------------------------------------------------
@@ -539,6 +737,11 @@ export function createConversationService(deps) {
539
737
  ownerName,
540
738
  cli: claim.cli,
541
739
  })));
740
+ const linked = await links.turnSection(conn, conversation);
741
+ const turnSections = [
742
+ ...(linked ? [linked] : []),
743
+ ...added.flatMap((lines) => lines.turn ?? []),
744
+ ];
542
745
  return {
543
746
  subject: {
544
747
  key,
@@ -566,7 +769,10 @@ export function createConversationService(deps) {
566
769
  history,
567
770
  }),
568
771
  turn: {
569
- prompt: conversationTurn(claim.inputs.length),
772
+ prompt: [
773
+ conversationTurn(claim.inputs.length),
774
+ ...turnSections.map((section) => section.join('\n')),
775
+ ].join('\n\n'),
570
776
  ...(previousSummary ? { previousSummary } : {}),
571
777
  },
572
778
  data: {
@@ -582,8 +788,53 @@ export function createConversationService(deps) {
582
788
  scopes: [],
583
789
  };
584
790
  }
791
+ /**
792
+ * After a turn ended: the conversation waits for its owner when they have not seen all of it, or when its agent asked
793
+ * them something; announced once per turn.
794
+ */
795
+ async function attend(unit, id, run) {
796
+ if (run.status !== 'completed' && run.status !== 'failed')
797
+ return;
798
+ const record = await findConversation(unit.conn, id);
799
+ if (!record || record.archivedAt)
800
+ return;
801
+ const seq = Number(record.lastSeq);
802
+ // A failed run asked nothing it can still be answered on: the failure is what waits.
803
+ const reason = run.status === 'failed'
804
+ ? 'failed'
805
+ : record.needsYou === 'confirm'
806
+ ? 'confirm'
807
+ : 'reply';
808
+ if (reason !== 'confirm' && seq <= Number(record.seenSeq ?? 0))
809
+ return;
810
+ const text = reason === 'confirm' ? (record.needsYouText ?? null) : null;
811
+ await conversationsRepo(unit.conn).updateMany({
812
+ filter: { id },
813
+ values: { needsYou: reason, needsYouSeq: seq, needsYouText: text },
814
+ });
815
+ changed(unit, record);
816
+ unit.emit({
817
+ type: 'conversation.attention',
818
+ conversationId: id,
819
+ userId: record.userId,
820
+ reason,
821
+ seq,
822
+ title: record.title,
823
+ text,
824
+ });
825
+ }
585
826
  const binding = {
586
827
  kind: CONVERSATION_SUBJECT,
828
+ // A deleted conversation's work is over: runners remove what they keep for it.
829
+ workspaces: {
830
+ async settled(conn, subjectIds) {
831
+ const kept = await conversationsRepo(conn).findMany({
832
+ filter: (f) => f.or(subjectIds.map((id) => f.string('id').eq(id))),
833
+ });
834
+ const known = new Set(kept.map((record) => String(record.id)));
835
+ return new Set(subjectIds.filter((id) => !known.has(id)));
836
+ },
837
+ },
587
838
  private: true,
588
839
  title: { key: 'subjects.conversation', ns: ACCESS_NAMESPACE },
589
840
  triggers: CONVERSATION_TRIGGERS,
@@ -617,7 +868,8 @@ export function createConversationService(deps) {
617
868
  if (!record || record.userId !== claim.run.actorUserId)
618
869
  throw new Error('The conversation is gone.');
619
870
  const ownerName = await nameOf(conn, record.userId);
620
- const fresh = !claim.runner ||
871
+ const fresh = claim.freshSession === true ||
872
+ !claim.runner ||
621
873
  !(await hasSession(conn, {
622
874
  agentId: claim.agent.id,
623
875
  runnerId: claim.runner.id,
@@ -639,7 +891,8 @@ export function createConversationService(deps) {
639
891
  limit: CONVERSATION_HISTORY_IN_PROMPT + pending.size,
640
892
  });
641
893
  history = recent
642
- .filter((message) => !pending.has(message.id))
894
+ .filter((message) => !pending.has(message.id) &&
895
+ !jsonObject(message.metadata).withdrawn)
643
896
  .slice(0, CONVERSATION_HISTORY_IN_PROMPT)
644
897
  .reverse()
645
898
  .map((message) => {
@@ -686,6 +939,29 @@ export function createConversationService(deps) {
686
939
  : assembly;
687
940
  },
688
941
  },
942
+ runnerPin: {
943
+ async runnerOf(conn, run) {
944
+ const record = await findConversation(conn, run.subject.id);
945
+ if (!record || record.userId !== run.actorUserId)
946
+ return null;
947
+ return record.runnerId ?? null;
948
+ },
949
+ async pin(unit, run, runner) {
950
+ const record = await findConversation(unit.conn, run.subject.id);
951
+ if (!record || record.userId !== run.actorUserId)
952
+ return true;
953
+ if (record.runnerId)
954
+ return record.runnerId === runner.id;
955
+ const bound = await conversationsRepo(unit.conn).updateMany({
956
+ filter: (f) => f.and([f.string('id').eq(record.id), f.string('runnerId').empty()]),
957
+ values: { runnerId: runner.id },
958
+ });
959
+ if (bound.updatedCount !== 1)
960
+ return false;
961
+ changed(unit, record);
962
+ return true;
963
+ },
964
+ },
689
965
  sink: {
690
966
  async onRunFinished(unit, run) {
691
967
  await syncIn(unit, run);
@@ -733,9 +1009,27 @@ export function createConversationService(deps) {
733
1009
  }, now());
734
1010
  changed(unit, record, Number(draft.seq));
735
1011
  }
736
- if (run.status !== 'failed')
737
- return;
738
- await notice(unit, record, { code: 'runFailed', runId: run.id, reason: run.failureReason }, run.id);
1012
+ if (run.status === 'failed')
1013
+ await notice(unit, record, { code: 'runFailed', runId: run.id, reason: run.failureReason }, run.id);
1014
+ // The run's response now closes with its turn's footer, one revision on: a page showing it rereads it.
1015
+ const [answered] = await messagesRepo(unit.conn).findMany({
1016
+ filter: (f) => f.and([
1017
+ f.string('conversationId').eq(record.id),
1018
+ f.string('runId').eq(run.id),
1019
+ f.or([
1020
+ f.string('role').eq('assistant'),
1021
+ f.string('role').eq('user'),
1022
+ ]),
1023
+ ]),
1024
+ sort: (sort) => [sort.field('role').asc(), sort.field('seq').desc()],
1025
+ limit: 1,
1026
+ });
1027
+ if (answered)
1028
+ changed(unit, record, {
1029
+ messageId: `runner:${run.id}`,
1030
+ revision: Number(answered.seq) + 1,
1031
+ });
1032
+ await attend(unit, record.id, run);
739
1033
  },
740
1034
  },
741
1035
  };
@@ -782,6 +1076,8 @@ export function createConversationService(deps) {
782
1076
  }
783
1077
  }
784
1078
  async function sendMessage(userId, id, request, realtime) {
1079
+ if (request.agentId !== undefined && (!realtime || !request.agentId.trim()))
1080
+ throw invalid('Agent selection requires a Runner realtime message.');
785
1081
  const content = request.content;
786
1082
  const attachmentIds = readAttachmentIds(request.attachmentIds);
787
1083
  if (typeof content !== 'string' ||
@@ -814,22 +1110,44 @@ export function createConversationService(deps) {
814
1110
  ? await historicalPageContext(deps.contextKinds, tx.read(), userId, previous[0]?.workContext, realtime.workContext)
815
1111
  : undefined;
816
1112
  const sent = await measuredAdmission(async (unit) => {
817
- const record = await lockOwned(unit, userId, id, Boolean(realtime));
818
- const binding = runnerBinding(record.metadata);
1113
+ let record = await lockOwned(unit, userId, id, Boolean(realtime));
1114
+ let binding = runnerBinding(record.metadata);
819
1115
  if (realtime) {
820
1116
  if (!binding)
821
1117
  throw invalid('This is not a Runner OnlineAgent conversation.');
822
1118
  if (!isDeepStrictEqual(binding.config, realtime.expectedConfig))
823
1119
  throw conflict('The conversation configuration changed; read it again.');
824
- if ((realtime.config.agentId ?? 'online') !== record.agentId)
1120
+ const targetId = request.agentId ?? record.agentId;
1121
+ const switching = targetId !== record.agentId;
1122
+ if ((realtime.config.agentId ?? 'online') !== targetId)
825
1123
  throw conflict('Start a new conversation to choose another agent.');
826
- if (binding.closingRunId ||
827
- (await openRunsOnEach(unit.conn, CONVERSATION_SUBJECT, [id])).length)
828
- throw conflict('This conversation is busy.');
829
- const agent = await pickAgent(unit.conn, userId, record.agentId);
1124
+ if (switching &&
1125
+ (realtime.queue || record.archivedAt || record.mode !== 'runner'))
1126
+ throw conflict('Only an idle, unarchived Runner conversation can change agents.');
1127
+ const open = await openRunsOnEach(unit.conn, CONVERSATION_SUBJECT, [
1128
+ id,
1129
+ ]);
1130
+ // Queued input joins the run at work; otherwise a new one starts only once the last one closed.
1131
+ if (realtime.queue
1132
+ ? !open.some((run) => run.status !== 'queued')
1133
+ : binding.closingRunId || open.length)
1134
+ throw conflict(realtime.queue
1135
+ ? 'The agent is not working on this conversation.'
1136
+ : 'This conversation is busy.');
1137
+ const agent = await pickAgent(unit.conn, userId, targetId);
1138
+ // A conversation bound to a runner waits for it, online or not.
830
1139
  if (agent.source !== 'runner' ||
831
- !(await canAnswer(unit.conn, agent, userId)))
1140
+ ((switching || !record.runnerId) &&
1141
+ !(await canAnswer(unit.conn, agent, userId))))
832
1142
  throw conflict('This agent has no available execution environment.');
1143
+ if (switching) {
1144
+ record = await rebind(unit, record, agent, null, {
1145
+ code: 'switchedAgent',
1146
+ agentId: agent.id,
1147
+ fromAgentId: record.agentId,
1148
+ }, true);
1149
+ binding = runnerBinding(record.metadata);
1150
+ }
833
1151
  if (!isDeepStrictEqual(binding.config, realtime.config))
834
1152
  await conversationsRepo(unit.conn).updateMany({
835
1153
  filter: { id },
@@ -861,6 +1179,7 @@ export function createConversationService(deps) {
861
1179
  workContext: realtime?.workContext ?? context,
862
1180
  metadata: {
863
1181
  ...(request.clientId ? { clientId: request.clientId } : {}),
1182
+ ...(realtime?.queue ? { queued: true } : {}),
864
1183
  },
865
1184
  });
866
1185
  if (links && attachmentIds.length > 0) {
@@ -874,12 +1193,21 @@ export function createConversationService(deps) {
874
1193
  }));
875
1194
  }
876
1195
  const run = await wake(unit, record, [message], realtime?.business, previousPage);
877
- if (realtime && (!run || run.outcome !== 'created'))
878
- throw conflict('This conversation could not accept a new execution.');
1196
+ if (realtime &&
1197
+ (!run ||
1198
+ (realtime.queue
1199
+ ? run.outcome !== 'appended'
1200
+ : run.outcome !== 'created')))
1201
+ throw conflict(realtime.queue
1202
+ ? 'The agent is no longer working on this conversation.'
1203
+ : 'This conversation could not accept a new execution.');
1204
+ // Writing answers what waited for the owner, and they have seen what they answered.
879
1205
  await conversationsRepo(unit.conn).updateMany({
880
1206
  filter: { id },
881
- values: { read: true },
1207
+ values: { read: true, seenSeq: Number(message.seq), ...SETTLED },
882
1208
  });
1209
+ // Announced apart from the new message, so a listener that settles what waited (an inbox notice) hears it.
1210
+ changed(unit, record);
883
1211
  return {
884
1212
  run,
885
1213
  message: (await messagesRepo(unit.conn).findOne({
@@ -905,6 +1233,18 @@ export function createConversationService(deps) {
905
1233
  const limit = limitOf(query.pageSize, CONVERSATION_PAGE_DEFAULT, CONVERSATION_PAGE_MAX);
906
1234
  const cursor = query.pageToken ? decodeCursor(query.pageToken) : null;
907
1235
  const q = query.q?.trim();
1236
+ if ((query.linkKind === undefined) !== (query.linkTargetId === undefined))
1237
+ throw invalid('linkKind and linkTargetId go together.', {
1238
+ field: query.linkKind === undefined ? 'linkKind' : 'linkTargetId',
1239
+ });
1240
+ const linked = query.linkKind !== undefined && query.linkTargetId !== undefined
1241
+ ? await links.conversationIds(conn, {
1242
+ kind: query.linkKind,
1243
+ targetId: query.linkTargetId,
1244
+ })
1245
+ : null;
1246
+ if (linked && linked.length === 0)
1247
+ return { items: [], nextCursor: null };
908
1248
  const rows = await conversationsRepo(conn).findMany({
909
1249
  filter: (f) => f.and([
910
1250
  f.string('userId').eq(userId),
@@ -931,6 +1271,9 @@ export function createConversationService(deps) {
931
1271
  ]
932
1272
  : []),
933
1273
  ...(query.source ? [f.string('source').eq(query.source)] : []),
1274
+ ...(linked
1275
+ ? [f.or(linked.map((id) => f.string('id').eq(id)))]
1276
+ : []),
934
1277
  ...(q
935
1278
  ? [
936
1279
  f.or([
@@ -1029,10 +1372,13 @@ export function createConversationService(deps) {
1029
1372
  values.title = titleFor(patch.title, CONVERSATION_TITLE_MAX);
1030
1373
  values.titleSource = 'user';
1031
1374
  }
1032
- if (patch.archived !== undefined)
1375
+ if (patch.archived !== undefined) {
1033
1376
  values.archivedAt = patch.archived
1034
1377
  ? (record.archivedAt ?? now())
1035
1378
  : null;
1379
+ if (patch.archived)
1380
+ Object.assign(values, SETTLED);
1381
+ }
1036
1382
  if (Object.keys(values).length === 0)
1037
1383
  return;
1038
1384
  await conversationsRepo(unit.conn).updateMany({
@@ -1127,6 +1473,45 @@ export function createConversationService(deps) {
1127
1473
  },
1128
1474
  send: (userId, id, request) => sendMessage(userId, id, request),
1129
1475
  sendRealtime: (userId, id, request, input) => sendMessage(userId, id, request, input),
1476
+ async withdraw(userId, id, messageId) {
1477
+ await tx.run(async (unit) => {
1478
+ const record = await lockOwned(unit, userId, id, true);
1479
+ const message = await messagesRepo(unit.conn).findOne({
1480
+ filter: { id: messageId, conversationId: id },
1481
+ });
1482
+ if (!message || message.role !== 'user')
1483
+ throw notFound('Message');
1484
+ const metadata = jsonObject(message.metadata);
1485
+ if (metadata.withdrawn)
1486
+ return;
1487
+ // Taken back only while no runner has it: one conditional update, which a delivery racing it cannot both
1488
+ // pass (`markDelivered` updates only an input not handled).
1489
+ const taken = !metadata.queued ||
1490
+ metadata.pickedUp ||
1491
+ typeof metadata.inputId !== 'string' ||
1492
+ (await inputsRepo(unit.conn).updateMany({
1493
+ filter: (f) => f.and([
1494
+ f.string('id').eq(metadata.inputId),
1495
+ f.date('deliveredAt').empty(),
1496
+ f.date('handledAt').empty(),
1497
+ ]),
1498
+ values: { handledAt: now() },
1499
+ })).updatedCount !== 1;
1500
+ if (taken)
1501
+ throw conversationConflict('messageTaken', 'The agent already has this message.');
1502
+ await messagesRepo(unit.conn).updateMany({
1503
+ filter: { id: message.id },
1504
+ values: {
1505
+ metadata: asJson({ ...metadata, withdrawn: true }),
1506
+ updatedAt: now(),
1507
+ },
1508
+ });
1509
+ changed(unit, record, {
1510
+ messageId: message.id,
1511
+ revision: queuedRevision(message.seq, 'withdrawn'),
1512
+ });
1513
+ });
1514
+ },
1130
1515
  async start(userId, request) {
1131
1516
  if (!sources.has(request.source))
1132
1517
  throw invalid(`source must be one of ${sources
@@ -1152,18 +1537,292 @@ export function createConversationService(deps) {
1152
1537
  });
1153
1538
  },
1154
1539
  async markRead(userId, id) {
1540
+ const record = await owned(tx.read(), userId, id);
1541
+ return service.markSeen(userId, id, Number(record.lastSeq));
1542
+ },
1543
+ async markSeen(userId, id, seq) {
1544
+ if (!Number.isInteger(seq) || seq < 0)
1545
+ throw invalid('seq must be a message seq.', { field: 'seq' });
1546
+ const changes = (record) => {
1547
+ const last = Number(record.lastSeq);
1548
+ const seen = Math.max(Number(record.seenSeq ?? 0), Math.min(seq, last));
1549
+ const waiting = needsYouOf(record);
1550
+ return {
1551
+ ...(seen !== Number(record.seenSeq ?? 0) ? { seenSeq: seen } : {}),
1552
+ ...(seen >= last && !record.read ? { read: true } : {}),
1553
+ ...(waiting && waiting.reason !== 'confirm' && seen >= waiting.seq
1554
+ ? SETTLED
1555
+ : {}),
1556
+ };
1557
+ };
1558
+ // Nothing new seen: no write at all, not even the lock (a read-only deployment acknowledges freely).
1559
+ if (Object.keys(changes(await owned(tx.read(), userId, id))).length === 0)
1560
+ return detail(userId, id);
1155
1561
  await tx.run(async (unit) => {
1156
1562
  const record = await lockOwned(unit, userId, id, true);
1157
- if (record.read)
1563
+ const values = changes(record);
1564
+ if (Object.keys(values).length === 0)
1158
1565
  return;
1159
1566
  await conversationsRepo(unit.conn).updateMany({
1160
1567
  filter: { id },
1161
- values: { read: true },
1568
+ values,
1162
1569
  });
1163
1570
  changed(unit, record);
1164
1571
  });
1165
1572
  return detail(userId, id);
1166
1573
  },
1574
+ async attention(userId) {
1575
+ const conn = tx.read();
1576
+ const [unread, needsYou] = await Promise.all([
1577
+ conversationsRepo(conn).count({
1578
+ filter: (f) => f.and([
1579
+ f.string('userId').eq(userId),
1580
+ f.boolean('showInChatList').isTrue(),
1581
+ f.date('archivedAt').empty(),
1582
+ f.boolean('read').isFalse(),
1583
+ ]),
1584
+ }),
1585
+ conversationsRepo(conn).count({
1586
+ filter: (f) => f.and([
1587
+ f.string('userId').eq(userId),
1588
+ f.boolean('showInChatList').isTrue(),
1589
+ f.date('archivedAt').empty(),
1590
+ f.string('needsYou').notEmpty(),
1591
+ ]),
1592
+ }),
1593
+ ]);
1594
+ return { unread, needsYou };
1595
+ },
1596
+ async usage(userId, id) {
1597
+ const conn = tx.read();
1598
+ await owned(conn, userId, id);
1599
+ const totals = await conn.query
1600
+ .selectFrom('agRunUsage as u')
1601
+ .innerJoin('agRuns as r', 'r.id', 'u.runId')
1602
+ .where('r.subjectKind', '=', CONVERSATION_SUBJECT)
1603
+ .where('r.subjectId', '=', id)
1604
+ .select((eb) => [
1605
+ eb.fn.sum('u.inputTokens').as('inputTokens'),
1606
+ eb.fn.sum('u.cacheReadTokens').as('cacheReadTokens'),
1607
+ eb.fn.sum('u.outputTokens').as('outputTokens'),
1608
+ eb.fn.count('u.runId').distinct().as('runs'),
1609
+ ])
1610
+ .executeTakeFirst();
1611
+ // The status events its runs reported, read through the Repository: `content` is a text field, which some
1612
+ // dialects (Oracle's CLOB) do not compare with `=`.
1613
+ const runIds = (await runsRepo(conn).findMany({
1614
+ filter: (f) => f.and([
1615
+ f.string('subjectKind').eq(CONVERSATION_SUBJECT),
1616
+ f.string('subjectId').eq(id),
1617
+ ]),
1618
+ })).map((run) => String(run.id));
1619
+ const statuses = async (content, limit) => runIds.length === 0
1620
+ ? []
1621
+ : eventsRepo(conn).findMany({
1622
+ filter: (f) => f.and([
1623
+ f.or(runIds.map((runId) => f.string('runId').eq(runId))),
1624
+ f.string('type').eq('status'),
1625
+ f.text('content').eq(content),
1626
+ ]),
1627
+ sort: (sort) => limit
1628
+ ? [sort.field('createdAt').desc(), sort.field('seq').desc()]
1629
+ : [sort.field('seq').asc()],
1630
+ ...(limit ? { limit } : {}),
1631
+ });
1632
+ // The latest run that said how full its context window was.
1633
+ const [latest] = await statuses('context', 1);
1634
+ const context = jsonObject(jsonObject(latest?.meta).context);
1635
+ const used = Number(context.used);
1636
+ const size = Number(context.size);
1637
+ // A run's tool reports what it cost so far at each turn's end: its last report is the run's.
1638
+ const turns = await statuses('turnCompleted');
1639
+ const costs = new Map();
1640
+ for (const turn of turns) {
1641
+ const cost = Number(jsonObject(turn.meta).costUsd);
1642
+ if (Number.isFinite(cost) && cost >= 0)
1643
+ costs.set(String(turn.runId), cost);
1644
+ }
1645
+ return {
1646
+ inputTokens: Number(totals?.inputTokens ?? 0),
1647
+ cacheReadTokens: Number(totals?.cacheReadTokens ?? 0),
1648
+ outputTokens: Number(totals?.outputTokens ?? 0),
1649
+ runs: Number(totals?.runs ?? 0),
1650
+ context: Number.isFinite(used) && Number.isFinite(size) && size > 0
1651
+ ? { used, size }
1652
+ : null,
1653
+ costUsd: costs.size
1654
+ ? Math.round([...costs.values()].reduce((sum, cost) => sum + cost, 0) * 1e6) / 1e6
1655
+ : null,
1656
+ };
1657
+ },
1658
+ async markNoticed(conversationId, seq) {
1659
+ await tx.run(async (unit) => {
1660
+ const record = await lockConversation(unit.conn, conversationId, now());
1661
+ if (!record || Number(record.noticedSeq ?? -1) >= seq)
1662
+ return;
1663
+ await conversationsRepo(unit.conn).updateMany({
1664
+ filter: { id: conversationId },
1665
+ values: { noticedSeq: seq },
1666
+ });
1667
+ });
1668
+ },
1669
+ async switchAgent(userId, id, agentId) {
1670
+ if (typeof agentId !== 'string' || !agentId.trim())
1671
+ throw invalid('Select a valid agentId.');
1672
+ await tx.run(async (unit) => {
1673
+ const record = await lockOwned(unit, userId, id, true);
1674
+ if (record.mode !== 'runner' || jsonObject(record.metadata).onlineAgent)
1675
+ throw invalid('Only Runner conversations can switch agents.');
1676
+ if (record.archivedAt)
1677
+ throw conflict('Restore the conversation before changing agents.', {
1678
+ reason: 'archived',
1679
+ });
1680
+ const agent = await pickAgent(unit.conn, userId, agentId);
1681
+ if (agent.source !== 'runner')
1682
+ throw invalid('Select a callable Runner agent.');
1683
+ if (record.agentId === agent.id)
1684
+ return;
1685
+ const binding = runnerBinding(record.metadata);
1686
+ // Sending, queueing and switching serialize on this same conversation row. An admitted or closing stream
1687
+ // also keeps the boundary busy until its durable run and canonical history have settled.
1688
+ if (deps.runnerBusy?.(id) ||
1689
+ binding?.closingRunId ||
1690
+ (await openRunsOnEach(unit.conn, CONVERSATION_SUBJECT, [id])).length)
1691
+ throw conflict('Finish the conversation’s pending work before changing agents.', { reason: 'busy' });
1692
+ await rebind(unit, record, agent, null, {
1693
+ code: 'switchedAgent',
1694
+ agentId: agent.id,
1695
+ fromAgentId: record.agentId,
1696
+ }, true);
1697
+ });
1698
+ return detail(userId, id);
1699
+ },
1700
+ async switchRunner(userId, id) {
1701
+ await tx.run(async (unit) => {
1702
+ const record = await lockOwned(unit, userId, id, true);
1703
+ if (!record.runnerId)
1704
+ return;
1705
+ await conversationsRepo(unit.conn).updateMany({
1706
+ filter: { id },
1707
+ values: { runnerId: null, updatedAt: now() },
1708
+ });
1709
+ changed(unit, record);
1710
+ });
1711
+ return detail(userId, id);
1712
+ },
1713
+ async askOwnerFromRun(run, question, issue) {
1714
+ const text = question.trim();
1715
+ if (text === '' || [...text].length > QUESTION_MAX)
1716
+ throw invalid(`question must be 1 to ${QUESTION_MAX} characters.`, {
1717
+ field: 'question',
1718
+ });
1719
+ const conversation = await tx.run(async (unit) => {
1720
+ if (run.subject.kind !== CONVERSATION_SUBJECT)
1721
+ throw notFound('Conversation');
1722
+ const record = await lockConversation(unit.conn, run.subject.id, now());
1723
+ if (!record || record.userId !== run.actorUserId)
1724
+ throw notFound('Conversation');
1725
+ await conversationsRepo(unit.conn).updateMany({
1726
+ filter: { id: record.id },
1727
+ values: {
1728
+ needsYou: 'confirm',
1729
+ needsYouSeq: Number(record.lastSeq),
1730
+ needsYouText: text,
1731
+ metadata: asJson({
1732
+ ...jsonObject(record.metadata),
1733
+ ownerQuestion: issue ? { issue, askedAt: now() } : null,
1734
+ }),
1735
+ },
1736
+ });
1737
+ changed(unit, record);
1738
+ return record;
1739
+ });
1740
+ return detail(conversation.userId, conversation.id);
1741
+ },
1742
+ async settleOwnerQuestion(id, issue, resumedAt) {
1743
+ const resumed = Date.parse(resumedAt);
1744
+ const matches = (record) => {
1745
+ if (!record || record.needsYou !== 'confirm')
1746
+ return false;
1747
+ const question = jsonObject(jsonObject(record.metadata).ownerQuestion);
1748
+ return (question.issue === issue &&
1749
+ typeof question.askedAt === 'string' &&
1750
+ Date.parse(question.askedAt) < resumed);
1751
+ };
1752
+ // Filter before taking the existing conversation write boundary, then recheck under it.
1753
+ if (!matches(await findConversation(tx.read(), id)))
1754
+ return false;
1755
+ return tx.run(async (unit) => {
1756
+ const record = await lockConversation(unit.conn, id, now());
1757
+ if (!record || !matches(record))
1758
+ return false;
1759
+ await conversationsRepo(unit.conn).updateMany({
1760
+ filter: { id },
1761
+ values: {
1762
+ ...SETTLED,
1763
+ metadata: asJson({
1764
+ ...jsonObject(record.metadata),
1765
+ ownerQuestion: null,
1766
+ }),
1767
+ },
1768
+ });
1769
+ changed(unit, record);
1770
+ return true;
1771
+ });
1772
+ },
1773
+ async ofRunId(runId) {
1774
+ const conn = tx.read();
1775
+ const stored = await findRunRecord(conn, runId);
1776
+ return stored ? service.homeOf(conn, toRun(stored)) : null;
1777
+ },
1778
+ links,
1779
+ async delete(userId, id) {
1780
+ const cleanup = await tx.run(async (unit) => {
1781
+ const record = await lockOwned(unit, userId, id, true);
1782
+ return remove(unit, record, userId);
1783
+ });
1784
+ await cleanup();
1785
+ },
1786
+ async purgeArchived(days, at = new Date()) {
1787
+ if (days <= 0)
1788
+ return 0;
1789
+ const cutoff = new Date(at.getTime() - days * 86_400_000).toISOString();
1790
+ const started = Date.now();
1791
+ let purged = 0;
1792
+ for (;;) {
1793
+ const due = await conversationsRepo(tx.read()).findMany({
1794
+ filter: (f) => f.and([
1795
+ f.date('archivedAt').notEmpty(),
1796
+ f.date('archivedAt').before(cutoff),
1797
+ ]),
1798
+ sort: (sort) => [
1799
+ sort.field('archivedAt').asc(),
1800
+ sort.field('id').asc(),
1801
+ ],
1802
+ limit: ARCHIVED_PURGE_BATCH,
1803
+ });
1804
+ for (const candidate of due) {
1805
+ const cleanup = await tx.run(async (unit) => {
1806
+ const record = await lockConversation(unit.conn, candidate.id, now());
1807
+ // Restored meanwhile, or already gone: not counted.
1808
+ if (!record?.archivedAt ||
1809
+ new Date(record.archivedAt).toISOString() >= cutoff)
1810
+ return null;
1811
+ return remove(unit, record, record.userId);
1812
+ });
1813
+ if (!cleanup)
1814
+ continue;
1815
+ await cleanup();
1816
+ purged += 1;
1817
+ }
1818
+ if (due.length < ARCHIVED_PURGE_BATCH ||
1819
+ purged >= ARCHIVED_PURGE_LIMIT ||
1820
+ Date.now() - started > ARCHIVED_PURGE_TIME_MS) {
1821
+ await purgeOrphanRuns();
1822
+ return purged;
1823
+ }
1824
+ }
1825
+ },
1167
1826
  async stop(userId, id) {
1168
1827
  await tx.run(async (unit) => {
1169
1828
  const record = await lockOwned(unit, userId, id, true);
@@ -1276,11 +1935,13 @@ export function createConversationService(deps) {
1276
1935
  if (!record)
1277
1936
  return null;
1278
1937
  await notice(outer, record, news.notice);
1279
- // New bindings save business notices atomically but never wake a second execution.
1938
+ // Native bindings save business notices atomically but never wake a second execution; a Runner binding only
1939
+ // when the application asks it to.
1280
1940
  if (jsonObject(record.metadata).onlineAgent ||
1281
- runnerBinding(record.metadata))
1941
+ (runnerBinding(record.metadata) && !news.wake))
1282
1942
  return null;
1283
- if (!news.input)
1943
+ // An archived conversation keeps the news but is not woken by it.
1944
+ if (!news.input || record.archivedAt)
1284
1945
  return null;
1285
1946
  const agent = record.agentId
1286
1947
  ? await findAgent(outer.conn, record.agentId)