@jskit-ai/assistant-core 0.1.213 → 0.1.214
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/fixtures/responsive-assistant/ConversationFixture.vue +7 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +5 -1
- package/src/client/conversation/AssistantTranscript.vue +10 -1
- package/src/server/conversation/agentRun.js +38 -11
- package/src/server/conversation/claudeHistory.js +40 -5
- package/src/server/conversation/claudeProcess.js +16 -2
- package/src/server/conversation/claudeTurn.js +28 -8
- package/src/server/conversation/codexConfiguration.js +1 -1
- package/src/server/conversation/codexHistoryAdapter.js +11 -4
- package/src/server/conversation/codexNotificationQueue.js +11 -2
- package/src/server/conversation/codexObservationControl.js +8 -8
- package/src/server/conversation/codexOutput.js +20 -15
- package/src/server/conversation/codexProvider.js +40 -3
- package/src/server/conversation/codexProviderOwner.js +110 -1
- package/src/server/conversation/codexTurn.js +3 -0
- package/src/server/conversation/continuity.js +24 -6
- package/src/server/conversation/index.js +1 -1
- package/src/server/conversation/nativeHistoryExport.js +97 -0
- package/src/server/conversation/openCodeClient.js +47 -1
- package/src/server/conversation/openCodeRuntime.js +132 -24
- package/src/server/conversation/openCodeTurn.js +65 -24
- package/src/server/conversation/providers/api.js +7 -0
- package/src/server/conversation/providers/claudeDriver.js +52 -7
- package/src/server/conversation/providers/codex.js +19 -2
- package/src/server/conversation/providers/codexDriver.js +92 -14
- package/src/server/conversation/providers/opencodeDriver.js +31 -9
- package/src/server/conversation/runtime.js +418 -64
- package/src/server/conversation/runtimeStateUpgrade.js +77 -2
- package/src/server/conversation/streams.js +25 -5
- package/src/server/conversation/tools.js +2 -2
- package/src/server/conversation/transcript.js +23 -8
- package/src/server/hosts/claudeHistory.js +1 -0
- package/src/server/hosts/claudeProcess.js +1 -0
- package/src/server/hosts/codexProvider.js +3 -0
- package/src/server/hosts/nativeHistory.js +1 -0
- package/src/server/hosts/openCodeClient.js +1 -0
- package/src/server/hosts/openCodeTurn.js +1 -0
- package/src/server/index.js +1 -1
- package/src/server/lib/assistantToolLoop.js +99 -2
- package/test/aiConnectionClient.test.js +57 -0
- package/test/assistantBoundedToolLoop.test.js +332 -0
- package/test/assistantScroll.browser.test.js +151 -0
- package/test/claudeConversationSession.test.js +1329 -3
- package/test/claudeProcess.test.js +101 -0
- package/test/claudeTurn.test.js +31 -0
- package/test/codexConversationSession.test.js +1257 -12
- package/test/codexHistoryAdapter.test.js +74 -1
- package/test/codexTurn.test.js +282 -1
- package/test/conversation.test.js +83 -0
- package/test/conversationAgentRun.test.js +181 -0
- package/test/conversationRuntime.test.js +95 -0
- package/test/conversationRuntimeStateUpgrade.test.js +89 -0
- package/test/conversationStreaming.test.js +85 -0
- package/test/conversationTools.test.js +112 -1
- package/test/openCodeClient.test.js +186 -0
- package/test/openCodeRuntime.test.js +78 -0
- package/test/opencodeConversationSession.test.js +637 -5
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isAbsolute } from "node:path";
|
|
2
|
+
|
|
1
3
|
const engines = new Set(["api", "claude", "codex", "opencode"]);
|
|
2
4
|
const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
3
5
|
|
|
@@ -74,12 +76,81 @@ function inspectRequest(request, segment, version, warnings) {
|
|
|
74
76
|
warnings.push(`The retained ${segment.engine} request requires native receipt inspection. Its missing prompt was not reconstructed and will not be replayed.`);
|
|
75
77
|
}
|
|
76
78
|
|
|
79
|
+
/** Literal replacement state assembly shared by live and explicit offline callers. */
|
|
80
|
+
export function createConversationRuntimeReplacement({ request, engine, configuration, reason, segmentId, continuity, seen, destination }) {
|
|
81
|
+
return { request, engine, configuration, reason, segmentId,
|
|
82
|
+
continuity: continuity.text, continuityAttachments: continuity.attachments, seen,
|
|
83
|
+
...(destination ? { binding: destination.binding || null, submission: destination.request } : {}) };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function releaseConversationRuntimeBinding(state, pending, request, engine) {
|
|
87
|
+
state.predecessors.push({ segmentId: state.segmentId, engine: state.engine,
|
|
88
|
+
configuration: state.configuration, binding: state.binding, seen: state.seen,
|
|
89
|
+
continuity: state.continuity, continuityAttachments: state.continuityAttachments, request: state.request,
|
|
90
|
+
successorId: pending.segmentId, replacement: request,
|
|
91
|
+
...(pending.preparedAt ? { preparedAt: pending.preparedAt } : {}) });
|
|
92
|
+
Object.assign(state, { engine, configuration: pending.configuration, binding: pending.binding,
|
|
93
|
+
segmentId: pending.segmentId, continuity: pending.continuity, continuityAttachments: pending.continuityAttachments, seen: pending.seen });
|
|
94
|
+
if (pending.submission) state.request = pending.submission;
|
|
95
|
+
else delete state.request;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function finishConversationRuntimeReplacement(state, { engine, selection, retireNative, pending, value }) {
|
|
99
|
+
if (value) {
|
|
100
|
+
if (engine !== "api") state.seen = value.engines[engine].seen;
|
|
101
|
+
state.lastEngine = value.lastEngine;
|
|
102
|
+
} else if (engine !== "api" &&
|
|
103
|
+
(!selection || retireNative === true || engine === "opencode" && !pending.binding.sessionId)) {
|
|
104
|
+
state.lastEngine = "";
|
|
105
|
+
}
|
|
106
|
+
delete state.replacement;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Paths are resolved and authorized by the caller; this constructor performs no I/O. */
|
|
110
|
+
export function createInertCodexConversationBinding({ workdir, configRoot }) {
|
|
111
|
+
return { threadId: "", workdir, configRoot, executionId: "" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function retireCodexBinding(runtime, retirement) {
|
|
115
|
+
const keys = ["operationId", "successorSegmentId", "expectedSegmentId", "expectedThreadId", "expectedToolSchemaIdentity", "workdir", "configRoot"];
|
|
116
|
+
if (!object(retirement) || Object.keys(retirement).some(key => !keys.includes(key)) ||
|
|
117
|
+
typeof retirement.operationId !== "string" || typeof retirement.successorSegmentId !== "string" ||
|
|
118
|
+
!/^[\w-]{1,128}$/u.test(retirement.operationId || "") ||
|
|
119
|
+
!/^[\w-]{1,128}$/u.test(retirement.successorSegmentId || "") ||
|
|
120
|
+
typeof retirement.expectedSegmentId !== "string" || !retirement.expectedSegmentId ||
|
|
121
|
+
typeof retirement.expectedThreadId !== "string" || !retirement.expectedThreadId ||
|
|
122
|
+
typeof retirement.expectedToolSchemaIdentity !== "string" || !/^[a-f0-9]{64}$/u.test(retirement.expectedToolSchemaIdentity) ||
|
|
123
|
+
typeof retirement.workdir !== "string" || !isAbsolute(retirement.workdir) ||
|
|
124
|
+
typeof retirement.configRoot !== "string" || !isAbsolute(retirement.configRoot)) {
|
|
125
|
+
invalid("Offline Codex retirement requires immutable operation/successor IDs, the exact old native tuple, and verified physical paths.");
|
|
126
|
+
}
|
|
127
|
+
if (runtime.version !== 3 || runtime.engine !== "codex" || runtime.request !== undefined || runtime.replacement !== undefined ||
|
|
128
|
+
runtime.segmentId !== retirement.expectedSegmentId || runtime.binding.threadId !== retirement.expectedThreadId ||
|
|
129
|
+
runtime.binding.toolSchemaIdentity !== retirement.expectedToolSchemaIdentity ||
|
|
130
|
+
runtime.binding.workdir !== retirement.workdir || runtime.binding.configRoot !== retirement.configRoot ||
|
|
131
|
+
retirement.successorSegmentId === runtime.segmentId ||
|
|
132
|
+
runtime.predecessors.some(segment => segment.segmentId === retirement.successorSegmentId ||
|
|
133
|
+
segment.successorId === retirement.successorSegmentId || segment.replacement?.operationId === retirement.operationId)) {
|
|
134
|
+
invalid("Offline Codex retirement conflicts with its saved native identity, pending delivery, paths or retained replacement IDs.");
|
|
135
|
+
}
|
|
136
|
+
const request = { operationId: retirement.operationId, expectedSegmentId: runtime.segmentId,
|
|
137
|
+
engine: "codex", configuration: structuredClone(runtime.configuration), retireNative: true, operation: "select" };
|
|
138
|
+
const pending = createConversationRuntimeReplacement({ request, engine: "codex", configuration: structuredClone(runtime.configuration),
|
|
139
|
+
reason: "model-change", segmentId: retirement.successorSegmentId, continuity: { text: undefined, attachments: [] }, seen: {} });
|
|
140
|
+
pending.binding = createInertCodexConversationBinding(retirement);
|
|
141
|
+
releaseConversationRuntimeBinding(runtime, pending, request, "codex");
|
|
142
|
+
finishConversationRuntimeReplacement(runtime, { engine: "codex", selection: true, retireNative: true, pending });
|
|
143
|
+
}
|
|
144
|
+
|
|
77
145
|
/** Pure offline transform. The application owns enumeration, backups and publication. */
|
|
78
|
-
export function upgradeConversationRuntimeState({ metadata, conversationLog }) {
|
|
146
|
+
export function upgradeConversationRuntimeState({ metadata, conversationLog, retirement }) {
|
|
79
147
|
if (!object(metadata) || !Array.isArray(conversationLog)) {
|
|
80
148
|
invalid("Conversation runtime upgrade requires metadata and the complete stored transcript.");
|
|
81
149
|
}
|
|
82
|
-
if (metadata.runtime === undefined)
|
|
150
|
+
if (metadata.runtime === undefined) {
|
|
151
|
+
if (retirement !== undefined) invalid("Offline native retirement requires the exact saved runtime; it cannot create a historical binding.");
|
|
152
|
+
return { metadata, changed: false, warnings: [] };
|
|
153
|
+
}
|
|
83
154
|
const source = metadata.runtime;
|
|
84
155
|
if (!object(source) || ![2, 3].includes(source.version) || !Array.isArray(source.predecessors)) {
|
|
85
156
|
invalid("This conversation has an unsupported runtime version. Inspect its original format and provide an offline conversion before opening it.");
|
|
@@ -110,6 +181,10 @@ export function upgradeConversationRuntimeState({ metadata, conversationLog }) {
|
|
|
110
181
|
invalid("An unfinished replacement has a request without its exact saved binding. Inspect it before upgrading.");
|
|
111
182
|
}
|
|
112
183
|
}
|
|
184
|
+
if (retirement !== undefined) {
|
|
185
|
+
retireCodexBinding(runtime, retirement);
|
|
186
|
+
return { metadata: { ...metadata, runtime }, changed: true, warnings };
|
|
187
|
+
}
|
|
113
188
|
if (source.version === 3) return { metadata, changed: false, warnings: [] };
|
|
114
189
|
let lastEngine = "";
|
|
115
190
|
for (const turn of conversationLog) {
|
|
@@ -11,15 +11,19 @@ export function createConversationStreams({ clock = () => new Date() } = {}) {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
function update(scope, { turnId, messageId, role = "assistant", text, delta, at, origin, nativeIdentity, outputId } = {}) {
|
|
14
|
+
function update(scope, { turnId, messageId, role = "assistant", text, delta, at, origin, nativeIdentity, outputId, authorship } = {}) {
|
|
15
15
|
if (!turnId || !messageId || !["assistant", "commentary"].includes(role)) return null;
|
|
16
16
|
let stream = streams.get(scope);
|
|
17
17
|
if (!stream || stream.turnId !== turnId) {
|
|
18
|
-
stream = { turnId, messages: new Map(), completed: new Set() };
|
|
18
|
+
stream = { turnId, messages: new Map(), completed: new Set(), authorship: new Map() };
|
|
19
19
|
streams.set(scope, stream);
|
|
20
20
|
}
|
|
21
21
|
if (stream.completed.has(messageId)) return null;
|
|
22
22
|
const previous = stream.messages.get(messageId);
|
|
23
|
+
const captured = stream.authorship.get(messageId);
|
|
24
|
+
if (authorship?.messageId && (!previous || captured?.messageId === authorship.messageId)) {
|
|
25
|
+
stream.authorship.set(messageId, captured?.turnId ? captured : { ...authorship });
|
|
26
|
+
}
|
|
23
27
|
const next = {
|
|
24
28
|
messageId,
|
|
25
29
|
...((previous?.outputId || outputId) ? { outputId: previous?.outputId || outputId } : {}),
|
|
@@ -27,24 +31,40 @@ export function createConversationStreams({ clock = () => new Date() } = {}) {
|
|
|
27
31
|
// Ordinary runtime output already carries the exact authored row identity.
|
|
28
32
|
...(["user", "application"].includes(origin || previous?.origin)
|
|
29
33
|
? { ...(!nativeIdentity ? { turnId } : {}), origin: origin || previous.origin } : {}),
|
|
34
|
+
...(stream.authorship.get(messageId)?.origin === "application" && stream.authorship.get(messageId)?.completedEnvelope === true
|
|
35
|
+
? { completedEnvelope: true } : {}),
|
|
30
36
|
role: previous?.role || role,
|
|
31
37
|
at: previous?.at || at || clock().toISOString(),
|
|
32
38
|
text: typeof delta === "string" ? (previous?.text || "") + delta : (text ?? previous?.text ?? ""),
|
|
33
39
|
status: "inProgress"
|
|
34
40
|
};
|
|
35
|
-
if (previous?.text === next.text && previous.role === next.role && previous.outputId === next.outputId
|
|
41
|
+
if (previous?.text === next.text && previous.role === next.role && previous.outputId === next.outputId &&
|
|
42
|
+
previous.completedEnvelope === next.completedEnvelope) return null;
|
|
36
43
|
stream.messages.set(messageId, next);
|
|
37
44
|
revision += 1;
|
|
38
45
|
return read(scope);
|
|
39
46
|
}
|
|
40
47
|
|
|
41
|
-
function complete(scope, messageId) {
|
|
48
|
+
function complete(scope, messageId, { text, role, authorship } = {}) {
|
|
42
49
|
const stream = streams.get(scope);
|
|
43
50
|
if (!stream) return read(scope);
|
|
51
|
+
const message = stream.messages.get(messageId);
|
|
52
|
+
const captured = stream.authorship.get(messageId);
|
|
53
|
+
const completedAuthorship = captured?.messageId === authorship?.messageId && captured?.origin === authorship?.origin && !captured?.turnId
|
|
54
|
+
? { ...captured, ...(authorship?.turnId ? { turnId: authorship.turnId } : {}) } : captured;
|
|
44
55
|
stream.completed.add(messageId);
|
|
45
56
|
stream.messages.delete(messageId);
|
|
57
|
+
stream.authorship.delete(messageId);
|
|
46
58
|
revision += 1;
|
|
47
|
-
|
|
59
|
+
const snapshot = read(scope);
|
|
60
|
+
// Only the exact native completed-text path supplies a completion carrier.
|
|
61
|
+
// Cleanup, duplicate notifications and ordinary reads cannot replay it.
|
|
62
|
+
if (message && typeof text === "string" && completedAuthorship?.turnId && ["user", "application"].includes(completedAuthorship.origin)) {
|
|
63
|
+
snapshot.completedMessages = [{ ...message, turnId: completedAuthorship.turnId, origin: completedAuthorship.origin,
|
|
64
|
+
...(completedAuthorship.origin === "application" && completedAuthorship.completedEnvelope === true ? { completedEnvelope: true } : {}),
|
|
65
|
+
...(["assistant", "commentary", "thinking"].includes(role) ? { role } : {}), text, status: "complete" }];
|
|
66
|
+
}
|
|
67
|
+
return snapshot;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
function clear(scope) {
|
|
@@ -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, prepareContext, signal: turnSignal, previousCalls = [], authorize, save, emit,
|
|
6
|
+
export function createConversationTools({ catalog, toolSet: retainedToolSet, 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.");
|
|
@@ -12,7 +12,7 @@ export function createConversationTools({ catalog, context, prepareContext, sign
|
|
|
12
12
|
throw new TypeError("Transient application tools cannot restore a previous execution.");
|
|
13
13
|
}
|
|
14
14
|
if (prepareContext !== undefined && typeof prepareContext !== "function") throw new TypeError("Application tool context requires a server-owned mapper.");
|
|
15
|
-
const toolSet = catalog.resolveToolSet(context, { discoveryOnly });
|
|
15
|
+
const toolSet = retainedToolSet ?? catalog.resolveToolSet(context, { discoveryOnly });
|
|
16
16
|
const calls = new Map(structuredClone(previousCalls).map(call => [call.id, call]));
|
|
17
17
|
let pending = Promise.resolve();
|
|
18
18
|
let requested = 0;
|
|
@@ -6,23 +6,30 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
6
6
|
throw new TypeError("Conversation storage requires read(scope, callback) and write(scope, callback).");
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
async function readConversationLog(scope) {
|
|
9
|
+
async function readConversationLog(scope, { presentation = false } = {}) {
|
|
10
|
+
if (typeof presentation !== "boolean") throw new TypeError("Transcript presentation requires an explicit boolean.");
|
|
10
11
|
return storage.read(scope, async (transaction) => {
|
|
11
12
|
const ids = await transaction.listTurnIds();
|
|
12
|
-
|
|
13
|
+
const turns = (await Promise.all(ids.map((id) => transaction.readTurn(id)))).filter(hasMessages);
|
|
14
|
+
return presentation ? turns.filter(turn => !isCompletedEnvelopeTurn(turn)) : turns;
|
|
13
15
|
});
|
|
14
16
|
}
|
|
15
17
|
|
|
16
|
-
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0 } = {}) {
|
|
18
|
+
async function readConversationLogPage(scope, { beforeTurnId = "", limit = 0, presentation = false } = {}) {
|
|
19
|
+
if (typeof presentation !== "boolean") throw new TypeError("Transcript presentation requires an explicit boolean.");
|
|
17
20
|
return storage.read(scope, async (transaction) => {
|
|
18
|
-
const
|
|
21
|
+
const allIds = await transaction.listTurnIds();
|
|
22
|
+
const visible = presentation ? (await Promise.all(allIds.map(id => transaction.readTurn(id))))
|
|
23
|
+
.filter(turn => hasMessages(turn) && !isCompletedEnvelopeTurn(turn)) : null;
|
|
24
|
+
const ids = visible ? visible.map(turn => turn.turnId) : allIds;
|
|
19
25
|
const before = normalizeText(beforeTurnId);
|
|
20
26
|
const requestedLimit = Number.parseInt(String(limit || ""), 10);
|
|
21
27
|
const pageLimit = Number.isFinite(requestedLimit) && requestedLimit > 0 ? Math.min(requestedLimit, 100) : 0;
|
|
22
28
|
const end = before && ids.includes(before) ? ids.indexOf(before) : ids.length;
|
|
23
29
|
const start = pageLimit ? Math.max(0, end - pageLimit) : 0;
|
|
24
30
|
const selected = ids.slice(start, end);
|
|
25
|
-
const turns =
|
|
31
|
+
const turns = visible ? visible.slice(start, end)
|
|
32
|
+
: (await Promise.all(selected.map((id) => transaction.readTurn(id)))).filter(hasMessages);
|
|
26
33
|
return {
|
|
27
34
|
conversationLog: turns,
|
|
28
35
|
pagination: {
|
|
@@ -44,20 +51,23 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
44
51
|
return ids.length ? transaction.readTurn(ids.at(-1)) : null;
|
|
45
52
|
}
|
|
46
53
|
|
|
47
|
-
async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], data, turnMetadata = null, requireOpenTurn = false, outputId = "" } = {}) {
|
|
54
|
+
async function append(scope, role, { text = "", messageId = "", at = "", attachments = [], data, turnMetadata = null, requireOpenTurn = false, outputId = "", turnId: requestedTurnId = "" } = {}) {
|
|
48
55
|
const messageText = normalizeText(text);
|
|
49
56
|
const id = normalizeText(messageId);
|
|
50
57
|
if (!messageText && !(role === "user" && Array.isArray(attachments) && attachments.length)) return null;
|
|
51
58
|
return storage.write(scope, async (transaction) => {
|
|
52
59
|
if (id && await transaction.hasMessage(id)) return null;
|
|
53
60
|
const createdAt = new Date(at || clock());
|
|
54
|
-
const
|
|
61
|
+
const targetId = ["assistant", "commentary", "thinking"].includes(role) ? normalizeText(requestedTurnId) : "";
|
|
62
|
+
const target = targetId ? await transaction.readTurn(targetId) : null;
|
|
63
|
+
if (targetId && !target) throw new Error("The native output's original turn no longer exists.");
|
|
64
|
+
const tail = target || (["assistant", "commentary", "thinking"].includes(role) ? await tailTurn(transaction) : null);
|
|
55
65
|
const open = (tail?.user || applicationTurns && tail?.system?.origin === "application") && !tail.assistant ? tail : null;
|
|
56
66
|
if (requireOpenTurn && !open) return null;
|
|
57
67
|
const thinkingOnly = role === "thinking" && !open && at && tail &&
|
|
58
68
|
!tail.system && !tail.user && !tail.assistant && !tail.commentary?.length &&
|
|
59
69
|
tail.thinking?.some((message) => message.at === createdAt.toISOString());
|
|
60
|
-
const turnId = open?.turnId || (thinkingOnly ? tail.turnId : await transaction.nextTurnId());
|
|
70
|
+
const turnId = target?.turnId || open?.turnId || (thinkingOnly ? tail.turnId : await transaction.nextTurnId());
|
|
61
71
|
await transaction.appendMessage(turnId, {
|
|
62
72
|
role, text: messageText, messageId: id, at: createdAt.toISOString(),
|
|
63
73
|
...(["assistant", "commentary", "thinking"].includes(role) && normalizeText(outputId) ? { outputId: normalizeText(outputId) } : {}),
|
|
@@ -91,3 +101,8 @@ export function createConversationTranscript({ storage, clock = () => new Date()
|
|
|
91
101
|
function hasMessages(turn) {
|
|
92
102
|
return Boolean(turn && (turn.system || turn.user || turn.assistant || turn.commentary?.length || turn.thinking?.length));
|
|
93
103
|
}
|
|
104
|
+
|
|
105
|
+
// Only the future server-owned application response marker identifies a private carrier.
|
|
106
|
+
export function isCompletedEnvelopeTurn(turn) {
|
|
107
|
+
return turn?.metadata?.runtime?.origin === "application" && turn.metadata.runtime.completedEnvelope === true;
|
|
108
|
+
}
|
|
@@ -16,8 +16,11 @@ export {
|
|
|
16
16
|
ensureCodexAppServerThread,
|
|
17
17
|
inspectCodexAppServerMessageAdmission,
|
|
18
18
|
retireCodexConversationHistory,
|
|
19
|
+
readCodexNativeGoal,
|
|
19
20
|
resumeExactCodexAppServerThread,
|
|
20
21
|
sendCodexAppServerPrompt,
|
|
21
22
|
shellQuote,
|
|
22
23
|
startFreshCodexAppServerThread
|
|
23
24
|
} from "../conversation/codexProvider.js";
|
|
25
|
+
|
|
26
|
+
export { readCodexHistoryRows } from "../conversation/codexHistoryAdapter.js";
|
package/src/server/index.js
CHANGED
|
@@ -15,4 +15,4 @@ export {
|
|
|
15
15
|
export { resolveWorkspaceSlug } from "./lib/resolveWorkspaceSlug.js";
|
|
16
16
|
export { createServiceToolCatalog } from "./lib/serviceToolCatalog.js";
|
|
17
17
|
export { consumeCompletionStream, sanitizeAssistantMessageText } from "./lib/assistantCompletion.js";
|
|
18
|
-
export { runAssistantToolLoop, runBoundedAssistantToolLoop } from "./lib/assistantToolLoop.js";
|
|
18
|
+
export { runAssistantToolLoop, runBoundedAssistantToolLoop, readAssistantResponseEnvelope, readPartialAssistantReply } from "./lib/assistantToolLoop.js";
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createSchema } from "@jskit-ai/kernel/shared/validators";
|
|
2
3
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
4
|
import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
|
|
4
5
|
import { sanitizeAssistantMessageText } from "./assistantCompletion.js";
|
|
@@ -259,6 +260,43 @@ async function runAssistantToolLoop({ messages, input, toolSet, toToolSchema, co
|
|
|
259
260
|
});
|
|
260
261
|
}
|
|
261
262
|
|
|
263
|
+
const ASSISTANT_TOOL_PAYLOAD_LIMIT = 256 * 1024;
|
|
264
|
+
|
|
265
|
+
const assistantEnvelopeSchema = createSchema({
|
|
266
|
+
kind: { type: "string", enum: ["reply", "tool"], required: true },
|
|
267
|
+
text: { type: "string", maxLength: 16000, required: false },
|
|
268
|
+
toolName: { type: "string", maxLength: 256, required: false },
|
|
269
|
+
arguments: { type: "string", maxLength: ASSISTANT_TOOL_PAYLOAD_LIMIT, required: false }
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const assistantEnvelopeOutputSchema = {
|
|
273
|
+
type: "object", additionalProperties: false,
|
|
274
|
+
required: ["kind", "text", "toolName", "arguments"],
|
|
275
|
+
properties: {
|
|
276
|
+
kind: { type: "string", enum: ["reply", "tool"] },
|
|
277
|
+
text: { type: "string", maxLength: 16000, description: "The final reply, or a brief progress sentence for the first tool request; empty for subsequent tool requests." }, toolName: { type: "string", maxLength: 256 }, arguments: { type: "string", maxLength: ASSISTANT_TOOL_PAYLOAD_LIMIT }
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
function readAssistantResponseEnvelope(text) {
|
|
282
|
+
const parsed = JSON.parse(text);
|
|
283
|
+
const result = assistantEnvelopeSchema.create(parsed);
|
|
284
|
+
if (Object.keys(result.errors).length || !parsed || Array.isArray(parsed)) throw new Error("Invalid assistant response envelope.");
|
|
285
|
+
const value = result.validatedObject;
|
|
286
|
+
if (value.kind === "reply" && (!value.text || value.toolName || value.arguments)) throw new Error("A reply needs text and no tool call.");
|
|
287
|
+
if (value.kind === "tool" && (!value.toolName || !value.arguments || (value.text || "").length > 280)) throw new Error("A tool call needs its name and JSON arguments, with at most 280 characters of progress text.");
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function readPartialAssistantReply(text) {
|
|
292
|
+
// Only the reply prefix from our envelope is visible. An unfinished escape
|
|
293
|
+
// waits for its remaining bytes; tool envelopes never become chat text.
|
|
294
|
+
// eslint-disable-next-line no-control-regex -- JSON strings forbid literal control characters.
|
|
295
|
+
const match = /^\s*\{\s*"kind"\s*:\s*"reply"\s*,\s*"text"\s*:\s*("(?:[^"\\\u0000-\u001f]|\\(?:["\\/bfnrt]|u[\da-fA-F]{4}))*)/.exec(text);
|
|
296
|
+
if (!match) return "";
|
|
297
|
+
return JSON.parse(`${match[1]}"`).slice(0, 16000).replace(/[\uD800-\uDBFF]$/, "");
|
|
298
|
+
}
|
|
299
|
+
|
|
262
300
|
function boundedResponseSchema(outputSchema, descriptors) {
|
|
263
301
|
const object = properties => ({ type: "object", additionalProperties: false,
|
|
264
302
|
properties, required: Object.keys(properties) });
|
|
@@ -273,9 +311,68 @@ function boundedResponseSchema(outputSchema, descriptors) {
|
|
|
273
311
|
/** Original bounded response/operation order, with one shared reply/tool protocol. */
|
|
274
312
|
async function runBoundedAssistantToolLoop({ prompt, signal, policy, complete, outputSchema, limitError,
|
|
275
313
|
invalidResponseError = new Error("The assistant returned an invalid response."),
|
|
276
|
-
failureError = new Error("The assistant could not complete this request."), toolCatalog, toolContext
|
|
314
|
+
failureError = new Error("The assistant could not complete this request."), toolCatalog, toolContext,
|
|
315
|
+
completedEnvelope = false, settle }) {
|
|
316
|
+
if (typeof completedEnvelope !== "boolean") throw new TypeError("Completed envelope mode must be a boolean.");
|
|
317
|
+
if (completedEnvelope && (policy.maximumResponses !== 24 || typeof settle !== "function")) {
|
|
318
|
+
throw new TypeError("Completed envelope mode requires 24 responses and application settlement.");
|
|
319
|
+
}
|
|
320
|
+
let invalidResponses = 0;
|
|
321
|
+
let previousResponse;
|
|
277
322
|
for (let round = 0; round < policy.maximumResponses; round += 1) {
|
|
278
323
|
signal?.throwIfAborted();
|
|
324
|
+
if (completedEnvelope) {
|
|
325
|
+
// complete returns inside the original worker's current authority lifetime.
|
|
326
|
+
// Its durable response tools must not reuse a sealed native request's tools.
|
|
327
|
+
const result = await complete(prompt, { timeoutMs: policy.timeoutMs,
|
|
328
|
+
outputSchema: assistantEnvelopeOutputSchema, previousResponse });
|
|
329
|
+
signal?.throwIfAborted();
|
|
330
|
+
if (result?.ok === false) {
|
|
331
|
+
throw Object.assign(new Error(normalizeText(result.error) || failureError.message), {
|
|
332
|
+
code: normalizeText(result.code) || failureError.code
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
const decision = await settle({ phase: "before-parse", result });
|
|
336
|
+
signal?.throwIfAborted();
|
|
337
|
+
if (decision === "continue") continue;
|
|
338
|
+
if (decision === "end") return;
|
|
339
|
+
if (decision !== "use") throw new TypeError("Invalid completed-response settlement decision.");
|
|
340
|
+
let response;
|
|
341
|
+
try {
|
|
342
|
+
response = readAssistantResponseEnvelope(result?.text);
|
|
343
|
+
if (result.nativeToolAttempt && response.kind === "reply") {
|
|
344
|
+
throw new Error("Application tools must use the response envelope.");
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
previousResponse = { kind: "invalid", nativeToolAttempt: Boolean(result?.nativeToolAttempt) };
|
|
348
|
+
if (++invalidResponses > 2) {
|
|
349
|
+
const error = typeof invalidResponseError === "function"
|
|
350
|
+
? invalidResponseError({ nativeToolAttempt: previousResponse.nativeToolAttempt }) : invalidResponseError;
|
|
351
|
+
if (!(error instanceof Error)) throw new TypeError("Invalid response error must be an Error.");
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (response.kind === "reply") {
|
|
357
|
+
const decision = await settle({ phase: "after-final", response });
|
|
358
|
+
signal?.throwIfAborted();
|
|
359
|
+
if (decision === "continue") continue;
|
|
360
|
+
if (decision !== "end") throw new TypeError("Invalid completed-response settlement decision.");
|
|
361
|
+
return response.text;
|
|
362
|
+
}
|
|
363
|
+
if (typeof result?.tools?.execute !== "function") {
|
|
364
|
+
throw new TypeError("Completed application operations require the current durable tools.");
|
|
365
|
+
}
|
|
366
|
+
if (typeof result.toolCallId !== "string" || !result.toolCallId || result.toolCallId.length > 256) {
|
|
367
|
+
throw new TypeError("Completed application operations require their verified response call id.");
|
|
368
|
+
}
|
|
369
|
+
// Recovery reuses the verified response identity, never a new reservation.
|
|
370
|
+
// The existing tool owner alone reserves, authorizes, executes and saves.
|
|
371
|
+
const toolResult = await result.tools.execute({ id: result.toolCallId, name: response.toolName,
|
|
372
|
+
arguments: response.arguments }, { signal });
|
|
373
|
+
previousResponse = { ...response, result: toolResult };
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
279
376
|
const tools = createConversationTools({ catalog: toolCatalog, context: toolContext,
|
|
280
377
|
signal: signal || new AbortController().signal, maximumCalls: 1, discoveryOnly: false,
|
|
281
378
|
transient: true, propagateErrors: true });
|
|
@@ -311,4 +408,4 @@ async function runBoundedAssistantToolLoop({ prompt, signal, policy, complete, o
|
|
|
311
408
|
throw limitError;
|
|
312
409
|
}
|
|
313
410
|
|
|
314
|
-
export { runAssistantToolLoop, runBoundedAssistantToolLoop };
|
|
411
|
+
export { runAssistantToolLoop, runBoundedAssistantToolLoop, readAssistantResponseEnvelope, readPartialAssistantReply };
|
|
@@ -368,3 +368,60 @@ test("only explicit application-catalogue mode reports well-formed unavailable t
|
|
|
368
368
|
assert.equal(chunks.at(-1).choices[0].finish_reason, "tool-calls");
|
|
369
369
|
assert.equal(requests, 1, "reporting the attempted call neither executes an action nor starts another request");
|
|
370
370
|
});
|
|
371
|
+
|
|
372
|
+
// Saved app-model aliases are direct API choices, not new native Codex/Claude
|
|
373
|
+
// offerings. Retain the original resolver and image-SDK assertions together.
|
|
374
|
+
for (const modelId of ["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp"]) {
|
|
375
|
+
test(`saved DeepSeek ${modelId} reaches the authorized SDK unchanged with exact limits and image bytes`, async () => {
|
|
376
|
+
const selected = `deepseek/${modelId}`;
|
|
377
|
+
const configuration = { schemaVersion: 1, registrations: {}, integrations: {
|
|
378
|
+
assistant: { provider: "ai", accountMode: "per-user", scopes: [],
|
|
379
|
+
authentication: { method: "api-key", secretRef: "account:deepseek" }, settings: { model: selected } }
|
|
380
|
+
} };
|
|
381
|
+
const actor = { applicationId: "example", subjectId: "person-one" };
|
|
382
|
+
let credentialReads = 0, requests = 0;
|
|
383
|
+
const connection = await createAiConnectionResolver({ configuration,
|
|
384
|
+
authorize(context, operation) {
|
|
385
|
+
assert.deepEqual(context, actor);
|
|
386
|
+
assert.deepEqual(operation, { integrationId: "assistant", operation: "ai.resolve", accountMode: "per-user" });
|
|
387
|
+
return actor;
|
|
388
|
+
},
|
|
389
|
+
resolveReference(reference, owner, slot) {
|
|
390
|
+
credentialReads++;
|
|
391
|
+
assert.equal(reference, "account:deepseek");
|
|
392
|
+
assert.deepEqual(owner, actor);
|
|
393
|
+
assert.deepEqual(slot, { integrationId: "assistant", accountMode: "per-user", providerId: "deepseek" });
|
|
394
|
+
return "selected-person-key";
|
|
395
|
+
}
|
|
396
|
+
}).resolve({ context: actor, integrationId: "assistant" });
|
|
397
|
+
assert.equal(connection.providerId, "deepseek");
|
|
398
|
+
assert.equal(connection.model, modelId);
|
|
399
|
+
assert.deepEqual(connection.modelLimits, { context: 1048576, output: 393216 });
|
|
400
|
+
assert.equal(connection.sdkPackage, "@ai-sdk/openai-compatible");
|
|
401
|
+
assert.equal(configuration.integrations.assistant.settings.model, selected, "Resolving preserves the saved selection");
|
|
402
|
+
const client = createAiConnectionClient(connection, {
|
|
403
|
+
async fetch(url, options) {
|
|
404
|
+
requests++;
|
|
405
|
+
assert.equal(String(url), "https://api.deepseek.com/chat/completions");
|
|
406
|
+
assert.equal(new Headers(options.headers).get("authorization"), "Bearer selected-person-key");
|
|
407
|
+
const body = JSON.parse(options.body);
|
|
408
|
+
assert.equal(body.model, modelId, "The actual SDK never substitutes the current Flash ID for a saved alias");
|
|
409
|
+
assert.equal(body.stream, true);
|
|
410
|
+
const content = body.messages[0].content;
|
|
411
|
+
assert.equal(content[0].text, "Describe this");
|
|
412
|
+
assert.equal(content[1].type, "image_url");
|
|
413
|
+
assert.equal(content[1].image_url.url, "data:image/png;base64,AQID");
|
|
414
|
+
return eventsResponse([{ choices: [{ index: 0, delta: { content: "An image" }, finish_reason: "stop" }] }]);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
assert.equal(client.defaultModel, modelId);
|
|
418
|
+
const chunks = await Array.fromAsync(client.createChatCompletionStream({ messages: [{ role: "user", content: [
|
|
419
|
+
{ type: "text", text: "Describe this" }, { type: "image", image: new Uint8Array([1, 2, 3]), mediaType: "image/png" }
|
|
420
|
+
] }] }));
|
|
421
|
+
assert.equal(chunks[0].choices[0].delta.content, "An image");
|
|
422
|
+
assert.equal(chunks.at(-1).choices[0].finish_reason, "stop");
|
|
423
|
+
assert.equal(credentialReads, 1);
|
|
424
|
+
assert.equal(requests, 1, "One selected connection produces one inference without fallback or retry");
|
|
425
|
+
assert.deepEqual(connection.modelLimits, { context: 1048576, output: 393216 });
|
|
426
|
+
});
|
|
427
|
+
}
|