dsh-bot-team 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +194 -0
- package/README.md +206 -156
- package/client.js +3706 -746
- package/cordis.patch.yml +2 -2
- package/host/agents.js +469 -289
- package/host/background-interactions.js +213 -0
- package/host/conversations.js +45 -0
- package/host/discussion-flow.js +179 -0
- package/host/discussion-runner.js +491 -0
- package/host/domain.js +551 -69
- package/host/engine.js +621 -166
- package/host/execution.js +192 -121
- package/host/native-activity.js +225 -0
- package/host/native-session.js +144 -0
- package/host/pdf.js +43 -6
- package/host/prompt.js +253 -9
- package/host/routes.js +31 -1
- package/host/service.js +751 -127
- package/host/session-export.js +22 -0
- package/host/session-visibility.js +147 -0
- package/host/storage-layout.js +109 -0
- package/host/tools.js +314 -85
- package/host/update-check.js +351 -0
- package/host/util.js +3 -2
- package/host/workspace.js +36 -2
- package/index.js +1 -1
- package/package.json +14 -6
package/host/execution.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/** Read
|
|
1
|
+
/** Read Bot executions and group-owned Session history through session-query. */
|
|
2
|
+
import { conversationIdOf } from './conversations.js';
|
|
2
3
|
const ID = /^[A-Za-z0-9_-]{1,256}$/;
|
|
3
4
|
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,255}$/;
|
|
4
5
|
const ACTIVE = new Set(['pending', 'streaming', 'running', 'queued']);
|
|
@@ -57,18 +58,6 @@ function clone(value, ancestors = new Set()) {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
function pick(value, keys) {
|
|
61
|
-
const result = {};
|
|
62
|
-
if (!record(value)) return result;
|
|
63
|
-
for (const key of keys) {
|
|
64
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
65
|
-
if (!descriptor || !own(descriptor, 'value')) continue;
|
|
66
|
-
const copied = clone(descriptor.value);
|
|
67
|
-
if (copied !== undefined) result[key] = copied;
|
|
68
|
-
}
|
|
69
|
-
return result;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
61
|
function reference(message) {
|
|
73
62
|
const raw = message.meta?.execution;
|
|
74
63
|
if (raw === undefined || raw === null) return { legacy: true, execution: null };
|
|
@@ -112,16 +101,36 @@ function response(message, afterSeq, availability, execution, extra = {}) {
|
|
|
112
101
|
};
|
|
113
102
|
}
|
|
114
103
|
|
|
104
|
+
function validateExecutionOwnership(repo, message, execution) {
|
|
105
|
+
if (!repo.getGroup(message.groupId)?.activeConversationId || !execution?.sessionId) return;
|
|
106
|
+
const conversationId = conversationIdOf(repo, message);
|
|
107
|
+
const binding = repo.sessionBindingFor?.(execution.sessionId);
|
|
108
|
+
if (!conversationId || (binding && (binding.groupId !== message.groupId ||
|
|
109
|
+
binding.conversationId !== conversationId || binding.membershipId !== message.author.membershipId ||
|
|
110
|
+
binding.botId !== message.author.botId))) fail('invalid-input', 'Execution Session does not belong to this message');
|
|
111
|
+
}
|
|
112
|
+
|
|
115
113
|
function legacyResponse(repo, message, afterSeq) {
|
|
116
114
|
const member = repo.getMember?.(message.author.membershipId);
|
|
117
|
-
const
|
|
118
|
-
typeof message.author.membershipId === 'string' && ID.test(message.author.membershipId)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
const validAuthor = typeof message.author.botId === 'string' && ID.test(message.author.botId) &&
|
|
116
|
+
typeof message.author.membershipId === 'string' && ID.test(message.author.membershipId);
|
|
117
|
+
let fallbackSessionId;
|
|
118
|
+
if (repo.getGroup(message.groupId)?.activeConversationId) {
|
|
119
|
+
// A legacy message cannot borrow a newer member Session. Only a unique,
|
|
120
|
+
// committed binding for this message's own conversation proves a fallback.
|
|
121
|
+
const conversationId = conversationIdOf(repo, message);
|
|
122
|
+
const bindings = conversationId && validAuthor ? (repo.sessionBindingsOf?.(message.groupId, conversationId) || []) : [];
|
|
123
|
+
const candidates = new Set(bindings.filter(binding =>
|
|
124
|
+
binding.groupId === message.groupId && binding.conversationId === conversationId &&
|
|
125
|
+
binding.membershipId === message.author.membershipId && binding.botId === message.author.botId &&
|
|
126
|
+
typeof binding.sessionId === 'string' && SESSION_ID.test(binding.sessionId)).map(binding => binding.sessionId));
|
|
127
|
+
if (candidates.size === 1) fallbackSessionId = [...candidates][0];
|
|
128
|
+
} else if (validAuthor && member && member.groupId === message.groupId &&
|
|
129
|
+
member.membershipId === message.author.membershipId && member.botId === message.author.botId &&
|
|
130
|
+
typeof member.sessionId === 'string' && SESSION_ID.test(member.sessionId)) fallbackSessionId = member.sessionId;
|
|
122
131
|
return response(message, afterSeq, 'legacy-unlinked', null, {
|
|
123
132
|
reason: 'legacy-message-has-no-execution-reference',
|
|
124
|
-
...(
|
|
133
|
+
...(fallbackSessionId ? { fallbackSessionId } : {}),
|
|
125
134
|
});
|
|
126
135
|
}
|
|
127
136
|
|
|
@@ -140,11 +149,16 @@ function observationEvents(observation, sessionId) {
|
|
|
140
149
|
if (!observation || observation.header?.id !== sessionId || typeof observation[Symbol.dispose] !== 'function') {
|
|
141
150
|
fail('execution-log-corrupt', 'Session observation identity or lease is invalid');
|
|
142
151
|
}
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
return validatedEvents(observation.events, observation.cursor);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function validatedEvents(events, cursor) {
|
|
145
156
|
if (!Array.isArray(events) || !Number.isSafeInteger(cursor) || cursor < -1) fail('execution-log-corrupt', 'Invalid session snapshot');
|
|
146
|
-
if (events.length !== cursor + 1
|
|
147
|
-
|
|
157
|
+
if (events.length !== cursor + 1) fail('execution-log-corrupt', 'Session log is not a contiguous snapshot');
|
|
158
|
+
for (const [index, event] of events.entries()) {
|
|
159
|
+
if (!record(event) || event.seq !== index || typeof event.type !== 'string') {
|
|
160
|
+
fail('execution-log-corrupt', 'Session log is not a contiguous snapshot');
|
|
161
|
+
}
|
|
148
162
|
}
|
|
149
163
|
return events;
|
|
150
164
|
}
|
|
@@ -236,115 +250,164 @@ function resolveSpan(events, execution) {
|
|
|
236
250
|
return { span };
|
|
237
251
|
}
|
|
238
252
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
function userMessage(data) {
|
|
244
|
-
const result = pick(data, ['id', 'role']);
|
|
245
|
-
result.source = pick(data?.source, ['kind']);
|
|
246
|
-
result.content = Array.isArray(data?.content) ? data.content.filter((block) =>
|
|
247
|
-
block && ['text', 'image', 'image-url', 'file', 'audio'].includes(block.type) && block.hidden !== true && block.internal !== true,
|
|
248
|
-
).map((block) => block.type === 'text' ? pick(block, ['type', 'text']) : clone(block)) : [];
|
|
249
|
-
return result;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** Explicit observable vocabulary: no request envelopes, inbox copies or opaque streams. */
|
|
253
|
-
function project(event, inputMessageId) {
|
|
254
|
-
const data = record(event.data) ? event.data : {};
|
|
255
|
-
let projected;
|
|
256
|
-
switch (event.type) {
|
|
257
|
-
case 'user/message':
|
|
258
|
-
if (event.surfaceOp && event.surfaceOp !== 'append') return null;
|
|
259
|
-
if (data.id !== inputMessageId && data.source?.kind !== 'user') return null;
|
|
260
|
-
projected = userMessage(data);
|
|
261
|
-
break;
|
|
262
|
-
case 'turn/start':
|
|
263
|
-
case 'step/start':
|
|
264
|
-
case 'step/end':
|
|
265
|
-
case 'assistant/attempt':
|
|
266
|
-
projected = pick(data, ['turn', 'step']);
|
|
267
|
-
break;
|
|
268
|
-
case 'turn/end':
|
|
269
|
-
projected = pick(data, ['turn']);
|
|
270
|
-
projected.reason = pick(data.reason, ['kind']);
|
|
271
|
-
if (data.reason?.reason) projected.reason.reason = pick(data.reason.reason, ['kind']);
|
|
272
|
-
if (data.reason?.error) projected.reason.error = errorInfo(data.reason.error);
|
|
273
|
-
break;
|
|
274
|
-
case 'assistant/message': {
|
|
275
|
-
if (event.surfaceOp && event.surfaceOp !== 'append') return null;
|
|
276
|
-
const content = Array.isArray(data.message?.content) ? data.message.content
|
|
277
|
-
.filter((block) => block?.type === 'text' && typeof block.text === 'string' && block.hidden !== true && block.internal !== true && block.visibility !== 'hidden')
|
|
278
|
-
.map((block) => ({ type: 'text', text: block.text })) : [];
|
|
279
|
-
if (content.length === 0) return null;
|
|
280
|
-
projected = pick(data, ['turn', 'step', 'interrupted', 'usage']);
|
|
281
|
-
projected.message = { ...pick(data.message, ['id', 'role']), content, source: pick(data.message?.source, ['provider', 'model']) };
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
284
|
-
case 'tool/call':
|
|
285
|
-
projected = pick(data, ['turn', 'step', 'callId', 'name', 'arguments']);
|
|
286
|
-
break;
|
|
287
|
-
case 'tool/result':
|
|
288
|
-
if (event.surfaceOp && event.surfaceOp !== 'append') return null;
|
|
289
|
-
projected = pick(data, ['turn', 'step', 'meta']);
|
|
290
|
-
projected.message = pick(data.message, ['id', 'role', 'toolCallId', 'isError', 'content']);
|
|
291
|
-
projected.message.source = pick(data.message?.source, ['kind', 'callId']);
|
|
292
|
-
if (data.error !== undefined) projected.error = errorInfo(data.error);
|
|
293
|
-
break;
|
|
294
|
-
case 'tool/ptc-dispatch-start':
|
|
295
|
-
case 'tool/ptc-dispatch':
|
|
296
|
-
case 'tool/ptc-dispatch-end':
|
|
297
|
-
projected = pick(data, ['turn', 'step', 'rootCallId', 'parentCallId', 'subCallId', 'name', 'arguments', 'isError', 'content', 'meta']);
|
|
298
|
-
if (data.error !== undefined) projected.error = errorInfo(data.error);
|
|
299
|
-
break;
|
|
300
|
-
case 'approval/asked':
|
|
301
|
-
projected = pick(data, ['id', 'toolName', 'callId', 'reason']);
|
|
302
|
-
break;
|
|
303
|
-
case 'approval/decided':
|
|
304
|
-
projected = pick(data, ['id', 'outcome']);
|
|
305
|
-
break;
|
|
306
|
-
case 'llm/retry':
|
|
307
|
-
case 'llm/retry-started':
|
|
308
|
-
projected = pick(data, ['turn', 'step', 'retryId', 'retry', 'provider', 'mode', 'maxRetries', 'delayMs']);
|
|
309
|
-
if (data.failure !== undefined) projected.failure = errorInfo(data.failure);
|
|
310
|
-
break;
|
|
311
|
-
case 'compaction/start':
|
|
312
|
-
case 'compaction/summary':
|
|
313
|
-
case 'compaction/end':
|
|
314
|
-
case 'compaction/prune':
|
|
315
|
-
// The summary/rawOutput can repeat prompts, reasoning and adjacent turns.
|
|
316
|
-
projected = pick(data, ['turn', 'compactionId', 'sourceCommandId', 'shadowedRange', 'shadowedSeqs', 'shadowedTokenCount', 'provider', 'model', 'maxTokens', 'usage', 'prunedSeqs']);
|
|
317
|
-
if (data.error !== undefined) projected.error = errorInfo(data.error);
|
|
318
|
-
break;
|
|
319
|
-
default:
|
|
320
|
-
return null;
|
|
321
|
-
}
|
|
322
|
-
return { seq: event.seq, time: clone(event.time) ?? null, type: event.type, data: projected };
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function page(events, span, execution, afterSeq, limit) {
|
|
326
|
-
const visible = [];
|
|
253
|
+
/** Keep the SDK event envelope and payload intact, including future event types. */
|
|
254
|
+
function page(events, span, afterSeq, limit) {
|
|
255
|
+
const selected = [];
|
|
327
256
|
let nextSeq = afterSeq;
|
|
328
257
|
for (const event of events) {
|
|
329
258
|
if (event.seq < span.startSeq || event.seq > span.lastSeq || event.seq <= afterSeq) continue;
|
|
330
|
-
|
|
331
|
-
|
|
259
|
+
if (selected.length === limit) return { events: selected, nextSeq, hasMore: true };
|
|
260
|
+
selected.push(clone(event));
|
|
332
261
|
nextSeq = event.seq;
|
|
333
|
-
if (item) visible.push(item);
|
|
334
262
|
}
|
|
335
|
-
return { events:
|
|
263
|
+
return { events: selected, nextSeq, hasMore: false };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function sessionDescriptor(header) {
|
|
267
|
+
return {
|
|
268
|
+
id: header.id,
|
|
269
|
+
...(header.origin === undefined ? {} : { origin: clone(header.origin) }),
|
|
270
|
+
native: header.origin !== 'subagent',
|
|
271
|
+
};
|
|
336
272
|
}
|
|
337
273
|
|
|
338
274
|
function failureReason(error) {
|
|
339
|
-
if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error?.code === 'ENOENT') return 'session-log-missing';
|
|
275
|
+
if (error?.code === 'SESSION_QUERY_SESSION_NOT_FOUND' || error?.code === 'ENOENT' || error?.name === 'SessionPersistenceNotFoundError') return 'session-log-missing';
|
|
276
|
+
if (error?.name === 'SessionPersistenceCorruptionError') return 'session-log-corrupt';
|
|
277
|
+
if (error?.name === 'SessionFormatUnsupportedError') return 'session-log-format-unsupported';
|
|
340
278
|
if (['SESSION_QUERY_CORRUPT_SESSION', 'SESSION_QUERY_INVALID_SURFACE', 'execution-log-corrupt'].includes(error?.code)) return 'session-log-corrupt';
|
|
341
279
|
if (error?.code === 'SESSION_QUERY_SOURCE_CONFLICT') return 'session-reference-mismatch';
|
|
342
280
|
return 'session-log-read-failed';
|
|
343
281
|
}
|
|
344
282
|
|
|
283
|
+
/** Resolve group-owned references before any Session lookup, including historical Bots. */
|
|
284
|
+
function sessionReferences(repo, groupId, sessionId) {
|
|
285
|
+
const group = repo.getGroup(groupId);
|
|
286
|
+
if (!group || group.groupId !== groupId) fail('not-found', 'Group not found');
|
|
287
|
+
const validId = (value) => typeof value === 'string' && ID.test(value);
|
|
288
|
+
const binding = repo.sessionBindingFor?.(sessionId) || repo.sessionBindingsOf?.(groupId).find(candidate => candidate.sessionId === sessionId);
|
|
289
|
+
if (binding && binding.groupId !== groupId) fail('invalid-input', 'Session does not belong to this group');
|
|
290
|
+
const bound = binding && validId(binding.membershipId) && validId(binding.botId) &&
|
|
291
|
+
typeof binding.conversationId === 'string' && conversationIdOf(repo, binding) === binding.conversationId;
|
|
292
|
+
const current = !group.activeConversationId && repo.membersOf(groupId).some((member) =>
|
|
293
|
+
member?.groupId === groupId && validId(member.membershipId) && validId(member.botId) && member.sessionId === sessionId,
|
|
294
|
+
);
|
|
295
|
+
const references = [];
|
|
296
|
+
for (const message of repo.messagesOf(groupId)) {
|
|
297
|
+
if (message?.groupId !== groupId || !validId(message.messageId) || message.author?.type !== 'bot' ||
|
|
298
|
+
!validId(message.author.membershipId) || !validId(message.author.botId)) continue;
|
|
299
|
+
const parsed = reference(message);
|
|
300
|
+
if (parsed.execution?.sessionId === sessionId && (!group.activeConversationId ||
|
|
301
|
+
(conversationIdOf(repo, message) && (!binding || conversationIdOf(repo, message) === binding.conversationId)))) {
|
|
302
|
+
references.push(parsed.execution);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (!bound && !current && references.length === 0) fail('invalid-input', 'Session does not belong to this group');
|
|
306
|
+
return references;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Read all raw events of a verified Bot Session without creating or resuming an Agent. */
|
|
310
|
+
export async function readBotSessionHistory({ ctx, repo, groupId, sessionId, afterSeq, limit, signal } = {}) {
|
|
311
|
+
if (typeof groupId !== 'string' || !ID.test(groupId) || typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) {
|
|
312
|
+
fail('invalid-input', 'groupId and sessionId must be valid identifiers');
|
|
313
|
+
}
|
|
314
|
+
afterSeq = integerParam(afterSeq, -1, -1, 'afterSeq');
|
|
315
|
+
limit = Math.min(integerParam(limit, 100, 1, 'limit'), 300);
|
|
316
|
+
abort(signal);
|
|
317
|
+
sessionReferences(repo, groupId, sessionId);
|
|
318
|
+
const result = (availability, extra = {}) => ({
|
|
319
|
+
ok: true, groupId, sessionId, availability, events: [], nextSeq: afterSeq, hasMore: false, ...extra,
|
|
320
|
+
});
|
|
321
|
+
let observation;
|
|
322
|
+
try {
|
|
323
|
+
const query = typeof ctx?.get === 'function' ? ctx.get('sessionQuery') : ctx?.sessionQuery;
|
|
324
|
+
if (typeof query?.observeSession !== 'function') return result('unavailable', { reason: 'session-query-unavailable' });
|
|
325
|
+
observation = await query.observeSession(sessionId, { signal, projectionMode: 'none' });
|
|
326
|
+
abort(signal);
|
|
327
|
+
// Group removal or membership rotation may have raced the asynchronous read.
|
|
328
|
+
const references = sessionReferences(repo, groupId, sessionId);
|
|
329
|
+
const raw = observationEvents(observation, sessionId);
|
|
330
|
+
const last = raw.at(-1);
|
|
331
|
+
// Only a recorded end for this exact tail protects it from cold-repair removal.
|
|
332
|
+
// An earlier turn's durable end must not legitimize a later synthetic suffix.
|
|
333
|
+
const end = references.find((ref) => ref.endSeq === last?.seq && (ref.turn === null || ref.turn === last.data?.turn));
|
|
334
|
+
const events = withoutColdRepairs(raw, observation, { endSeq: end?.endSeq ?? null });
|
|
335
|
+
const span = { startSeq: 0, lastSeq: events.at(-1)?.seq ?? -1 };
|
|
336
|
+
const response = result('available', { session: sessionDescriptor(observation.header), ...page(events, span, afterSeq, limit) });
|
|
337
|
+
abort(signal);
|
|
338
|
+
return response;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (isAbort(error, signal)) throw error;
|
|
341
|
+
if (error?.code === 'invalid-input' || error?.code === 'not-found') throw error;
|
|
342
|
+
sessionReferences(repo, groupId, sessionId);
|
|
343
|
+
return result('unavailable', { reason: failureReason(error) });
|
|
344
|
+
} finally {
|
|
345
|
+
if (typeof observation?.[Symbol.dispose] === 'function') observation[Symbol.dispose]();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Read a group-owned committed log for native JSONL export. A prepared query
|
|
351
|
+
* observation is never an export source: it can contain unpersisted repairs.
|
|
352
|
+
* Flush only a Session already attached to the store; never open/resume an Agent.
|
|
353
|
+
* Returns { ok, groupId, sessionId, availability, header, events, session?, reason? }.
|
|
354
|
+
*/
|
|
355
|
+
export async function readBotSessionLog({ ctx, repo, groupId, sessionId, signal } = {}) {
|
|
356
|
+
if (typeof groupId !== 'string' || !ID.test(groupId) || typeof sessionId !== 'string' || !SESSION_ID.test(sessionId)) {
|
|
357
|
+
fail('invalid-input', 'groupId and sessionId must be valid identifiers');
|
|
358
|
+
}
|
|
359
|
+
abort(signal);
|
|
360
|
+
sessionReferences(repo, groupId, sessionId);
|
|
361
|
+
const result = (availability, extra = {}) => ({
|
|
362
|
+
ok: true, groupId, sessionId, availability, header: null, events: [], ...extra,
|
|
363
|
+
});
|
|
364
|
+
try {
|
|
365
|
+
const service = (name) => typeof ctx?.get === 'function' ? ctx.get(name) : ctx?.[name];
|
|
366
|
+
const persistence = service('sessionPersistence');
|
|
367
|
+
if (typeof persistence?.open !== 'function') return result('unavailable', { reason: 'session-persistence-unavailable' });
|
|
368
|
+
const sessions = service('sessions');
|
|
369
|
+
const live = sessions?.get?.(sessionId);
|
|
370
|
+
if (live !== undefined) {
|
|
371
|
+
if (typeof sessions.flush !== 'function') return result('unavailable', { reason: 'session-flush-unavailable' });
|
|
372
|
+
await sessions.flush(live);
|
|
373
|
+
abort(signal);
|
|
374
|
+
sessionReferences(repo, groupId, sessionId);
|
|
375
|
+
}
|
|
376
|
+
const options = signal === undefined ? {} : { signal };
|
|
377
|
+
let handle;
|
|
378
|
+
let header;
|
|
379
|
+
let events;
|
|
380
|
+
try {
|
|
381
|
+
handle = await persistence.open(sessionId, 'read', options);
|
|
382
|
+
abort(signal);
|
|
383
|
+
sessionReferences(repo, groupId, sessionId);
|
|
384
|
+
if (!handle || handle.header?.id !== sessionId || typeof handle.read !== 'function' || typeof handle.close !== 'function') {
|
|
385
|
+
fail('execution-log-corrupt', 'Session persistence identity or read handle is invalid');
|
|
386
|
+
}
|
|
387
|
+
const snapshot = await handle.read(0, undefined, options);
|
|
388
|
+
abort(signal);
|
|
389
|
+
sessionReferences(repo, groupId, sessionId);
|
|
390
|
+
header = clone(handle.header);
|
|
391
|
+
if (header?.id !== sessionId) fail('execution-log-corrupt', 'Session persistence identity changed during read');
|
|
392
|
+
events = validatedEvents(snapshot?.events, snapshot?.events?.length - 1).map((event) => clone(event));
|
|
393
|
+
} finally {
|
|
394
|
+
if (typeof handle?.close === 'function') await handle.close();
|
|
395
|
+
}
|
|
396
|
+
abort(signal);
|
|
397
|
+
sessionReferences(repo, groupId, sessionId);
|
|
398
|
+
return result('available', { header, events, session: sessionDescriptor(header) });
|
|
399
|
+
} catch (error) {
|
|
400
|
+
abort(signal);
|
|
401
|
+
if (isAbort(error, signal)) throw error;
|
|
402
|
+
if (error?.code === 'invalid-input' || error?.code === 'not-found') throw error;
|
|
403
|
+
sessionReferences(repo, groupId, sessionId);
|
|
404
|
+
return result('unavailable', { reason: failureReason(error) });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
345
408
|
/**
|
|
346
|
-
* afterSeq is exclusive; nextSeq is the last
|
|
347
|
-
*
|
|
409
|
+
* afterSeq is exclusive; nextSeq is the last returned raw seq.
|
|
410
|
+
* Bounds in execution are inclusive. hasMore describes this captured
|
|
348
411
|
* snapshot only; callers may poll again while status is pending/streaming.
|
|
349
412
|
*/
|
|
350
413
|
export async function readMessageExecution({ ctx, repo, groupId, messageId, afterSeq, limit, signal } = {}) {
|
|
@@ -359,10 +422,14 @@ export async function readMessageExecution({ ctx, repo, groupId, messageId, afte
|
|
|
359
422
|
if (parsed.legacy) return legacyResponse(repo, message, afterSeq);
|
|
360
423
|
if (parsed.reason) return response(message, afterSeq, 'unavailable', null, { reason: parsed.reason });
|
|
361
424
|
let execution = parsed.execution;
|
|
425
|
+
validateExecutionOwnership(repo, message, execution);
|
|
362
426
|
if (!execution.sessionId) return response(message, afterSeq, ACTIVE.has(message.status) ? 'preparing' : 'unavailable', execution, { reason: 'execution-session-not-recorded' });
|
|
363
427
|
let observation;
|
|
364
428
|
try {
|
|
365
|
-
|
|
429
|
+
// Cordis rejects property reads for services outside the plugin's inject
|
|
430
|
+
// list. sessionQuery is optional: resolve it through get before touching
|
|
431
|
+
// the property used by plain-context adapters.
|
|
432
|
+
const query = typeof ctx?.get === 'function' ? ctx.get('sessionQuery') : ctx?.sessionQuery;
|
|
366
433
|
if (typeof query?.observeSession !== 'function') return response(message, afterSeq, 'unavailable', execution, { reason: 'session-query-unavailable' });
|
|
367
434
|
observation = await query.observeSession(execution.sessionId, { signal, projectionMode: 'none' });
|
|
368
435
|
abort(signal);
|
|
@@ -373,15 +440,18 @@ export async function readMessageExecution({ ctx, repo, groupId, messageId, afte
|
|
|
373
440
|
return response(message, afterSeq, 'unavailable', parsed.execution, { reason: 'execution-reference-changed' });
|
|
374
441
|
}
|
|
375
442
|
execution = parsed.execution;
|
|
443
|
+
validateExecutionOwnership(repo, message, execution);
|
|
376
444
|
const events = withoutColdRepairs(observationEvents(observation, execution.sessionId), observation, execution);
|
|
445
|
+
const session = sessionDescriptor(observation.header);
|
|
377
446
|
const resolved = resolveSpan(events, execution);
|
|
378
|
-
if (!resolved.span) return response(message, afterSeq, resolved.pending && ACTIVE.has(message.status) ? 'preparing' : 'unavailable', execution, { reason: resolved.reason });
|
|
447
|
+
if (!resolved.span) return response(message, afterSeq, resolved.pending && ACTIVE.has(message.status) ? 'preparing' : 'unavailable', execution, { session, reason: resolved.reason });
|
|
379
448
|
const { span } = resolved;
|
|
380
449
|
execution = { ...execution, turn: span.turn, startSeq: span.startSeq };
|
|
381
450
|
if (execution.endSeq === null && span.end && span.end.data?.reason?.kind !== 'interrupted') execution.endSeq = span.end.seq;
|
|
382
451
|
const incomplete = !span.end || execution.endSeq === null;
|
|
383
452
|
const result = response(message, afterSeq, 'available', execution, {
|
|
384
|
-
|
|
453
|
+
session,
|
|
454
|
+
...page(events, span, afterSeq, limit),
|
|
385
455
|
...(incomplete ? { reason: 'execution-log-incomplete' } : {}),
|
|
386
456
|
});
|
|
387
457
|
abort(signal);
|
|
@@ -392,6 +462,7 @@ export async function readMessageExecution({ ctx, repo, groupId, messageId, afte
|
|
|
392
462
|
// A failed read can race terminal lifecycle updates too.
|
|
393
463
|
message = target(repo, groupId, messageId);
|
|
394
464
|
execution = reference(message).execution;
|
|
465
|
+
validateExecutionOwnership(repo, message, execution);
|
|
395
466
|
// Do not return SDK exception text: storage errors can include private paths.
|
|
396
467
|
return response(message, afterSeq, 'unavailable', execution, { reason: failureReason(error) });
|
|
397
468
|
} finally {
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/** Native activity is queried through public host services; no Agent is resumed. */
|
|
2
|
+
function service(ctx, name) {
|
|
3
|
+
// Cordis guards property access with inject declarations, even for absent
|
|
4
|
+
// optional services. get() safely reads the store and may return undefined.
|
|
5
|
+
return typeof ctx?.get === 'function' ? ctx.get(name) : ctx?.[name];
|
|
6
|
+
}
|
|
7
|
+
function adapterOf(ctx) { return service(ctx, 'botTeamNativeActivity'); }
|
|
8
|
+
|
|
9
|
+
/** Explicit adapters support isolated embedders/fixtures without fake idle states. */
|
|
10
|
+
export function nativeConversationCapability({ ctx, pool } = {}) {
|
|
11
|
+
const adapter = adapterOf(ctx);
|
|
12
|
+
if (adapter) {
|
|
13
|
+
try {
|
|
14
|
+
const capability = typeof adapter.capability === 'function' ? adapter.capability({ ctx, pool }) : adapter.capability;
|
|
15
|
+
if (capability?.supported === true && typeof adapter.collect === 'function') return { ...capability, missing: capability.missing || [] };
|
|
16
|
+
return { supported: false, missing: capability?.missing || ['adapter'], reason: capability?.reason || '原生活动适配器不可用' };
|
|
17
|
+
} catch (error) { return { supported: false, missing: ['adapter'], reason: String(error?.message || error) }; }
|
|
18
|
+
}
|
|
19
|
+
const requirements = { agents: ['get', 'list', 'roots', 'isOwnedBy'], jobs: ['list'], goals: ['get'],
|
|
20
|
+
subagents: ['listDescendants', 'drainDescendants'], sessionQuery: ['observeSession'] };
|
|
21
|
+
const missing = [];
|
|
22
|
+
for (const [name, methods] of Object.entries(requirements)) {
|
|
23
|
+
const host = service(ctx, name);
|
|
24
|
+
for (const method of methods) if (typeof host?.[method] !== 'function') missing.push(name + '.' + method);
|
|
25
|
+
}
|
|
26
|
+
return { supported: missing.length === 0, missing,
|
|
27
|
+
...(missing.length ? { reason: '宿主缺少可验证的原生活动查询能力:' + missing.join(', ') } : {}) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Installed rc.3 ApprovalService.setPolicy emits exactly this annotation via
|
|
31
|
+
// agent.inject(...): next-step, wakeup=false. It carries no work to execute.
|
|
32
|
+
// Keep every next-turn entry and every unrecognized next-step source blocking.
|
|
33
|
+
function policyAnnotation(message) {
|
|
34
|
+
return message?.role === 'user' && message.source?.kind === 'user-approval' &&
|
|
35
|
+
Object.keys(message.source).length === 1 && message.content?.length === 1 &&
|
|
36
|
+
message.content[0]?.type === 'text' &&
|
|
37
|
+
/^The approval policy changed from "(ask|never)" to "(ask|never)" [(]changed by the user[)][.]$/.test(message.content[0].text);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inboxSize(nextTurn, nextStep, annotations = false) {
|
|
41
|
+
return nextTurn.length + nextStep.filter(message => !annotations || !policyAnnotation(message)).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function persistedInbox(events, annotations = false) {
|
|
45
|
+
const lanes = { 'next-turn': [], 'next-step': [] };
|
|
46
|
+
for (const event of events) {
|
|
47
|
+
if (event.type !== 'agent/inbox/spliced') continue;
|
|
48
|
+
const data = event.data;
|
|
49
|
+
const lane = lanes[data?.target];
|
|
50
|
+
const removed = data?.removedCount ?? 0;
|
|
51
|
+
if (!lane || !Number.isSafeInteger(data.start) || data.start < 0 || data.start > lane.length ||
|
|
52
|
+
!Number.isSafeInteger(removed) || removed < 0 || data.start + removed > lane.length || !Array.isArray(data.inserted)) {
|
|
53
|
+
throw new Error('原生 inbox 历史无效');
|
|
54
|
+
}
|
|
55
|
+
lane.splice(data.start, removed, ...data.inserted);
|
|
56
|
+
}
|
|
57
|
+
return inboxSize(lanes['next-turn'], lanes['next-step'], annotations);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const missingSession = error => ['SESSION_QUERY_SESSION_NOT_FOUND', 'SESSION_QUERY_NOT_FOUND'].includes(error?.code);
|
|
61
|
+
const terminalJob = status => ['completed', 'failed', 'killed'].includes(status);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Async because the installed host's durable descendant catalog and cold
|
|
65
|
+
* Session observations are async. List.activity is deliberately ignored:
|
|
66
|
+
* external activations have no local Session and may misleadingly be inactive.
|
|
67
|
+
* Returns only blockers. Unknown observations fail closed, with a concrete cause.
|
|
68
|
+
*/
|
|
69
|
+
export async function collectNativeConversationBlockers({ ctx, repo, pool, groupId, interactions } = {}) {
|
|
70
|
+
const adapter = adapterOf(ctx);
|
|
71
|
+
const capability = nativeConversationCapability({ ctx, pool });
|
|
72
|
+
const blockers = [];
|
|
73
|
+
const add = (kind, message, extra = {}) => blockers.push({ code: 'conversation-busy', kind, groupId, message, ...extra });
|
|
74
|
+
if (!capability.supported) {
|
|
75
|
+
add('native-unsupported', capability.reason, { missing: capability.missing });
|
|
76
|
+
return blockers;
|
|
77
|
+
}
|
|
78
|
+
if (adapter) {
|
|
79
|
+
try {
|
|
80
|
+
const result = await adapter.collect({ ctx, repo, pool, groupId, interactions });
|
|
81
|
+
if (!Array.isArray(result)) throw new Error('原生活动适配器未返回阻塞列表');
|
|
82
|
+
return result;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
add('native-unknown', '无法确认原生活动:' + String(error?.message || error));
|
|
85
|
+
return blockers;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const agents = service(ctx, 'agents');
|
|
89
|
+
const jobs = service(ctx, 'jobs');
|
|
90
|
+
const goals = service(ctx, 'goals');
|
|
91
|
+
const subagents = service(ctx, 'subagents');
|
|
92
|
+
const query = service(ctx, 'sessionQuery');
|
|
93
|
+
const roots = new Map();
|
|
94
|
+
const root = (sessionId, member) => { if (sessionId && !roots.has(sessionId)) roots.set(sessionId, member); };
|
|
95
|
+
for (const member of repo.members?.values() || []) if (member.groupId === groupId) {
|
|
96
|
+
root(member.sessionId, member);
|
|
97
|
+
for (const id of member.previousSessionIds || []) root(id, member);
|
|
98
|
+
}
|
|
99
|
+
for (const binding of repo.sessionBindings?.values() || []) if (binding.groupId === groupId) root(binding.sessionId, binding);
|
|
100
|
+
for (const [agent, owned] of pool?.owned || []) {
|
|
101
|
+
const member = repo.getMember?.(owned.membershipId);
|
|
102
|
+
if (member?.groupId === groupId) root(agent.id, member);
|
|
103
|
+
}
|
|
104
|
+
const ids = new Set(roots.keys());
|
|
105
|
+
const observed = new Map();
|
|
106
|
+
const checkLive = (agent, member, isRoot = false) => {
|
|
107
|
+
const details = { sessionId: agent.id, membershipId: member?.membershipId };
|
|
108
|
+
if (isRoot && (!agents.roots().includes(agent) || !pool?.owns?.(agent))) {
|
|
109
|
+
add('native-borrowed', '原生会话由其他所有者持有,无法关闭独立输入', details);
|
|
110
|
+
}
|
|
111
|
+
if (agent.status !== 'idle') add('native-agent', agent.status === 'running' ? '原生 Agent 仍在运行' : '无法确认原生 Agent 已空闲', details);
|
|
112
|
+
try {
|
|
113
|
+
const inbox = agent.inbox;
|
|
114
|
+
if (!Array.isArray(inbox?.nextTurn) || !Array.isArray(inbox?.nextStep)) throw new Error('缺少 inbox 查询');
|
|
115
|
+
if (inboxSize(inbox.nextTurn, inbox.nextStep, isRoot && pool?.owns?.(agent))) add('native-inbox', '原生 inbox 仍有待执行输入', details);
|
|
116
|
+
} catch (error) { add('native-unknown', '无法确认原生 inbox:' + String(error?.message || error), details); }
|
|
117
|
+
try {
|
|
118
|
+
const goal = goals.get(agent);
|
|
119
|
+
if (goal?.phase === 'active' && goal.activation === 'armed') add('native-goal', '原生目标仍获授权自动续跑', { ...details, goalId: goal.id });
|
|
120
|
+
else if (goal?.phase === 'active' && goal.activation !== 'disarmed') add('native-unknown', '无法确认原生目标的续跑授权', details);
|
|
121
|
+
} catch (error) { add('native-unknown', '无法读取原生目标:' + String(error?.message || error), details); }
|
|
122
|
+
};
|
|
123
|
+
for (const [sessionId, member] of roots) {
|
|
124
|
+
const live = agents.get(sessionId);
|
|
125
|
+
let absent = false;
|
|
126
|
+
observed.set(sessionId, live);
|
|
127
|
+
if (live) checkLive(live, member, true);
|
|
128
|
+
else {
|
|
129
|
+
let observation;
|
|
130
|
+
try {
|
|
131
|
+
observation = await query.observeSession(sessionId, { projectionMode: 'none' });
|
|
132
|
+
if (observation.header?.id !== sessionId || !Array.isArray(observation.events)) throw new Error('无效的原生 Session 观察');
|
|
133
|
+
if (persistedInbox(observation.events, true)) add('native-inbox', '历史原生 inbox 仍有未执行输入', { sessionId, membershipId: member?.membershipId });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (missingSession(error)) absent = true; // Allocated ids need not have a log yet.
|
|
136
|
+
else {
|
|
137
|
+
add('native-unknown', '无法读取原生会话活动:' + String(error?.message || error), { sessionId });
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
} finally { observation?.[Symbol.dispose]?.(); }
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const descendants = await subagents.listDescendants(sessionId);
|
|
144
|
+
if (!Array.isArray(descendants)) throw new Error('无效的子代理列表');
|
|
145
|
+
for (const child of descendants) {
|
|
146
|
+
ids.add(child.id);
|
|
147
|
+
const details = { sessionId: child.id, parentSessionId: child.parentId, rootSessionId: sessionId, membershipId: member?.membershipId };
|
|
148
|
+
if (child.kind !== 'child' || child.mode === 'external' || child.mode === 'unknown') {
|
|
149
|
+
// Exact owned teardown has awaited the manager's complete forest.
|
|
150
|
+
if (!live && pool?.drainedSessions?.has(sessionId)) continue;
|
|
151
|
+
add('native-descendant-unknown', '无法证明子代理已停止:' + (child.reason || child.mode || '未知类型'), details);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const agent = agents.get(child.id);
|
|
155
|
+
observed.set(child.id, agent);
|
|
156
|
+
if (agent) checkLive(agent, member);
|
|
157
|
+
else if (child.activity === 'running') add('native-descendant-unknown', '子代理 Session 仍驻留,但无法读取其 Agent 活动', details);
|
|
158
|
+
else {
|
|
159
|
+
let observation;
|
|
160
|
+
try {
|
|
161
|
+
observation = await query.observeSession(child.id, { projectionMode: 'none' });
|
|
162
|
+
if (observation.header?.id !== child.id || !Array.isArray(observation.events)) throw new Error('无效的子代理 Session 观察');
|
|
163
|
+
if (persistedInbox(observation.events)) add('native-inbox', '子代理 inbox 仍有未执行输入', details);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!missingSession(error)) add('native-descendant-unknown', '无法读取子代理 inbox:' + String(error?.message || error), details);
|
|
166
|
+
} finally { observation?.[Symbol.dispose]?.(); }
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (!absent || !missingSession(error)) add('native-unknown', '无法读取原生子代理:' + String(error?.message || error), { sessionId });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// Registry ownership also covers live children before their durable catalog
|
|
174
|
+
// publication; iterate to include descendants of every exact observed owner.
|
|
175
|
+
let grew;
|
|
176
|
+
do {
|
|
177
|
+
grew = false;
|
|
178
|
+
for (const agent of agents.list()) {
|
|
179
|
+
if (ids.has(agent.id)) continue;
|
|
180
|
+
const parent = [...ids].map(id => agents.get(id)).find(owner => owner && agents.isOwnedBy(agent.id, owner));
|
|
181
|
+
// A manager-owned child can survive a missing root writer/catalog. Its
|
|
182
|
+
// immutable parentSession still ties that live activity to this group.
|
|
183
|
+
const parentSession = agent.session?.header?.parentSession;
|
|
184
|
+
const parentId = typeof parentSession === 'string' ? parentSession : parentSession?.id;
|
|
185
|
+
if (!parent && !ids.has(parentId)) continue;
|
|
186
|
+
ids.add(agent.id);
|
|
187
|
+
observed.set(agent.id, agent);
|
|
188
|
+
checkLive(agent);
|
|
189
|
+
grew = true;
|
|
190
|
+
}
|
|
191
|
+
} while (grew);
|
|
192
|
+
for (const sessionId of ids) {
|
|
193
|
+
try {
|
|
194
|
+
const listed = jobs.list(sessionId);
|
|
195
|
+
if (!Array.isArray(listed)) throw new Error('无效的后台作业列表');
|
|
196
|
+
for (const job of listed) if (job.owner === sessionId && !terminalJob(job.status)) add('native-job', '原生后台作业尚未结束', { sessionId, jobId: job.id, status: job.status });
|
|
197
|
+
} catch (error) { add('native-unknown', '无法读取原生后台作业:' + String(error?.message || error), { sessionId }); }
|
|
198
|
+
}
|
|
199
|
+
// status intentionally reports idle during native maintenance. whenIdle is
|
|
200
|
+
// public and includes its activityDone promise; a one-turn probe distinguishes
|
|
201
|
+
// settled idle from ongoing maintenance without waiting for work to finish.
|
|
202
|
+
for (const [sessionId, agent] of observed) if (agent?.status === 'idle') {
|
|
203
|
+
try {
|
|
204
|
+
if (typeof agent.whenIdle !== 'function') throw new Error('缺少 whenIdle 查询');
|
|
205
|
+
let immediate;
|
|
206
|
+
const idle = await Promise.race([
|
|
207
|
+
Promise.resolve(agent.whenIdle()).then(() => true),
|
|
208
|
+
new Promise(resolve => { immediate = setImmediate(() => resolve(false)); }),
|
|
209
|
+
]).finally(() => clearImmediate(immediate));
|
|
210
|
+
if (!idle) add('native-agent', '原生 Agent 仍有维护或待收尾活动', { sessionId });
|
|
211
|
+
} catch (error) { add('native-unknown', '无法确认原生 Agent 已完成活动:' + String(error?.message || error), { sessionId }); }
|
|
212
|
+
}
|
|
213
|
+
for (const [sessionId, initial] of observed) {
|
|
214
|
+
const current = agents.get(sessionId);
|
|
215
|
+
if (current !== initial) add('native-changed', '检查期间原生 Agent 已变更,请重试', { sessionId });
|
|
216
|
+
else if (current) checkLive(current, roots.get(sessionId), roots.has(sessionId));
|
|
217
|
+
}
|
|
218
|
+
if (pool?.isPreparingGroup?.(groupId)) add('native-preparing', '原生 Agent 或输入正在准备');
|
|
219
|
+
const pendingInteractions = new Set([...(interactions?.entries?.values() || []), ...(interactions?.responding || [])]);
|
|
220
|
+
for (const entry of pendingInteractions) if (entry.view?.groupId === groupId && (entry.busy || !['done', 'settled'].includes(entry.view.state))) {
|
|
221
|
+
add('native-interaction', entry.busy ? '原生交互回答正在提交' : '原生交互尚未完成', { sessionId: entry.view.sessionId, interactionId: entry.view.interactionId });
|
|
222
|
+
}
|
|
223
|
+
const unique = new Map(blockers.map(value => [[value.kind, value.sessionId, value.jobId, value.interactionId, value.goalId].join(':'), value]));
|
|
224
|
+
return [...unique.values()];
|
|
225
|
+
}
|