@nocobase/app-plugin-agents 1.0.0-beta.26 → 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.
- package/CHANGELOG.md +58 -0
- package/README.md +40 -22
- package/dist/client/chat/api.d.ts +18 -1
- package/dist/client/chat/api.d.ts.map +1 -1
- package/dist/client/chat/api.js +37 -0
- package/dist/client/chat/api.js.map +1 -1
- package/dist/client/chat/coalesced-invalidation.d.ts +15 -0
- package/dist/client/chat/coalesced-invalidation.d.ts.map +1 -0
- package/dist/client/chat/coalesced-invalidation.js +29 -0
- package/dist/client/chat/coalesced-invalidation.js.map +1 -0
- package/dist/client/chat/controller.d.ts +8 -1
- package/dist/client/chat/controller.d.ts.map +1 -1
- package/dist/client/chat/controller.js +21 -0
- package/dist/client/chat/controller.js.map +1 -1
- package/dist/client/chat/message-model.d.ts.map +1 -1
- package/dist/client/chat/message-model.js +4 -0
- package/dist/client/chat/message-model.js.map +1 -1
- package/dist/client/chat/submissions.d.ts +3 -0
- package/dist/client/chat/submissions.d.ts.map +1 -1
- package/dist/client/chat/submissions.js +1 -0
- package/dist/client/chat/submissions.js.map +1 -1
- package/dist/client/chat/use-chat-scroll.d.ts +8 -1
- package/dist/client/chat/use-chat-scroll.d.ts.map +1 -1
- package/dist/client/chat/use-chat-scroll.js +29 -5
- package/dist/client/chat/use-chat-scroll.js.map +1 -1
- package/dist/client/chat/use-chat.d.ts +2 -0
- package/dist/client/chat/use-chat.d.ts.map +1 -1
- package/dist/client/chat/use-chat.js +17 -7
- package/dist/client/chat/use-chat.js.map +1 -1
- package/dist/client/components/agent-prompt-callout.d.ts +19 -0
- package/dist/client/components/agent-prompt-callout.d.ts.map +1 -0
- package/dist/client/components/agent-prompt-callout.js +28 -0
- package/dist/client/components/agent-prompt-callout.js.map +1 -0
- package/dist/client/locales/chat.en-US.d.ts +1 -0
- package/dist/client/locales/chat.en-US.d.ts.map +1 -1
- package/dist/client/locales/chat.en-US.js +1 -0
- package/dist/client/locales/chat.en-US.js.map +1 -1
- package/dist/client/locales/chat.zh-CN.d.ts.map +1 -1
- package/dist/client/locales/chat.zh-CN.js +1 -0
- package/dist/client/locales/chat.zh-CN.js.map +1 -1
- package/dist/client/locales/de-DE.d.ts.map +1 -1
- package/dist/client/locales/de-DE.js +1 -0
- package/dist/client/locales/de-DE.js.map +1 -1
- package/dist/client/locales/es-ES.d.ts.map +1 -1
- package/dist/client/locales/es-ES.js +1 -0
- package/dist/client/locales/es-ES.js.map +1 -1
- package/dist/client/locales/fr-FR.d.ts.map +1 -1
- package/dist/client/locales/fr-FR.js +1 -0
- package/dist/client/locales/fr-FR.js.map +1 -1
- package/dist/client/locales/id-ID.d.ts.map +1 -1
- package/dist/client/locales/id-ID.js +1 -0
- package/dist/client/locales/id-ID.js.map +1 -1
- package/dist/client/locales/ja-JP.d.ts.map +1 -1
- package/dist/client/locales/ja-JP.js +1 -0
- package/dist/client/locales/ja-JP.js.map +1 -1
- package/dist/client/locales/pt-BR.d.ts.map +1 -1
- package/dist/client/locales/pt-BR.js +1 -0
- package/dist/client/locales/pt-BR.js.map +1 -1
- package/dist/client/locales/runtimes.en-US.d.ts +4 -1
- package/dist/client/locales/runtimes.en-US.d.ts.map +1 -1
- package/dist/client/locales/runtimes.en-US.js +6 -3
- package/dist/client/locales/runtimes.en-US.js.map +1 -1
- package/dist/client/locales/runtimes.zh-CN.d.ts.map +1 -1
- package/dist/client/locales/runtimes.zh-CN.js +6 -3
- package/dist/client/locales/runtimes.zh-CN.js.map +1 -1
- package/dist/client/locales/vi-VN.d.ts.map +1 -1
- package/dist/client/locales/vi-VN.js +1 -0
- package/dist/client/locales/vi-VN.js.map +1 -1
- package/dist/client/locales/zh-TW.d.ts.map +1 -1
- package/dist/client/locales/zh-TW.js +1 -0
- package/dist/client/locales/zh-TW.js.map +1 -1
- package/dist/client/pages/runtimes/connect.d.ts.map +1 -1
- package/dist/client/pages/runtimes/connect.js +16 -10
- package/dist/client/pages/runtimes/connect.js.map +1 -1
- package/dist/client/pages/skills/index.js +2 -2
- package/dist/client/pages/skills/index.js.map +1 -1
- package/dist/database/migrations/.manifest.json +8 -0
- package/dist/database/migrations/202611010001_ag_conversation_runner_attention.d.ts +4 -0
- package/dist/database/migrations/202611010001_ag_conversation_runner_attention.d.ts.map +1 -0
- package/dist/database/migrations/202611010001_ag_conversation_runner_attention.js +30 -0
- package/dist/database/migrations/202611010001_ag_conversation_runner_attention.js.map +1 -0
- package/dist/database/migrations/202611010002_ag_create_conversation_links.d.ts +4 -0
- package/dist/database/migrations/202611010002_ag_create_conversation_links.d.ts.map +1 -0
- package/dist/database/migrations/202611010002_ag_create_conversation_links.js +30 -0
- package/dist/database/migrations/202611010002_ag_create_conversation_links.js.map +1 -0
- package/dist/package.json +1 -1
- package/dist/server/composition.d.ts.map +1 -1
- package/dist/server/composition.js +1 -0
- package/dist/server/composition.js.map +1 -1
- package/dist/server/core/conversations/attachments.d.ts +5 -0
- package/dist/server/core/conversations/attachments.d.ts.map +1 -1
- package/dist/server/core/conversations/attachments.js +10 -0
- package/dist/server/core/conversations/attachments.js.map +1 -1
- package/dist/server/core/conversations/conversation.service.d.ts +3 -1
- package/dist/server/core/conversations/conversation.service.d.ts.map +1 -1
- package/dist/server/core/conversations/conversation.service.js +693 -32
- package/dist/server/core/conversations/conversation.service.js.map +1 -1
- package/dist/server/core/conversations/conversation.store.d.ts +24 -0
- package/dist/server/core/conversations/conversation.store.d.ts.map +1 -1
- package/dist/server/core/conversations/conversation.store.js +10 -0
- package/dist/server/core/conversations/conversation.store.js.map +1 -1
- package/dist/server/core/conversations/index.d.ts +1 -1
- package/dist/server/core/conversations/index.d.ts.map +1 -1
- package/dist/server/core/conversations/index.js.map +1 -1
- package/dist/server/core/conversations/links.d.ts +27 -0
- package/dist/server/core/conversations/links.d.ts.map +1 -0
- package/dist/server/core/conversations/links.js +188 -0
- package/dist/server/core/conversations/links.js.map +1 -0
- package/dist/server/core/conversations/ports.d.ts +107 -3
- package/dist/server/core/conversations/ports.d.ts.map +1 -1
- package/dist/server/core/runs/claim.d.ts.map +1 -1
- package/dist/server/core/runs/claim.js +47 -3
- package/dist/server/core/runs/claim.js.map +1 -1
- package/dist/server/core/runs/lifecycle.d.ts.map +1 -1
- package/dist/server/core/runs/lifecycle.js +28 -3
- package/dist/server/core/runs/lifecycle.js.map +1 -1
- package/dist/server/core/runs/ports.d.ts +15 -0
- package/dist/server/core/runs/ports.d.ts.map +1 -1
- package/dist/server/core/runs/ports.js.map +1 -1
- package/dist/server/core/runs/run-data-purge.d.ts +7 -0
- package/dist/server/core/runs/run-data-purge.d.ts.map +1 -1
- package/dist/server/core/runs/run-data-purge.js +13 -0
- package/dist/server/core/runs/run-data-purge.js.map +1 -1
- package/dist/server/core/runs/run.store.d.ts +6 -2
- package/dist/server/core/runs/run.store.d.ts.map +1 -1
- package/dist/server/core/runs/run.store.js +28 -10
- package/dist/server/core/runs/run.store.js.map +1 -1
- package/dist/server/kernel/errors.d.ts +1 -1
- package/dist/server/kernel/errors.d.ts.map +1 -1
- package/dist/server/kernel/errors.js +2 -0
- package/dist/server/kernel/errors.js.map +1 -1
- package/dist/server/kernel/events.d.ts +19 -0
- package/dist/server/kernel/events.d.ts.map +1 -1
- package/dist/server/kernel/events.js.map +1 -1
- package/dist/server/online/agent/contracts.d.ts +5 -0
- package/dist/server/online/agent/contracts.d.ts.map +1 -1
- package/dist/server/online/agent/native/session.d.ts +1 -0
- package/dist/server/online/agent/native/session.d.ts.map +1 -1
- package/dist/server/online/agent/native/session.js +4 -1
- package/dist/server/online/agent/native/session.js.map +1 -1
- package/dist/server/online/agent/persistence/store.d.ts.map +1 -1
- package/dist/server/online/agent/persistence/store.js +8 -1
- package/dist/server/online/agent/persistence/store.js.map +1 -1
- package/dist/server/online/agent/runner/backend.d.ts +15 -0
- package/dist/server/online/agent/runner/backend.d.ts.map +1 -1
- package/dist/server/online/agent/runner/backend.js +310 -70
- package/dist/server/online/agent/runner/backend.js.map +1 -1
- package/dist/server/online/agent/runner/projection.d.ts +24 -2
- package/dist/server/online/agent/runner/projection.d.ts.map +1 -1
- package/dist/server/online/agent/runner/projection.js +330 -26
- package/dist/server/online/agent/runner/projection.js.map +1 -1
- package/dist/server/online/agent/runner/types.d.ts +8 -1
- package/dist/server/online/agent/runner/types.d.ts.map +1 -1
- package/dist/server/online/agent/types.d.ts +2 -0
- package/dist/server/online/agent/types.d.ts.map +1 -1
- package/dist/server/providers/agents.d.ts +7 -0
- package/dist/server/providers/agents.d.ts.map +1 -1
- package/dist/server/providers/agents.js +3 -1
- package/dist/server/providers/agents.js.map +1 -1
- package/dist/server/providers/runners.d.ts.map +1 -1
- package/dist/server/providers/runners.js +12 -1
- package/dist/server/providers/runners.js.map +1 -1
- package/dist/server/routes/chat.d.ts.map +1 -1
- package/dist/server/routes/chat.js +270 -2
- package/dist/server/routes/chat.js.map +1 -1
- package/dist/server/routes/run.d.ts.map +1 -1
- package/dist/server/routes/run.js +44 -0
- package/dist/server/routes/run.js.map +1 -1
- package/dist/server/routes/schemas.d.ts +15 -1
- package/dist/server/routes/schemas.d.ts.map +1 -1
- package/dist/server/routes/schemas.js +116 -1
- package/dist/server/routes/schemas.js.map +1 -1
- package/dist/server/runners/workspaces.d.ts.map +1 -1
- package/dist/server/runners/workspaces.js +46 -2
- package/dist/server/runners/workspaces.js.map +1 -1
- package/dist/server/tokens.d.ts +1 -1
- package/dist/server/tokens.d.ts.map +1 -1
- package/dist/server/tokens.js.map +1 -1
- package/dist/shared/conversations.d.ts +107 -1
- package/dist/shared/conversations.d.ts.map +1 -1
- package/dist/shared/conversations.js +19 -0
- package/dist/shared/conversations.js.map +1 -1
- package/dist/shared/ui-message.d.ts +30 -0
- package/dist/shared/ui-message.d.ts.map +1 -1
- package/dist/shared/ui-message.js +17 -0
- package/dist/shared/ui-message.js.map +1 -1
- package/package.json +10 -10
- package/skills/nocobase-app-plugin-agents/references/extension-points.md +19 -19
- 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,
|
|
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
|
-
...(
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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 =
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
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
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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 (
|
|
827
|
-
(
|
|
828
|
-
throw conflict('
|
|
829
|
-
const
|
|
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
|
-
|
|
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 &&
|
|
878
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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)
|