@jskit-ai/assistant-core 0.1.208 → 0.1.211
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/examples/conversation/package.json +5 -5
- package/package.json +5 -5
- package/src/client/conversation/messageDelivery.js +7 -1
- package/src/server/conversation/codexHistoryAdapter.js +55 -2
- package/src/server/conversation/codexMessageCommands.js +2 -0
- package/src/server/conversation/codexMessageDelivery.js +7 -2
- package/src/server/conversation/codexNotificationObserver.js +15 -2
- package/src/server/conversation/codexProvider.js +64 -7
- package/src/server/conversation/codexTurn.js +16 -2
- package/src/server/conversation/codexTurnRecovery.js +104 -18
- package/src/server/conversation/continuity.js +3 -0
- package/src/server/conversation/openCodeRuntime.js +4 -0
- package/src/server/conversation/providers/codexDriver.js +106 -36
- package/src/server/conversation/providers/opencodeDriver.js +94 -37
- package/src/server/conversation/runtime.js +67 -7
- package/src/server/conversation/tools.js +5 -2
- package/src/server/conversation/transcript.js +2 -2
- package/src/server/hosts/openCodeProcess.js +1 -0
- package/test/claudeConversationSession.test.js +49 -0
- package/test/codexConversationSession.test.js +346 -0
- package/test/codexHistoryAdapter.test.js +103 -0
- package/test/codexTurn.test.js +542 -0
- package/test/conversationSession.test.js +19 -0
- package/test/conversationTools.test.js +298 -0
- package/test/messageDelivery.test.js +18 -0
- package/test/openCodeRuntime.test.js +29 -0
- package/test/opencodeConversationSession.test.js +354 -0
|
@@ -14,6 +14,43 @@ import { validateConversationConfiguration, validateConnectionModel } from "../c
|
|
|
14
14
|
const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
15
15
|
const nativeMessageId = id => `msg_jskit_${hash(id).slice(0, 40)}`;
|
|
16
16
|
|
|
17
|
+
// Original standalone native-use validation and authored admission, shared by
|
|
18
|
+
// explicitly supplied bound tools without transferring native process ownership.
|
|
19
|
+
async function executeOpenCodeApplicationTool({ active, turn, target, sessionId, assertCurrent }, input, signal) {
|
|
20
|
+
if (!active?.tools || input.sessionId !== sessionId ||
|
|
21
|
+
[input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
|
|
22
|
+
throw new Error("This OpenCode tool call does not belong to the active conversation.");
|
|
23
|
+
}
|
|
24
|
+
if (!turn?.abortController) {
|
|
25
|
+
throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
|
|
26
|
+
}
|
|
27
|
+
assertCurrent?.();
|
|
28
|
+
const observed = AbortSignal.any([signal, turn.abortController.signal]);
|
|
29
|
+
await turn.admission?.promise;
|
|
30
|
+
observed.throwIfAborted();
|
|
31
|
+
const messages = await target.server.client.messages(sessionId, { limit: 100 }, { signal: observed });
|
|
32
|
+
let message;
|
|
33
|
+
let owner;
|
|
34
|
+
for (const [nativeId, authored] of active.messageIds) {
|
|
35
|
+
message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
|
|
36
|
+
if (message) { owner = authored; break; }
|
|
37
|
+
}
|
|
38
|
+
const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
|
|
39
|
+
if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
|
|
40
|
+
throw new Error("OpenCode's application call does not match an admitted native tool use.");
|
|
41
|
+
}
|
|
42
|
+
if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
|
|
43
|
+
await owner.committed;
|
|
44
|
+
observed.throwIfAborted();
|
|
45
|
+
assertCurrent?.(true, owner);
|
|
46
|
+
try {
|
|
47
|
+
return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!assertCurrent || assertCurrent(false, owner)) turn.abortController.abort(error);
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
17
54
|
/** Uses the application's authorized API connection; native state stays server-side. */
|
|
18
55
|
export function createOpenCodeConversationDriver({ connections, host = {}, limits = {} } = {}) {
|
|
19
56
|
if (!host.conversation && typeof connections?.resolve !== "function") throw new TypeError("OpenCode conversations require an authorized AI connection resolver.");
|
|
@@ -167,10 +204,12 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
167
204
|
let current;
|
|
168
205
|
let disposed = false;
|
|
169
206
|
let cleanupFailure;
|
|
207
|
+
let toolBridge;
|
|
170
208
|
async function interrupt(context) {
|
|
171
209
|
try {
|
|
172
210
|
const acquired = await supplied.acquire(context || current?.context);
|
|
173
211
|
const value = await supplied.owner.interruptPreparedTurn(await supplied.preparation.interruption(acquired.options));
|
|
212
|
+
if (toolBridge) { await toolBridge.close(); toolBridge = null; }
|
|
174
213
|
if (cleanupFailure) {
|
|
175
214
|
await reportFailure(cleanupFailure, { recovered: true });
|
|
176
215
|
cleanupFailure = null;
|
|
@@ -184,16 +223,61 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
184
223
|
throw error;
|
|
185
224
|
}
|
|
186
225
|
}
|
|
187
|
-
async function send(
|
|
226
|
+
async function send(command) {
|
|
227
|
+
const { input, context, signal, beforeDispatch, accept } = command;
|
|
188
228
|
signal.throwIfAborted();
|
|
189
229
|
const acquired = await supplied.acquire(context);
|
|
190
|
-
const
|
|
191
|
-
|
|
230
|
+
const active = current;
|
|
231
|
+
if (active?.tools) signal.throwIfAborted();
|
|
232
|
+
if (active?.tools && !toolBridge) {
|
|
233
|
+
toolBridge = await createOpenCodeToolBridge({ schemas: active.tools.schemas,
|
|
234
|
+
maxArgumentBytes: active.tools.maximumArgumentBytes, execute(input, signal) {
|
|
235
|
+
const turn = supplied.owner.turns.get(acquired.key);
|
|
236
|
+
const target = supplied.owner.processes.get(acquired.key);
|
|
237
|
+
return executeOpenCodeApplicationTool({ active: current, turn, target,
|
|
238
|
+
sessionId: target?.upstreamSessionId,
|
|
239
|
+
assertCurrent(throwFailure = true, authored) {
|
|
240
|
+
const owned = !disposed && current === active && active.turn === turn && turn?.active &&
|
|
241
|
+
(!authored || active.messageIds.get(turn.inputMessageId) === authored) &&
|
|
242
|
+
supplied.owner.turns.get(acquired.key) === turn && supplied.owner.processes.get(acquired.key) === target;
|
|
243
|
+
if (!owned && throwFailure) throw new Error("This OpenCode tool call belongs to a retired native turn.");
|
|
244
|
+
return owned;
|
|
245
|
+
}
|
|
246
|
+
}, input, signal);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
const options = active?.tools ? { ...acquired.options, applicationTools: toolBridge.configuration } : acquired.options;
|
|
251
|
+
let prepared;
|
|
252
|
+
const dispatch = active?.tools ? async identity => {
|
|
253
|
+
await beforeDispatch(identity);
|
|
254
|
+
signal.throwIfAborted();
|
|
255
|
+
active.turn = supplied.owner.turns.get(acquired.key);
|
|
256
|
+
active.messageIds.set(prepared.input.id, { messageId: input.messageId, committed: command.deliveryCommitted });
|
|
257
|
+
} : beforeDispatch;
|
|
258
|
+
prepared = await supplied.preparation.message({ ...input.nativeMessage, onPromptSending: dispatch }, options);
|
|
259
|
+
if (active?.tools && prepared.application?.monitor) {
|
|
260
|
+
const monitor = prepared.application.monitor;
|
|
261
|
+
const create = monitor.create;
|
|
262
|
+
prepared.application = { ...prepared.application, monitor: { ...monitor,
|
|
263
|
+
create(...args) {
|
|
264
|
+
const projection = create(...args);
|
|
265
|
+
const finalResponse = projection.finalResponse;
|
|
266
|
+
return { ...projection, finalResponse: { ...finalResponse,
|
|
267
|
+
async beforeRecovery(completion) {
|
|
268
|
+
active.messageIds.set(finalResponse.recoveryMessageId, active.messageIds.get(completion.inputMessageId));
|
|
269
|
+
await finalResponse.beforeRecovery?.(completion);
|
|
270
|
+
}
|
|
271
|
+
} };
|
|
272
|
+
}
|
|
273
|
+
} };
|
|
274
|
+
}
|
|
275
|
+
const value = await supplied.owner.sendPreparedMessage(prepared, options);
|
|
192
276
|
const delivered = { value, completion: value.conversationTurn ? supplied.owner.monitors.get(acquired.key) : null };
|
|
193
277
|
if (value.delivered === true && value.conversationTurn) {
|
|
194
278
|
if (current) current.delivery = delivered;
|
|
195
279
|
await accept({ nativeResult: { value }, conversationTurn: value.conversationTurn, nativeTurnId: value.turn?.id });
|
|
196
|
-
}
|
|
280
|
+
} else if (active?.tools) active.messageIds.delete(prepared.input.id);
|
|
197
281
|
return delivered;
|
|
198
282
|
}
|
|
199
283
|
return Object.freeze({
|
|
@@ -205,7 +289,8 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
205
289
|
},
|
|
206
290
|
async run(command) {
|
|
207
291
|
if (disposed || current) throw new Error("This OpenCode conversation is closed or already working.");
|
|
208
|
-
const active = { context: command.context, failure: Promise.withResolvers()
|
|
292
|
+
const active = { context: command.context, failure: Promise.withResolvers(),
|
|
293
|
+
...(command.tools ? { tools: command.tools, messageIds: new Map() } : {}) };
|
|
209
294
|
let failure;
|
|
210
295
|
active.failure.promise.catch(error => { failure = error; });
|
|
211
296
|
current = active;
|
|
@@ -248,6 +333,7 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
248
333
|
} finally {
|
|
249
334
|
command.signal.removeEventListener("abort", abort);
|
|
250
335
|
await cancellation?.catch(() => {});
|
|
336
|
+
if (toolBridge) { await toolBridge.close(); toolBridge = null; }
|
|
251
337
|
if (current === active) current = null;
|
|
252
338
|
}
|
|
253
339
|
},
|
|
@@ -260,6 +346,7 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
260
346
|
try {
|
|
261
347
|
const result = await disposeNative({ native: supplied, options });
|
|
262
348
|
if (result?.ok === false) throw Object.assign(new Error(result.error || "OpenCode cleanup could not be confirmed."), result);
|
|
349
|
+
if (toolBridge) { await toolBridge.close(); toolBridge = null; }
|
|
263
350
|
disposed = true;
|
|
264
351
|
return result;
|
|
265
352
|
} catch (error) {
|
|
@@ -388,38 +475,8 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
|
|
|
388
475
|
toolBridge = await createOpenCodeToolBridge({
|
|
389
476
|
schemas: current.tools.schemas, maxArgumentBytes: current.tools.maximumArgumentBytes,
|
|
390
477
|
async execute(input, signal) {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
[input.id, input.name, input.messageId].some(value => typeof value !== "string" || !value || value.length > 256)) {
|
|
394
|
-
throw new Error("This OpenCode tool call does not belong to the active conversation.");
|
|
395
|
-
}
|
|
396
|
-
const turn = facilities.runtime.turns.get(binding.directory);
|
|
397
|
-
if (!turn?.abortController) {
|
|
398
|
-
throw new Error("This OpenCode tool call does not belong to an admitted conversation.");
|
|
399
|
-
}
|
|
400
|
-
const observed = AbortSignal.any([signal, turn.abortController.signal]);
|
|
401
|
-
await turn.admission?.promise;
|
|
402
|
-
observed.throwIfAborted();
|
|
403
|
-
const messages = await native.server.client.messages(binding.sessionId, { limit: 100 }, { signal: observed });
|
|
404
|
-
let message;
|
|
405
|
-
let owner;
|
|
406
|
-
for (const [nativeId, authored] of active.messageIds) {
|
|
407
|
-
message = openCodeRowsForInput(messages, nativeId).find(message => message.id === input.messageId && message.type === "assistant");
|
|
408
|
-
if (message) { owner = authored; break; }
|
|
409
|
-
}
|
|
410
|
-
const tool = message?.content.find(part => part.type === "tool" && part.callID === input.id);
|
|
411
|
-
if (!tool || tool.tool !== input.name || !isDeepStrictEqual(tool.state?.input, input.input)) {
|
|
412
|
-
throw new Error("OpenCode's application call does not match an admitted native tool use.");
|
|
413
|
-
}
|
|
414
|
-
if (!owner.committed) throw new Error("This OpenCode tool call has no durable authored admission.");
|
|
415
|
-
await owner.committed;
|
|
416
|
-
observed.throwIfAborted();
|
|
417
|
-
try {
|
|
418
|
-
return await active.tools.execute({ id: input.id, name: input.name, arguments: JSON.stringify(input.input) }, { signal: observed, messageId: owner.messageId });
|
|
419
|
-
} catch (error) {
|
|
420
|
-
turn.abortController.abort(error);
|
|
421
|
-
throw error;
|
|
422
|
-
}
|
|
478
|
+
return executeOpenCodeApplicationTool({ active: current,
|
|
479
|
+
turn: facilities.runtime.turns.get(binding.directory), target: native, sessionId: binding.sessionId }, input, signal);
|
|
423
480
|
}
|
|
424
481
|
});
|
|
425
482
|
}
|
|
@@ -50,6 +50,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
50
50
|
if (!Number.isSafeInteger(maximumToolCalls) || maximumToolCalls < 1) throw new TypeError("Invalid application tool-call limit.");
|
|
51
51
|
const maximumInput = limits.maxInputCharacters ?? 32_000;
|
|
52
52
|
if (!Number.isSafeInteger(maximumInput) || maximumInput < 1) throw new TypeError("Invalid conversation input limit.");
|
|
53
|
+
const maximumFinalReply = limits.maxFinalReplyCharacters ?? Infinity;
|
|
54
|
+
if (limits.maxFinalReplyCharacters !== undefined && (!Number.isSafeInteger(maximumFinalReply) || maximumFinalReply < 1)) {
|
|
55
|
+
throw new TypeError("Invalid conversation final reply limit.");
|
|
56
|
+
}
|
|
53
57
|
const maximumContinuity = limits.maxContinuityCharacters ?? 128_000;
|
|
54
58
|
if (!Number.isSafeInteger(maximumContinuity) || maximumContinuity < 1) throw new TypeError("Invalid conversation continuity limit.");
|
|
55
59
|
const maximumAttachmentBytes = limits.maxAttachmentBytes ?? 8 * 1024 * 1024;
|
|
@@ -371,6 +375,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
371
375
|
|
|
372
376
|
async function initialize(entry, configuration, requestedEngine) {
|
|
373
377
|
if (entry.conversation) {
|
|
378
|
+
entry.applicationTools = boundApplicationTools(entry.conversation);
|
|
374
379
|
entry.engine = entry.conversation.engine;
|
|
375
380
|
if (requestedEngine && requestedEngine !== entry.engine) throw failure("The selected native engine changed.", "conversation_engine_mismatch");
|
|
376
381
|
entry.driver = createDriver(entry.engine, entry.host);
|
|
@@ -434,6 +439,15 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
434
439
|
entry.provider = await openProvider(entry, entry.driver, binding, entry.segmentId);
|
|
435
440
|
}
|
|
436
441
|
|
|
442
|
+
function boundApplicationTools(conversation) {
|
|
443
|
+
const facilities = conversation.applicationTools;
|
|
444
|
+
if (facilities === undefined) return null;
|
|
445
|
+
if (!catalog || conversation.commands || conversation.native?.scoped || typeof facilities?.prepareContext !== "function") {
|
|
446
|
+
throw new TypeError("Bound application tools require a catalogue, an admitted conversation and a server-owned context mapper.");
|
|
447
|
+
}
|
|
448
|
+
return { storage: createReentrantConversationStorage(facilities.storage), prepareContext: facilities.prepareContext };
|
|
449
|
+
}
|
|
450
|
+
|
|
437
451
|
async function perform(entry, active, configuration) {
|
|
438
452
|
const { driver, engine, segmentId } = entry;
|
|
439
453
|
const initial = active.request;
|
|
@@ -445,15 +459,39 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
445
459
|
const rejectionSaves = new Map();
|
|
446
460
|
let toolWork = Promise.resolve();
|
|
447
461
|
let toolFailure;
|
|
462
|
+
const applicationTools = entry.applicationTools;
|
|
463
|
+
const toolStorage = applicationTools?.storage || storage;
|
|
464
|
+
|
|
465
|
+
async function saveTools(transaction, request, calls) {
|
|
466
|
+
if (entry.conversation) {
|
|
467
|
+
const saved = await transaction.readTurn(request.turnId);
|
|
468
|
+
if (submittedMessage(saved || {})?.messageId !== request.input.messageId) {
|
|
469
|
+
throw new Error("The application tool receipt does not belong to this authored request.");
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
await transaction.updateTurnMetadata(request.turnId, { applicationTools: calls });
|
|
473
|
+
}
|
|
448
474
|
|
|
449
475
|
function prepareTools(request) {
|
|
450
|
-
if (!catalog || entry.conversation) return;
|
|
476
|
+
if (!catalog || entry.conversation && !applicationTools) return;
|
|
451
477
|
request.tools = createConversationTools({ catalog, context: request.context, signal: active.controller.signal,
|
|
478
|
+
...(applicationTools ? { prepareContext() {
|
|
479
|
+
if (!request.accepted) throw new Error("The application tool does not belong to an admitted message.");
|
|
480
|
+
return applicationTools.prepareContext(request.context, Object.freeze({ conversationId: entry.id,
|
|
481
|
+
turnId: request.turnId, messageId: request.input.messageId, nativeTurnId: request.nativeTurnId,
|
|
482
|
+
nativeThreadId: request.nativeThreadId, origin: request.input.origin,
|
|
483
|
+
assertCurrent() {
|
|
484
|
+
active.controller.signal.throwIfAborted();
|
|
485
|
+
if (entry.active !== active || current !== request || !request.accepted || request.finished) {
|
|
486
|
+
throw failure("This application tool's admitted message is no longer current.", "conversation_tool_request_retired");
|
|
487
|
+
}
|
|
488
|
+
} }));
|
|
489
|
+
} } : {}),
|
|
452
490
|
maximumCalls: maximumToolCalls, discoveryOnly: driver.toolDiscovery === true,
|
|
453
491
|
authorize: () => access(request.context, entry.id, "tool", entry),
|
|
454
492
|
save: calls => {
|
|
455
493
|
if (!request.accepted) throw new Error("The engine requested an application tool before admitting the message.");
|
|
456
|
-
return
|
|
494
|
+
return toolStorage.write(entry.id, transaction => saveTools(transaction, request, calls));
|
|
457
495
|
},
|
|
458
496
|
emit: event => emit(entry, { ...event, turnId: request.turnId })
|
|
459
497
|
});
|
|
@@ -612,6 +650,10 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
612
650
|
await setPhase(entry, "working", request.turnId);
|
|
613
651
|
if (request.sealed) return;
|
|
614
652
|
const messageId = `${request.turnId}:${message.id}`;
|
|
653
|
+
if (message.role === "assistant" && message.complete && message.text.length > maximumFinalReply) {
|
|
654
|
+
if (request.answer?.messageId === messageId) request.answer = null;
|
|
655
|
+
throw new Error("The assistant exceeded the configured final reply limit.");
|
|
656
|
+
}
|
|
615
657
|
const previous = request.answer?.messageId === messageId ? request.answer : request.progress.get(messageId);
|
|
616
658
|
const outputOwner = message.outputId && messageOwners.get(message.outputId) || request;
|
|
617
659
|
const outputId = previous?.outputId || (message.outputId ? `${outputOwner.turnId}:${message.outputId}` : "");
|
|
@@ -715,6 +757,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
715
757
|
async accept(native) {
|
|
716
758
|
if (entry.conversation) nativeResult = native.nativeResult;
|
|
717
759
|
if (native?.nativeTurnId) active.nativeTurnId = native.nativeTurnId;
|
|
760
|
+
request.nativeThreadId = threadId;
|
|
718
761
|
await admit(request, undefined, { nativeDelivery: true, conversationTurn: native?.conversationTurn });
|
|
719
762
|
admitted.resolve(entry.conversation ? nativeResult.value
|
|
720
763
|
: { ok: true, delivered: true, messageId: request.input.messageId, threadId });
|
|
@@ -970,8 +1013,13 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
970
1013
|
: status === "cancelled" ? "Work stopped." : problem?.message || active.steerError || initial.recovered?.error || "");
|
|
971
1014
|
entry.saveProgress = async () => {
|
|
972
1015
|
if (entry.conversation) {
|
|
973
|
-
|
|
974
|
-
|
|
1016
|
+
if (applicationTools) await toolStorage.write(entry.id, async transaction => {
|
|
1017
|
+
for (const request of active.requests.values()) {
|
|
1018
|
+
if (request.accepted && request.tools?.records().length) await saveTools(transaction, request, request.tools.records());
|
|
1019
|
+
}
|
|
1020
|
+
});
|
|
1021
|
+
// Only opted-in application receipts are saved above. Native output,
|
|
1022
|
+
// checkpoints and status remain with the original run owner and sender.
|
|
975
1023
|
entry.error = error;
|
|
976
1024
|
entry.storageFailure = null;
|
|
977
1025
|
entry.saveProgress = null;
|
|
@@ -1342,6 +1390,13 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1342
1390
|
}
|
|
1343
1391
|
const metadata = { runtime: await readState(entry) };
|
|
1344
1392
|
if (metadata.runtime.replacement) throw failure("Finish the native replacement before recovering output.", "conversation_replacement_pending");
|
|
1393
|
+
const savedRequests = [...metadata.runtime.predecessors, metadata.runtime].filter(segment => segment.request?.messageId === messageId);
|
|
1394
|
+
if (!existing && savedRequests.length && savedRequests.every(segment =>
|
|
1395
|
+
["codex", "claude", "opencode"].includes(segment.engine) && segment.request.attempted === false && !segment.request.inspectionOnly &&
|
|
1396
|
+
isDeepStrictEqual(segment.request, savedRequests[0].request))) {
|
|
1397
|
+
await access(context, entry.id, "inspectDelivery", entry);
|
|
1398
|
+
return { status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." };
|
|
1399
|
+
}
|
|
1345
1400
|
const request = existing ? submittedMessage(existing) : metadata.runtime.request;
|
|
1346
1401
|
if (!request || request.messageId !== messageId || !entry.provider.inspectAdmission) return receipt || { status: "unknown", messageId };
|
|
1347
1402
|
const pending = metadata.runtime.request?.messageId === messageId ? metadata.runtime.request : null;
|
|
@@ -1415,13 +1470,17 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1415
1470
|
}
|
|
1416
1471
|
const nativeReplies = (native.messages || []).filter(message => message.role === "assistant");
|
|
1417
1472
|
const outputId = nativeReplies.length === 1 && nativeReplies[0].outputId;
|
|
1418
|
-
|
|
1473
|
+
const replyText = replies.join("\n\n");
|
|
1474
|
+
const oversizedReply = replyText.length > maximumFinalReply;
|
|
1475
|
+
if (replies.length && !oversizedReply) await transaction.replaceAssistant(turn.turnId, { messageId: turn.assistant?.messageId || `${turn.turnId}:assistant`,
|
|
1419
1476
|
...(!turn.assistant?.outputId && outputId ? { outputId: `${turn.turnId}:${outputId}` } : {}),
|
|
1420
|
-
role: "assistant", text:
|
|
1477
|
+
role: "assistant", text: replyText, at: submittedMessage(turn).at });
|
|
1421
1478
|
const unknownTool = turn.metadata.applicationTools?.some(call => !call.result || call.status === "unknown");
|
|
1422
|
-
const
|
|
1479
|
+
const retainedFailure = ["cancelled", "failed"].includes(runtime.status);
|
|
1480
|
+
const status = retainedFailure ? runtime.status : oversizedReply ? "failed" : unknownTool ? "interrupted" : native.status || "interrupted";
|
|
1423
1481
|
const { error: previousError, ...saved } = runtime;
|
|
1424
1482
|
const error = unknownTool ? "Native output was recovered, but an application tool has no verified result. Inspect its target before requesting another execution."
|
|
1483
|
+
: oversizedReply ? retainedFailure && (previousError || native.error) || "The assistant exceeded the configured final reply limit."
|
|
1425
1484
|
: status === "complete" ? "" : previousError || native.error || "Delivery was confirmed from native history. Completion was not confirmed; this request was not replayed.";
|
|
1426
1485
|
await transaction.updateTurnMetadata(turn.turnId, { runtime: { ...saved, status,
|
|
1427
1486
|
...(request.goal || goalRequest ? { goalMessageId: messageId } : {}),
|
|
@@ -1531,6 +1590,7 @@ export function createConversationRuntime({ engine: defaultEngine = "api", defau
|
|
|
1531
1590
|
}
|
|
1532
1591
|
const next = await entry.host.conversation({ id: entry.id, context });
|
|
1533
1592
|
entry.conversation = next;
|
|
1593
|
+
entry.applicationTools = boundApplicationTools(next);
|
|
1534
1594
|
entry.engine = next.engine;
|
|
1535
1595
|
entry.driver = createDriver(entry.engine, entry.host);
|
|
1536
1596
|
const current = await readState(entry);
|
|
@@ -3,7 +3,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
3
3
|
const unknownOutcome = "The application operation did not return a verified result. Inspect its target before requesting another execution.";
|
|
4
4
|
|
|
5
5
|
/** One turn's application calls. Native/API adapters only transport these calls. */
|
|
6
|
-
export function createConversationTools({ catalog, context, signal: turnSignal, previousCalls = [], authorize, save, emit,
|
|
6
|
+
export function createConversationTools({ catalog, context, prepareContext, signal: turnSignal, previousCalls = [], authorize, save, emit,
|
|
7
7
|
maximumCalls = 32, discoveryOnly = false, transient = false, propagateErrors = false }) {
|
|
8
8
|
if (transient !== true && [authorize, save, emit].some(hook => typeof hook !== "function")) {
|
|
9
9
|
throw new TypeError("Conversation tools require authorization, durable save and event facilities.");
|
|
@@ -11,6 +11,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
|
|
|
11
11
|
if (transient === true && previousCalls.length) {
|
|
12
12
|
throw new TypeError("Transient application tools cannot restore a previous execution.");
|
|
13
13
|
}
|
|
14
|
+
if (prepareContext !== undefined && typeof prepareContext !== "function") throw new TypeError("Application tool context requires a server-owned mapper.");
|
|
14
15
|
const toolSet = catalog.resolveToolSet(context, { discoveryOnly });
|
|
15
16
|
const calls = new Map(structuredClone(previousCalls).map(call => [call.id, call]));
|
|
16
17
|
let pending = Promise.resolve();
|
|
@@ -27,6 +28,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
|
|
|
27
28
|
|
|
28
29
|
async function run({ id, name, arguments: argumentsText }, signal) {
|
|
29
30
|
await authorize?.();
|
|
31
|
+
let executionContext = prepareContext ? await prepareContext() : context;
|
|
30
32
|
signal.throwIfAborted();
|
|
31
33
|
if (typeof id !== "string" || !id || id.length > 256 || typeof name !== "string" || !name || name.length > 256 ||
|
|
32
34
|
typeof argumentsText !== "string") throw new TypeError("An application tool requires its call id, name and JSON arguments.");
|
|
@@ -52,6 +54,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
|
|
|
52
54
|
await emit?.({ type: "tool", call: structuredClone(call) });
|
|
53
55
|
try {
|
|
54
56
|
await authorize?.();
|
|
57
|
+
if (prepareContext) executionContext = await prepareContext();
|
|
55
58
|
signal.throwIfAborted();
|
|
56
59
|
} catch (error) {
|
|
57
60
|
call.status = "not-executed";
|
|
@@ -61,7 +64,7 @@ export function createConversationTools({ catalog, context, signal: turnSignal,
|
|
|
61
64
|
}
|
|
62
65
|
let failed = false;
|
|
63
66
|
let failure;
|
|
64
|
-
call.result = await catalog.executeToolCall({ toolName: name, argumentsText, toolSet, context: { ...
|
|
67
|
+
call.result = await catalog.executeToolCall({ toolName: name, argumentsText, toolSet, context: { ...executionContext, signal },
|
|
65
68
|
...(propagateErrors ? { onFailure(error) { failed = true; failure = error; } } : {}) });
|
|
66
69
|
call.status = !call.result.ok && call.result.error?.status >= 500 ? "unknown" : "complete";
|
|
67
70
|
// Keep the result in memory as well: retrySave can persist it after a failed
|
|
@@ -44,7 +44,7 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
44
44
|
return ids.length ? transaction.readTurn(ids.at(-1)) : null;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], turnMetadata = null, requireOpenTurn = false, outputId = "" } = {}) {
|
|
47
|
+
async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], data, turnMetadata = null, requireOpenTurn = false, outputId = "" } = {}) {
|
|
48
48
|
const messageText = normalizeText(text);
|
|
49
49
|
const id = normalizeText(messageId);
|
|
50
50
|
if (!messageText && !(role === "user" && Array.isArray(attachments) && attachments.length)) return null;
|
|
@@ -61,7 +61,7 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
61
61
|
await transaction.appendMessage(turnId, {
|
|
62
62
|
role, text: messageText, messageId: id, at: createdAt.toISOString(),
|
|
63
63
|
...(["assistant", "commentary", "thinking"].includes(role) && normalizeText(outputId) ? { outputId: normalizeText(outputId) } : {}),
|
|
64
|
-
...(role === "user" ? { attachments, turnMetadata } : {})
|
|
64
|
+
...(role === "user" ? { attachments, turnMetadata, ...(data !== undefined ? { data: structuredClone(data) } : {}) } : {})
|
|
65
65
|
});
|
|
66
66
|
return transaction.readTurn(turnId);
|
|
67
67
|
});
|
|
@@ -1277,3 +1277,52 @@ for (const change of ["model", "effort"]) test(`Claude honors a consumer's fresh
|
|
|
1277
1277
|
assert.match(restoredPrompt, /Continue after the change/);
|
|
1278
1278
|
assert.match(restoredPrompt, /Continue after restart/);
|
|
1279
1279
|
});
|
|
1280
|
+
|
|
1281
|
+
for (const maxFinalReplyCharacters of [12, 11]) {
|
|
1282
|
+
test("Claude recovered reply enforces configured final limit of " + maxFinalReplyCharacters, async t => {
|
|
1283
|
+
const f = await fixture(t, { limits: { maxFinalReplyCharacters } });
|
|
1284
|
+
await assert.rejects(f.conversation.send({ messageId: "bounded-lost", text: "lost" }));
|
|
1285
|
+
assert.equal((await f.conversation.read()).status, "unconfirmed");
|
|
1286
|
+
const receipt = await f.conversation.inspectDelivery({ messageId: "bounded-lost" });
|
|
1287
|
+
assert.equal(receipt.status, "accepted", "A reply rejection must retain the proven user admission");
|
|
1288
|
+
assert.equal(receipt.recovered, true);
|
|
1289
|
+
const state = await f.conversation.read();
|
|
1290
|
+
const turn = state.conversationLog[0];
|
|
1291
|
+
assert.equal(turn.user.text, "lost");
|
|
1292
|
+
assert.equal(turn.metadata.runtime.status, maxFinalReplyCharacters === 12 ? "interrupted" : "failed");
|
|
1293
|
+
assert.equal(turn.assistant?.text, maxFinalReplyCharacters === 12 ? "Answer: lost" : undefined);
|
|
1294
|
+
if (maxFinalReplyCharacters === 11) assert.match(state.error, /final reply limit/);
|
|
1295
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1, "Recovery cannot repeat inference");
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
for (const savedStatus of ["cancelled", "failed", "unknown-tool", "interrupted"]) {
|
|
1300
|
+
test("Claude oversized recovery preserves " + savedStatus + " evidence", async t => {
|
|
1301
|
+
const limits = { maxFinalReplyCharacters: 13 };
|
|
1302
|
+
const f = await fixture(t, { limits });
|
|
1303
|
+
await f.conversation.send(input);
|
|
1304
|
+
assert.equal((await f.conversation.wait()).conversationLog[0].assistant.text, "Answer: Hello");
|
|
1305
|
+
await f.first.close();
|
|
1306
|
+
await f.storage.write("conversation", async tx => {
|
|
1307
|
+
const turn = await tx.readTurn("000001");
|
|
1308
|
+
await tx.replaceAssistant(turn.turnId, { ...turn.assistant, text: "" });
|
|
1309
|
+
await tx.updateTurnMetadata(turn.turnId, { runtime: { ...turn.metadata.runtime,
|
|
1310
|
+
status: savedStatus === "unknown-tool" ? "interrupted" : savedStatus,
|
|
1311
|
+
error: savedStatus === "unknown-tool" ? "" : "Exact prior " + savedStatus + " cause." },
|
|
1312
|
+
...(savedStatus === "unknown-tool" ? { applicationTools: [{ id: "unresolved-tool", status: "unknown" }] } : {}) });
|
|
1313
|
+
});
|
|
1314
|
+
limits.maxFinalReplyCharacters = 11;
|
|
1315
|
+
const reopened = await f.runtime().open({ id: "conversation" });
|
|
1316
|
+
const receipt = await reopened.inspectDelivery({ messageId: input.messageId });
|
|
1317
|
+
assert.equal(receipt.status, "accepted");
|
|
1318
|
+
const state = await reopened.read();
|
|
1319
|
+
assert.equal(state.conversationLog[0].metadata.runtime.status, ["unknown-tool", "interrupted"].includes(savedStatus) ? "failed" : savedStatus);
|
|
1320
|
+
assert.notEqual(state.conversationLog[0].assistant?.text, "Answer: Hello");
|
|
1321
|
+
if (savedStatus === "unknown-tool") {
|
|
1322
|
+
assert.match(state.error, /application tool has no verified result.*Inspect its target/);
|
|
1323
|
+
assert.deepEqual(state.conversationLog[0].metadata.applicationTools, [{ id: "unresolved-tool", status: "unknown" }]);
|
|
1324
|
+
} else if (savedStatus === "interrupted") assert.match(state.error, /final reply limit/);
|
|
1325
|
+
else assert.equal(state.error, "Exact prior " + savedStatus + " cause.");
|
|
1326
|
+
assert.equal((await f.trace()).filter(row => row.frame?.type === "user").length, 1);
|
|
1327
|
+
});
|
|
1328
|
+
}
|