@jskit-ai/assistant-runtime 0.1.143 → 0.1.144
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/package.json +7 -7
- package/src/client/composables/useAssistantRuntime.js +8 -111
- package/src/client/support/assistantRuntimeState.js +140 -0
- package/src/server/services/chatService.js +41 -215
- package/src/shared/assistantResponseText.js +29 -0
- package/test/assistantRuntimeState.test.js +134 -0
- package/test/chatServiceLifecycle.test.js +300 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.144",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -11,15 +11,15 @@
|
|
|
11
11
|
"./server/actionIds": "./src/server/actionIds.js"
|
|
12
12
|
},
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@jskit-ai/assistant-core": "0.1.
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
14
|
+
"@jskit-ai/assistant-core": "0.1.149",
|
|
15
|
+
"@jskit-ai/database-runtime": "0.1.173",
|
|
16
16
|
"json-rest-schema": "^1.0.17"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/shell-web": "0.1.
|
|
19
|
+
"@jskit-ai/http-runtime": "0.1.171",
|
|
20
|
+
"@jskit-ai/http-web": "0.1.18",
|
|
21
|
+
"@jskit-ai/kernel": "0.1.173",
|
|
22
|
+
"@jskit-ai/shell-web": "0.1.177",
|
|
23
23
|
"@tanstack/vue-query": "^5.90.5",
|
|
24
24
|
"vue": "^3.5.13",
|
|
25
25
|
"vuetify": "^4.0.0"
|
|
@@ -5,14 +5,12 @@ import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/ker
|
|
|
5
5
|
import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
|
|
6
6
|
import {
|
|
7
7
|
ASSISTANT_STREAM_EVENT_TYPES,
|
|
8
|
-
MAX_HISTORY_MESSAGES,
|
|
9
8
|
MAX_INPUT_CHARS,
|
|
10
9
|
assistantConversationMessagesQueryKey,
|
|
11
10
|
assistantConversationsListQueryKey,
|
|
12
11
|
assistantScopeQueryKey,
|
|
13
12
|
normalizeAssistantStreamEventType,
|
|
14
13
|
normalizeConversationStatus as normalizeAssistantConversationStatus,
|
|
15
|
-
parseJsonObject,
|
|
16
14
|
toPositiveInteger
|
|
17
15
|
} from "@jskit-ai/assistant-core/shared";
|
|
18
16
|
import {
|
|
@@ -23,6 +21,13 @@ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
|
|
|
23
21
|
import { usePagedCollection } from "@jskit-ai/http-web/client/composables/usePagedCollection";
|
|
24
22
|
import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
|
|
25
23
|
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
24
|
+
import {
|
|
25
|
+
buildHistory,
|
|
26
|
+
buildId,
|
|
27
|
+
interruptPendingToolEvents,
|
|
28
|
+
mapTranscriptEntriesToAssistantState,
|
|
29
|
+
normalizeToolName
|
|
30
|
+
} from "../support/assistantRuntimeState.js";
|
|
26
31
|
import { insertTextAtSelection } from "../support/composerInputSupport.js";
|
|
27
32
|
import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
|
|
28
33
|
|
|
@@ -78,18 +83,6 @@ function writeStoredActiveConversationId(scope = {}, conversationId) {
|
|
|
78
83
|
}
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
function buildId(prefix = "id") {
|
|
82
|
-
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
83
|
-
return `${prefix}_${crypto.randomUUID()}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function normalizeToolName(value) {
|
|
90
|
-
return normalizeText(value) || "tool";
|
|
91
|
-
}
|
|
92
|
-
|
|
93
86
|
function normalizeConversationStatus(value) {
|
|
94
87
|
return normalizeAssistantConversationStatus(value, {
|
|
95
88
|
fallback: "unknown"
|
|
@@ -110,103 +103,6 @@ function formatConversationStartedAt(value) {
|
|
|
110
103
|
return date.toLocaleString();
|
|
111
104
|
}
|
|
112
105
|
|
|
113
|
-
function parseToolResultPayload(value) {
|
|
114
|
-
return parseJsonObject(value);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function buildHistory(messages) {
|
|
118
|
-
const normalizedHistory = (Array.isArray(messages) ? messages : [])
|
|
119
|
-
.filter((message) => {
|
|
120
|
-
if (!message || typeof message !== "object") {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
if (message.kind !== "chat") {
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
if (message.role !== "user" && message.role !== "assistant") {
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
if (normalizeText(message.status).toLowerCase() !== "done") {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
return Boolean(normalizeText(message.text));
|
|
133
|
-
})
|
|
134
|
-
.map((message) => ({
|
|
135
|
-
role: message.role,
|
|
136
|
-
content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
|
|
137
|
-
}));
|
|
138
|
-
|
|
139
|
-
return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function mapTranscriptEntriesToAssistantState(entries) {
|
|
143
|
-
const sourceEntries = Array.isArray(entries) ? entries : [];
|
|
144
|
-
const messages = [];
|
|
145
|
-
const toolEventsById = new Map();
|
|
146
|
-
|
|
147
|
-
function ensureToolEvent(toolCallId, toolName) {
|
|
148
|
-
const key = normalizeText(toolCallId) || buildId("tool_call");
|
|
149
|
-
if (toolEventsById.has(key)) {
|
|
150
|
-
return toolEventsById.get(key);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const next = {
|
|
154
|
-
id: key,
|
|
155
|
-
name: normalizeToolName(toolName),
|
|
156
|
-
arguments: "",
|
|
157
|
-
status: "pending",
|
|
158
|
-
result: null,
|
|
159
|
-
error: null
|
|
160
|
-
};
|
|
161
|
-
toolEventsById.set(key, next);
|
|
162
|
-
return next;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
for (const entry of sourceEntries) {
|
|
166
|
-
const role = normalizeText(entry?.role).toLowerCase();
|
|
167
|
-
const kind = normalizeText(entry?.kind).toLowerCase();
|
|
168
|
-
const metadata = normalizeObject(entry?.metadata);
|
|
169
|
-
const transcriptId = normalizeRecordId(entry?.id, { fallback: null });
|
|
170
|
-
const messageId = transcriptId ? `transcript_${transcriptId}` : buildId("transcript");
|
|
171
|
-
|
|
172
|
-
if (kind === "chat" && (role === "user" || role === "assistant")) {
|
|
173
|
-
messages.push({
|
|
174
|
-
id: messageId,
|
|
175
|
-
role,
|
|
176
|
-
kind: "chat",
|
|
177
|
-
text: entry?.contentText == null ? "" : String(entry.contentText),
|
|
178
|
-
status: "done"
|
|
179
|
-
});
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (kind === "tool_call") {
|
|
184
|
-
const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
|
|
185
|
-
const toolName = normalizeToolName(metadata.tool);
|
|
186
|
-
const toolEvent = ensureToolEvent(toolCallId, toolName);
|
|
187
|
-
toolEvent.arguments = String(entry?.contentText || "");
|
|
188
|
-
toolEvent.status = "pending";
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
if (kind === "tool_result") {
|
|
193
|
-
const parsedResult = parseToolResultPayload(entry?.contentText);
|
|
194
|
-
const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
|
|
195
|
-
const toolName = normalizeToolName(metadata.tool || parsedResult.tool);
|
|
196
|
-
const toolEvent = ensureToolEvent(toolCallId, toolName);
|
|
197
|
-
const failed = parsedResult.ok === false || metadata.ok === false;
|
|
198
|
-
toolEvent.status = failed ? "failed" : "done";
|
|
199
|
-
toolEvent.result = failed ? null : parsedResult.result;
|
|
200
|
-
toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return {
|
|
205
|
-
messages,
|
|
206
|
-
pendingToolEvents: [...toolEventsById.values()]
|
|
207
|
-
};
|
|
208
|
-
}
|
|
209
|
-
|
|
210
106
|
function resolveRuntimePolicy() {
|
|
211
107
|
const appConfig = getClientAppConfig();
|
|
212
108
|
const assistantConfig = normalizeObject(appConfig?.assistant);
|
|
@@ -724,6 +620,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
|
|
|
724
620
|
}
|
|
725
621
|
} finally {
|
|
726
622
|
clearTimeout(streamTimeout);
|
|
623
|
+
pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
|
|
727
624
|
abortController.value = null;
|
|
728
625
|
isStreaming.value = false;
|
|
729
626
|
await invalidateConversationScope();
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import {
|
|
3
|
+
MAX_HISTORY_MESSAGES,
|
|
4
|
+
MAX_INPUT_CHARS,
|
|
5
|
+
parseJsonObject
|
|
6
|
+
} from "@jskit-ai/assistant-core/shared";
|
|
7
|
+
import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
|
|
8
|
+
|
|
9
|
+
function buildId(prefix = "id") {
|
|
10
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
11
|
+
return `${prefix}_${crypto.randomUUID()}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeToolName(value) {
|
|
18
|
+
return normalizeText(value) || "tool";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildHistory(messages) {
|
|
22
|
+
const normalizedHistory = (Array.isArray(messages) ? messages : [])
|
|
23
|
+
.filter((message) => {
|
|
24
|
+
if (!message || typeof message !== "object") {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (message.kind !== "chat") {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
if (message.role !== "user" && message.role !== "assistant") {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (normalizeText(message.status).toLowerCase() !== "done") {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
const text = normalizeText(message.text);
|
|
37
|
+
if (!text) {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return message.role !== "assistant" || !isAssistantProgressOnlyText(text);
|
|
41
|
+
})
|
|
42
|
+
.map((message) => ({
|
|
43
|
+
role: message.role,
|
|
44
|
+
content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function interruptPendingToolEvents(toolEvents) {
|
|
51
|
+
return (Array.isArray(toolEvents) ? toolEvents : []).map((toolEvent) => {
|
|
52
|
+
if (normalizeText(toolEvent?.status).toLowerCase() !== "pending") {
|
|
53
|
+
return toolEvent;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...toolEvent,
|
|
58
|
+
status: "interrupted"
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function mapTranscriptEntriesToAssistantState(entries) {
|
|
64
|
+
const sourceEntries = Array.isArray(entries) ? entries : [];
|
|
65
|
+
const messages = [];
|
|
66
|
+
const toolEventsById = new Map();
|
|
67
|
+
|
|
68
|
+
function ensureToolEvent(toolCallId, toolName) {
|
|
69
|
+
const key = normalizeText(toolCallId) || buildId("tool_call");
|
|
70
|
+
if (toolEventsById.has(key)) {
|
|
71
|
+
return toolEventsById.get(key);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const next = {
|
|
75
|
+
id: key,
|
|
76
|
+
name: normalizeToolName(toolName),
|
|
77
|
+
arguments: "",
|
|
78
|
+
status: "pending",
|
|
79
|
+
result: null,
|
|
80
|
+
error: null
|
|
81
|
+
};
|
|
82
|
+
toolEventsById.set(key, next);
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const entry of sourceEntries) {
|
|
87
|
+
const role = normalizeText(entry?.role).toLowerCase();
|
|
88
|
+
const kind = normalizeText(entry?.kind).toLowerCase();
|
|
89
|
+
const metadata = normalizeObject(entry?.metadata);
|
|
90
|
+
const transcriptId = normalizeRecordId(entry?.id, { fallback: null });
|
|
91
|
+
const messageId = transcriptId ? `transcript_${transcriptId}` : buildId("transcript");
|
|
92
|
+
|
|
93
|
+
if (kind === "chat" && (role === "user" || role === "assistant")) {
|
|
94
|
+
const text = entry?.contentText == null ? "" : String(entry.contentText);
|
|
95
|
+
if (role === "assistant" && isAssistantProgressOnlyText(text)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
messages.push({
|
|
100
|
+
id: messageId,
|
|
101
|
+
role,
|
|
102
|
+
kind: "chat",
|
|
103
|
+
text,
|
|
104
|
+
status: "done"
|
|
105
|
+
});
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (kind === "tool_call") {
|
|
110
|
+
const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
|
|
111
|
+
const toolEvent = ensureToolEvent(toolCallId, metadata.tool);
|
|
112
|
+
toolEvent.arguments = String(entry?.contentText || "");
|
|
113
|
+
toolEvent.status = "pending";
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (kind === "tool_result") {
|
|
118
|
+
const parsedResult = parseJsonObject(entry?.contentText);
|
|
119
|
+
const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
|
|
120
|
+
const toolEvent = ensureToolEvent(toolCallId, metadata.tool || parsedResult.tool);
|
|
121
|
+
const failed = parsedResult.ok === false || metadata.ok === false;
|
|
122
|
+
toolEvent.status = failed ? "failed" : "done";
|
|
123
|
+
toolEvent.result = failed ? null : parsedResult.result;
|
|
124
|
+
toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
messages,
|
|
130
|
+
pendingToolEvents: interruptPendingToolEvents([...toolEventsById.values()])
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export {
|
|
135
|
+
buildHistory,
|
|
136
|
+
buildId,
|
|
137
|
+
interruptPendingToolEvents,
|
|
138
|
+
mapTranscriptEntriesToAssistantState,
|
|
139
|
+
normalizeToolName
|
|
140
|
+
};
|
|
@@ -5,10 +5,15 @@ import {
|
|
|
5
5
|
ASSISTANT_STREAM_EVENT_TYPES
|
|
6
6
|
} from "@jskit-ai/assistant-core/shared";
|
|
7
7
|
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
8
|
+
import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
|
|
8
9
|
|
|
9
10
|
const MAX_HISTORY_MESSAGES = 20;
|
|
10
11
|
const MAX_INPUT_CHARS = 8000;
|
|
11
|
-
const MAX_TOOL_ROUNDS =
|
|
12
|
+
const MAX_TOOL_ROUNDS = 16;
|
|
13
|
+
const MAX_RECOVERY_PASSES = 3;
|
|
14
|
+
const MAX_TOOL_RESULT_FALLBACK_CHARS = 4000;
|
|
15
|
+
const CLOCK_INSTRUCTION = "For current or relative date and time questions, first use any available authoritative workspace clock action; never infer the current date or time from model knowledge.";
|
|
16
|
+
const COMPLETION_INSTRUCTION = "Do not narrate future work or describe what you are about to do. Either call the required available tool now or provide the completed final answer.";
|
|
12
17
|
|
|
13
18
|
function normalizeConversationId(value) {
|
|
14
19
|
return normalizeRecordId(value, { fallback: null });
|
|
@@ -26,7 +31,7 @@ function normalizeHistory(history = []) {
|
|
|
26
31
|
}
|
|
27
32
|
|
|
28
33
|
const content = normalizeText(item.content).slice(0, MAX_INPUT_CHARS);
|
|
29
|
-
if (!content) {
|
|
34
|
+
if (!content || (role === "assistant" && isAssistantProgressOnlyText(content))) {
|
|
30
35
|
return null;
|
|
31
36
|
}
|
|
32
37
|
|
|
@@ -152,6 +157,7 @@ function buildSystemPrompt({ targetSurfaceId = "", toolDescriptors = [], workspa
|
|
|
152
157
|
"Use tools when they are necessary and only when available.",
|
|
153
158
|
"Do not mention tools that are not available.",
|
|
154
159
|
"When answering schema questions, rely only on tool contracts and tool results.",
|
|
160
|
+
CLOCK_INSTRUCTION,
|
|
155
161
|
workspaceLine,
|
|
156
162
|
toolSummary,
|
|
157
163
|
toolContracts
|
|
@@ -183,22 +189,17 @@ function buildRecoveryPrompt({ reason = "", toolFailures = [], toolSuccesses = [
|
|
|
183
189
|
const failureSuffix = failureSummary ? ` Recent tool failures: ${failureSummary}.` : "";
|
|
184
190
|
const successSuffix = successSummary ? ` Successful tools: ${successSummary}.` : "";
|
|
185
191
|
if (normalizedReason === "tool_failure") {
|
|
186
|
-
return `One or more tool calls may fail. Continue with available successful results. Do not output function-call markup. Do not mention failed operations unless explicitly asked
|
|
192
|
+
return `One or more tool calls may fail. Continue with available successful results. Do not output function-call markup. Do not mention failed operations unless explicitly asked. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
|
|
187
193
|
}
|
|
188
194
|
|
|
189
|
-
return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only
|
|
195
|
+
return `Tool-call rounds were exhausted. Provide the best direct answer with available context and successful results only. ${COMPLETION_INSTRUCTION}${failureSuffix}${successSuffix}`;
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
function buildRecoveryFallbackAnswer({
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
toolSuccesses
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
return "I reached the tool-call limit for this request. Please narrow the request and I will continue.";
|
|
198
|
+
function buildRecoveryFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
|
|
199
|
+
return buildToolOutcomeFallbackAnswer({
|
|
200
|
+
toolFailures,
|
|
201
|
+
toolSuccesses
|
|
202
|
+
});
|
|
202
203
|
}
|
|
203
204
|
|
|
204
205
|
function toSafeToolResultText(value) {
|
|
@@ -217,26 +218,19 @@ function toSafeToolResultText(value) {
|
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
function buildToolOutcomeFallbackAnswer({ toolFailures = [], toolSuccesses = [] } = {}) {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
.map((entry) => normalizeText(entry?.name))
|
|
223
|
-
.filter(Boolean)
|
|
224
|
-
)];
|
|
221
|
+
const successfulResults = (Array.isArray(toolSuccesses) ? toolSuccesses : [])
|
|
222
|
+
.filter((entry) => normalizeText(entry?.name));
|
|
225
223
|
const hasFailures = Array.isArray(toolFailures) && toolFailures.length > 0;
|
|
226
224
|
|
|
227
|
-
if (
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return `- ${name}:\n\`\`\`json\n${payload}\n\`\`\``;
|
|
234
|
-
});
|
|
225
|
+
if (successfulResults.length > 0) {
|
|
226
|
+
const latestSuccess = successfulResults.at(-1);
|
|
227
|
+
const answer = `Latest successful result from ${normalizeText(latestSuccess.name)}:\n${toSafeToolResultText(latestSuccess.result)}`;
|
|
228
|
+
if (answer.length <= MAX_TOOL_RESULT_FALLBACK_CHARS) {
|
|
229
|
+
return answer;
|
|
230
|
+
}
|
|
235
231
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
...summaryLines
|
|
239
|
-
].join("\n");
|
|
232
|
+
const suffix = "\n…[truncated]";
|
|
233
|
+
return `${answer.slice(0, MAX_TOOL_RESULT_FALLBACK_CHARS - suffix.length)}${suffix}`;
|
|
240
234
|
}
|
|
241
235
|
|
|
242
236
|
if (hasFailures) {
|
|
@@ -255,7 +249,8 @@ function sanitizeAssistantMessageText(value) {
|
|
|
255
249
|
const blockPatterns = [
|
|
256
250
|
/<[^>\n]*function_calls[^>\n]*>[\s\S]*?<\/[^>\n]*function_calls>/gi,
|
|
257
251
|
/<[^>\n]*tool_calls?[^>\n]*>[\s\S]*?<\/[^>\n]*tool_calls?[^>\n]*>/gi,
|
|
258
|
-
/<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi
|
|
252
|
+
/<[^>\n]*invoke\b[^>\n]*>[\s\S]*?<\/[^>\n]*invoke>/gi,
|
|
253
|
+
/<(?:analysis|reasoning|think)>[\s\S]*?<\/(?:analysis|reasoning|think)>/gi
|
|
259
254
|
];
|
|
260
255
|
for (const pattern of blockPatterns) {
|
|
261
256
|
source = source.replace(pattern, " ");
|
|
@@ -279,10 +274,10 @@ function sanitizeAssistantMessageText(value) {
|
|
|
279
274
|
.join("\n");
|
|
280
275
|
}
|
|
281
276
|
|
|
282
|
-
function buildAssistantToolCallMessage(
|
|
277
|
+
function buildAssistantToolCallMessage(toolCalls = []) {
|
|
283
278
|
return {
|
|
284
279
|
role: "assistant",
|
|
285
|
-
content:
|
|
280
|
+
content: "",
|
|
286
281
|
tool_calls: toolCalls.map((toolCall) => ({
|
|
287
282
|
id: toolCall.id,
|
|
288
283
|
type: "function",
|
|
@@ -336,96 +331,8 @@ function parseDsmlToolCallsFromText(value = "") {
|
|
|
336
331
|
return calls;
|
|
337
332
|
}
|
|
338
333
|
|
|
339
|
-
function
|
|
340
|
-
let inTag = false;
|
|
341
|
-
let tagBuffer = "";
|
|
342
|
-
let suppressedDepth = 0;
|
|
343
|
-
|
|
344
|
-
function resolveTagType(rawTag = "") {
|
|
345
|
-
const normalizedTag = String(rawTag || "").toLowerCase();
|
|
346
|
-
if (normalizedTag.includes("function_calls")) {
|
|
347
|
-
return "function_calls";
|
|
348
|
-
}
|
|
349
|
-
if (normalizedTag.includes("tool_calls")) {
|
|
350
|
-
return "tool_calls";
|
|
351
|
-
}
|
|
352
|
-
if (normalizedTag.includes("invoke")) {
|
|
353
|
-
return "invoke";
|
|
354
|
-
}
|
|
355
|
-
return "";
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function processTag(rawTag = "") {
|
|
359
|
-
const source = String(rawTag || "");
|
|
360
|
-
const inner = source.slice(1, -1).trim();
|
|
361
|
-
const isClosing = inner.startsWith("/");
|
|
362
|
-
const isSelfClosing = inner.endsWith("/");
|
|
363
|
-
const tagType = resolveTagType(inner);
|
|
364
|
-
|
|
365
|
-
if (suppressedDepth > 0) {
|
|
366
|
-
if (tagType && isClosing) {
|
|
367
|
-
suppressedDepth = Math.max(0, suppressedDepth - 1);
|
|
368
|
-
} else if (tagType && !isClosing && !isSelfClosing) {
|
|
369
|
-
suppressedDepth += 1;
|
|
370
|
-
}
|
|
371
|
-
return "";
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (!tagType) {
|
|
375
|
-
return source;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
if (!isClosing && !isSelfClosing) {
|
|
379
|
-
suppressedDepth = 1;
|
|
380
|
-
}
|
|
381
|
-
return "";
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
function process(delta = "") {
|
|
385
|
-
const source = String(delta || "");
|
|
386
|
-
if (!source) {
|
|
387
|
-
return "";
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
let output = "";
|
|
391
|
-
for (const char of source) {
|
|
392
|
-
if (inTag) {
|
|
393
|
-
tagBuffer += char;
|
|
394
|
-
if (char === ">") {
|
|
395
|
-
inTag = false;
|
|
396
|
-
output += processTag(tagBuffer);
|
|
397
|
-
tagBuffer = "";
|
|
398
|
-
}
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (char === "<") {
|
|
403
|
-
inTag = true;
|
|
404
|
-
tagBuffer = "<";
|
|
405
|
-
continue;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (suppressedDepth < 1) {
|
|
409
|
-
output += char;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
return output;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function flush() {
|
|
417
|
-
return "";
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return Object.freeze({
|
|
421
|
-
process,
|
|
422
|
-
flush
|
|
423
|
-
});
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true, deltaSanitizer = null } = {}) {
|
|
334
|
+
async function consumeCompletionStream(stream) {
|
|
427
335
|
let assistantText = "";
|
|
428
|
-
let streamedAssistantText = "";
|
|
429
336
|
const toolCallsByIndex = new Map();
|
|
430
337
|
|
|
431
338
|
for await (const chunk of stream) {
|
|
@@ -435,19 +342,6 @@ async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true
|
|
|
435
342
|
const textDelta = extractTextDelta(delta.content);
|
|
436
343
|
if (textDelta) {
|
|
437
344
|
assistantText += textDelta;
|
|
438
|
-
if (emitDeltas) {
|
|
439
|
-
const safeDelta =
|
|
440
|
-
deltaSanitizer && typeof deltaSanitizer.process === "function"
|
|
441
|
-
? String(deltaSanitizer.process(textDelta) || "")
|
|
442
|
-
: textDelta;
|
|
443
|
-
if (safeDelta) {
|
|
444
|
-
streamedAssistantText += safeDelta;
|
|
445
|
-
streamWriter.sendAssistantDelta({
|
|
446
|
-
type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
|
|
447
|
-
delta: safeDelta
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
345
|
}
|
|
452
346
|
|
|
453
347
|
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
@@ -489,47 +383,12 @@ async function consumeCompletionStream({ stream, streamWriter, emitDeltas = true
|
|
|
489
383
|
}
|
|
490
384
|
}
|
|
491
385
|
|
|
492
|
-
if (emitDeltas && deltaSanitizer && typeof deltaSanitizer.flush === "function") {
|
|
493
|
-
const trailing = String(deltaSanitizer.flush() || "");
|
|
494
|
-
if (trailing) {
|
|
495
|
-
streamedAssistantText += trailing;
|
|
496
|
-
streamWriter.sendAssistantDelta({
|
|
497
|
-
type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA,
|
|
498
|
-
delta: trailing
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
|
|
503
386
|
return {
|
|
504
387
|
assistantText,
|
|
505
|
-
streamedAssistantText,
|
|
506
388
|
toolCalls
|
|
507
389
|
};
|
|
508
390
|
}
|
|
509
391
|
|
|
510
|
-
function mergeAssistantMessageText(streamedText = "", completionText = "") {
|
|
511
|
-
const streamed = normalizeText(sanitizeAssistantMessageText(streamedText));
|
|
512
|
-
const completion = normalizeText(sanitizeAssistantMessageText(completionText));
|
|
513
|
-
|
|
514
|
-
if (!streamed) {
|
|
515
|
-
return completion;
|
|
516
|
-
}
|
|
517
|
-
if (!completion) {
|
|
518
|
-
return streamed;
|
|
519
|
-
}
|
|
520
|
-
if (streamed === completion) {
|
|
521
|
-
return streamed;
|
|
522
|
-
}
|
|
523
|
-
if (completion.startsWith(streamed) || completion.includes(streamed)) {
|
|
524
|
-
return completion;
|
|
525
|
-
}
|
|
526
|
-
if (streamed.startsWith(completion) || streamed.includes(completion)) {
|
|
527
|
-
return streamed;
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
return `${streamed}\n${completion}`;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
392
|
function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
|
|
534
393
|
const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
|
|
535
394
|
if (assistantSurface) {
|
|
@@ -664,10 +523,9 @@ function createChatService({
|
|
|
664
523
|
content: source.input
|
|
665
524
|
}
|
|
666
525
|
];
|
|
667
|
-
let streamedAssistantText = "";
|
|
668
526
|
|
|
669
527
|
async function completeWithAssistantMessage(assistantMessageText, { metadata = {} } = {}) {
|
|
670
|
-
const normalizedAssistantMessageText =
|
|
528
|
+
const normalizedAssistantMessageText = normalizeText(sanitizeAssistantMessageText(assistantMessageText));
|
|
671
529
|
if (!normalizedAssistantMessageText) {
|
|
672
530
|
throw new AppError(502, "Assistant returned no output.");
|
|
673
531
|
}
|
|
@@ -820,7 +678,6 @@ function createChatService({
|
|
|
820
678
|
}
|
|
821
679
|
|
|
822
680
|
async function recoverWithoutTools({ reason = "", toolFailures = [], toolSuccesses = [] } = {}) {
|
|
823
|
-
const MAX_RECOVERY_PASSES = 3;
|
|
824
681
|
for (let pass = 0; pass < MAX_RECOVERY_PASSES; pass += 1) {
|
|
825
682
|
const recoveryMessages = [
|
|
826
683
|
...messages,
|
|
@@ -839,31 +696,15 @@ function createChatService({
|
|
|
839
696
|
tools: [],
|
|
840
697
|
signal: options.abortSignal
|
|
841
698
|
});
|
|
842
|
-
const completion = await consumeCompletionStream(
|
|
843
|
-
stream: completionStream,
|
|
844
|
-
streamWriter,
|
|
845
|
-
emitDeltas: true,
|
|
846
|
-
deltaSanitizer: createDsmlDeltaSanitizer()
|
|
847
|
-
});
|
|
848
|
-
streamedAssistantText += String(completion.streamedAssistantText || "");
|
|
699
|
+
const completion = await consumeCompletionStream(completionStream);
|
|
849
700
|
|
|
850
701
|
const recoveryToolCalls = completion.toolCalls.filter((entry) => entry.name);
|
|
851
702
|
if (recoveryToolCalls.length > 0) {
|
|
852
|
-
messages.push(
|
|
853
|
-
buildAssistantToolCallMessage({
|
|
854
|
-
assistantText: completion.assistantText,
|
|
855
|
-
toolCalls: recoveryToolCalls
|
|
856
|
-
})
|
|
857
|
-
);
|
|
858
|
-
await executeToolCalls(recoveryToolCalls, {
|
|
859
|
-
toolFailures,
|
|
860
|
-
toolSuccesses
|
|
861
|
-
});
|
|
862
703
|
continue;
|
|
863
704
|
}
|
|
864
705
|
|
|
865
706
|
const assistantMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
|
|
866
|
-
if (assistantMessageText) {
|
|
707
|
+
if (assistantMessageText && !isAssistantProgressOnlyText(assistantMessageText)) {
|
|
867
708
|
return completeWithAssistantMessage(assistantMessageText, {
|
|
868
709
|
metadata: {
|
|
869
710
|
recoveryReason: reason || "unknown",
|
|
@@ -914,18 +755,12 @@ function createChatService({
|
|
|
914
755
|
signal: options.abortSignal
|
|
915
756
|
});
|
|
916
757
|
|
|
917
|
-
const completion = await consumeCompletionStream(
|
|
918
|
-
stream: completionStream,
|
|
919
|
-
streamWriter,
|
|
920
|
-
emitDeltas: true,
|
|
921
|
-
deltaSanitizer: createDsmlDeltaSanitizer()
|
|
922
|
-
});
|
|
923
|
-
streamedAssistantText += String(completion.streamedAssistantText || "");
|
|
758
|
+
const completion = await consumeCompletionStream(completionStream);
|
|
924
759
|
|
|
925
760
|
const toolCalls = completion.toolCalls.filter((entry) => entry.name);
|
|
926
761
|
if (toolCalls.length < 1) {
|
|
927
762
|
const finalMessageText = normalizeText(sanitizeAssistantMessageText(completion.assistantText));
|
|
928
|
-
if (finalMessageText) {
|
|
763
|
+
if (finalMessageText && !isAssistantProgressOnlyText(finalMessageText)) {
|
|
929
764
|
return completeWithAssistantMessage(finalMessageText, {
|
|
930
765
|
metadata: toolFailures.length > 0
|
|
931
766
|
? {
|
|
@@ -936,23 +771,14 @@ function createChatService({
|
|
|
936
771
|
});
|
|
937
772
|
}
|
|
938
773
|
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
return completeWithAssistantMessage(completion.assistantText);
|
|
774
|
+
messages.push({
|
|
775
|
+
role: "system",
|
|
776
|
+
content: COMPLETION_INSTRUCTION
|
|
777
|
+
});
|
|
778
|
+
continue;
|
|
948
779
|
}
|
|
949
780
|
|
|
950
|
-
messages.push(
|
|
951
|
-
buildAssistantToolCallMessage({
|
|
952
|
-
assistantText: completion.assistantText,
|
|
953
|
-
toolCalls
|
|
954
|
-
})
|
|
955
|
-
);
|
|
781
|
+
messages.push(buildAssistantToolCallMessage(toolCalls));
|
|
956
782
|
|
|
957
783
|
const roundFailures = await executeToolCalls(toolCalls, {
|
|
958
784
|
toolFailures,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const MAX_PROGRESS_ONLY_TEXT_CHARS = 600;
|
|
2
|
+
|
|
3
|
+
const PROGRESS_SENTENCE_PATTERNS = Object.freeze([
|
|
4
|
+
/^(?:let me|i(?:'|’)ll|i will|i(?:'|’)m going to|i am going to)\s+(?:(?:first|now|quickly)\s+)*(?:analy[sz]e|call|check|confirm|do|execute|fetch|find|inspect|investigate|load|look up|open|prepare|query|read|retrieve|review|run|search|summarize|test|try|use|verify)\b/iu,
|
|
5
|
+
/^(?:analy[sz]ing|calling|checking|confirming|executing|fetching|finding|inspecting|investigating|loading|looking up|opening|preparing|querying|reading|retrieving|reviewing|running|searching|summarizing|testing|trying|using|verifying)\b/iu,
|
|
6
|
+
/^(?:one moment|please wait)\b/iu
|
|
7
|
+
]);
|
|
8
|
+
|
|
9
|
+
function isAssistantProgressOnlyText(value) {
|
|
10
|
+
const text = String(value || "")
|
|
11
|
+
.replace(/\s+/gu, " ")
|
|
12
|
+
.trim()
|
|
13
|
+
.replace(/^(?:okay|sure)[,;:!\s—-]+/iu, "");
|
|
14
|
+
|
|
15
|
+
if (!text || text.length > MAX_PROGRESS_ONLY_TEXT_CHARS) {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const sentences = text
|
|
20
|
+
.split(/(?<=[.!?…])\s+/u)
|
|
21
|
+
.map((sentence) => sentence.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
|
|
24
|
+
return sentences.length > 0 && sentences.every((sentence) =>
|
|
25
|
+
PROGRESS_SENTENCE_PATTERNS.some((pattern) => pattern.test(sentence))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { isAssistantProgressOnlyText };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
buildHistory,
|
|
5
|
+
interruptPendingToolEvents,
|
|
6
|
+
mapTranscriptEntriesToAssistantState
|
|
7
|
+
} from "../src/client/support/assistantRuntimeState.js";
|
|
8
|
+
|
|
9
|
+
test("restored progress narration is neither rendered nor replayed", () => {
|
|
10
|
+
const restored = mapTranscriptEntriesToAssistantState([
|
|
11
|
+
{
|
|
12
|
+
id: "1",
|
|
13
|
+
role: "user",
|
|
14
|
+
kind: "chat",
|
|
15
|
+
contentText: "Find the booking."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "2",
|
|
19
|
+
role: "assistant",
|
|
20
|
+
kind: "chat",
|
|
21
|
+
contentText: "Let me query the bookings."
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
id: "3",
|
|
25
|
+
role: "assistant",
|
|
26
|
+
kind: "chat",
|
|
27
|
+
contentText: "The booking is confirmed."
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "4",
|
|
31
|
+
role: "assistant",
|
|
32
|
+
kind: "chat",
|
|
33
|
+
contentText: "Let me check. The second booking is also confirmed."
|
|
34
|
+
}
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
restored.messages.map((message) => message.text),
|
|
39
|
+
[
|
|
40
|
+
"Find the booking.",
|
|
41
|
+
"The booking is confirmed.",
|
|
42
|
+
"Let me check. The second booking is also confirmed."
|
|
43
|
+
]
|
|
44
|
+
);
|
|
45
|
+
assert.deepEqual(buildHistory(restored.messages), [
|
|
46
|
+
{
|
|
47
|
+
role: "user",
|
|
48
|
+
content: "Find the booking."
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
role: "assistant",
|
|
52
|
+
content: "The booking is confirmed."
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
role: "assistant",
|
|
56
|
+
content: "Let me check. The second booking is also confirmed."
|
|
57
|
+
}
|
|
58
|
+
]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("restored orphaned tool calls are marked interrupted", () => {
|
|
62
|
+
const restored = mapTranscriptEntriesToAssistantState([
|
|
63
|
+
{
|
|
64
|
+
id: "1",
|
|
65
|
+
role: "assistant",
|
|
66
|
+
kind: "tool_call",
|
|
67
|
+
contentText: "{}",
|
|
68
|
+
metadata: {
|
|
69
|
+
toolCallId: "orphaned",
|
|
70
|
+
tool: "action_search"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "2",
|
|
75
|
+
role: "assistant",
|
|
76
|
+
kind: "tool_call",
|
|
77
|
+
contentText: "{}",
|
|
78
|
+
metadata: {
|
|
79
|
+
toolCallId: "completed",
|
|
80
|
+
tool: "action_execute"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: "3",
|
|
85
|
+
role: "assistant",
|
|
86
|
+
kind: "tool_result",
|
|
87
|
+
contentText: JSON.stringify({
|
|
88
|
+
ok: true,
|
|
89
|
+
result: {
|
|
90
|
+
id: "41"
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
metadata: {
|
|
94
|
+
toolCallId: "completed",
|
|
95
|
+
tool: "action_execute",
|
|
96
|
+
ok: true
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
]);
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
restored.pendingToolEvents.map((event) => ({ id: event.id, status: event.status })),
|
|
103
|
+
[
|
|
104
|
+
{ id: "orphaned", status: "interrupted" },
|
|
105
|
+
{ id: "completed", status: "done" }
|
|
106
|
+
]
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("live stream cleanup interrupts only tool events that remain pending", () => {
|
|
111
|
+
const finalized = interruptPendingToolEvents([
|
|
112
|
+
{
|
|
113
|
+
id: "pending",
|
|
114
|
+
status: "pending"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
id: "done",
|
|
118
|
+
status: "done"
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "failed",
|
|
122
|
+
status: "failed"
|
|
123
|
+
}
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
finalized.map((event) => ({ id: event.id, status: event.status })),
|
|
128
|
+
[
|
|
129
|
+
{ id: "pending", status: "interrupted" },
|
|
130
|
+
{ id: "done", status: "done" },
|
|
131
|
+
{ id: "failed", status: "failed" }
|
|
132
|
+
]
|
|
133
|
+
);
|
|
134
|
+
});
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { createChatService } from "../src/server/services/chatService.js";
|
|
4
|
+
|
|
5
|
+
const APP_CONFIG = Object.freeze({
|
|
6
|
+
surfaceDefinitions: {
|
|
7
|
+
assistant: {
|
|
8
|
+
id: "assistant",
|
|
9
|
+
enabled: true,
|
|
10
|
+
requiresWorkspace: false,
|
|
11
|
+
accessPolicyId: "public"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
assistantSurfaces: {
|
|
15
|
+
assistant: {
|
|
16
|
+
settingsSurfaceId: "assistant",
|
|
17
|
+
configScope: "global"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
function textCompletion(text) {
|
|
23
|
+
return { text };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toolCompletion(name, sequence, { text = "" } = {}) {
|
|
27
|
+
return {
|
|
28
|
+
text,
|
|
29
|
+
toolCall: {
|
|
30
|
+
id: `tool_call_${sequence}`,
|
|
31
|
+
name,
|
|
32
|
+
arguments: JSON.stringify({ sequence })
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function* completionStream(completion = {}) {
|
|
38
|
+
if (completion.text) {
|
|
39
|
+
yield {
|
|
40
|
+
choices: [
|
|
41
|
+
{
|
|
42
|
+
delta: {
|
|
43
|
+
content: completion.text
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (completion.toolCall) {
|
|
51
|
+
yield {
|
|
52
|
+
choices: [
|
|
53
|
+
{
|
|
54
|
+
delta: {
|
|
55
|
+
tool_calls: [
|
|
56
|
+
{
|
|
57
|
+
index: 0,
|
|
58
|
+
id: completion.toolCall.id,
|
|
59
|
+
function: {
|
|
60
|
+
name: completion.toolCall.name,
|
|
61
|
+
arguments: completion.toolCall.arguments
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
]
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createHarness(completions, { executeToolCall = null } = {}) {
|
|
73
|
+
const pendingCompletions = [...completions];
|
|
74
|
+
const completionRequests = [];
|
|
75
|
+
const transcriptMessages = [];
|
|
76
|
+
const completedConversations = [];
|
|
77
|
+
const executedTools = [];
|
|
78
|
+
const streamEvents = [];
|
|
79
|
+
const tools = ["action_search", "action_contract", "action_execute"].map((name) => ({
|
|
80
|
+
name,
|
|
81
|
+
parameters: {
|
|
82
|
+
type: "object"
|
|
83
|
+
},
|
|
84
|
+
outputSchema: {
|
|
85
|
+
type: "object"
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
const chatService = createChatService({
|
|
90
|
+
aiClientFactory: {
|
|
91
|
+
resolveClient() {
|
|
92
|
+
return {
|
|
93
|
+
enabled: true,
|
|
94
|
+
provider: "test",
|
|
95
|
+
defaultModel: "test-model",
|
|
96
|
+
async createChatCompletionStream(request) {
|
|
97
|
+
completionRequests.push({
|
|
98
|
+
messages: structuredClone(request.messages),
|
|
99
|
+
tools: structuredClone(request.tools)
|
|
100
|
+
});
|
|
101
|
+
const completion = pendingCompletions.shift();
|
|
102
|
+
assert.ok(completion, "Expected a queued assistant completion.");
|
|
103
|
+
return completionStream(completion);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
transcriptService: {
|
|
109
|
+
async createConversationForTurn() {
|
|
110
|
+
return {
|
|
111
|
+
conversation: {
|
|
112
|
+
id: "conversation_1"
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
async appendMessage(_surface, _conversationId, message) {
|
|
117
|
+
transcriptMessages.push(structuredClone(message));
|
|
118
|
+
},
|
|
119
|
+
async completeConversation(_surface, _conversationId, completion) {
|
|
120
|
+
completedConversations.push(structuredClone(completion));
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
serviceToolCatalog: {
|
|
124
|
+
resolveToolSet() {
|
|
125
|
+
return { tools };
|
|
126
|
+
},
|
|
127
|
+
toOpenAiToolSchema(tool) {
|
|
128
|
+
return {
|
|
129
|
+
type: "function",
|
|
130
|
+
function: {
|
|
131
|
+
name: tool.name,
|
|
132
|
+
parameters: tool.parameters
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
async executeToolCall(request) {
|
|
137
|
+
executedTools.push(structuredClone(request));
|
|
138
|
+
if (typeof executeToolCall === "function") {
|
|
139
|
+
return executeToolCall(request, executedTools.length);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
result: {
|
|
144
|
+
sequence: executedTools.length,
|
|
145
|
+
tool: request.toolName
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
assistantConfigService: {
|
|
151
|
+
async resolveSystemPrompt() {
|
|
152
|
+
return "";
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
appConfig: APP_CONFIG
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const streamWriter = {};
|
|
159
|
+
for (const method of [
|
|
160
|
+
"sendMeta",
|
|
161
|
+
"sendAssistantDelta",
|
|
162
|
+
"sendAssistantMessage",
|
|
163
|
+
"sendToolCall",
|
|
164
|
+
"sendToolResult",
|
|
165
|
+
"sendError",
|
|
166
|
+
"sendDone"
|
|
167
|
+
]) {
|
|
168
|
+
streamWriter[method] = (payload) => {
|
|
169
|
+
streamEvents.push({ method, payload: structuredClone(payload) });
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function run(input = "Help me") {
|
|
174
|
+
return chatService.streamChat(
|
|
175
|
+
{
|
|
176
|
+
targetSurfaceId: "assistant",
|
|
177
|
+
messageId: "message_1",
|
|
178
|
+
input,
|
|
179
|
+
history: []
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
context: {
|
|
183
|
+
actor: {
|
|
184
|
+
id: "user_1"
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
streamWriter
|
|
188
|
+
}
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
completedConversations,
|
|
194
|
+
completionRequests,
|
|
195
|
+
executedTools,
|
|
196
|
+
run,
|
|
197
|
+
streamEvents,
|
|
198
|
+
transcriptMessages
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assistantMessages(events) {
|
|
203
|
+
return events
|
|
204
|
+
.filter((event) => event.method === "sendAssistantMessage")
|
|
205
|
+
.map((event) => event.payload.text);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
test("progress-only output is retried silently and current-time prompts require a workspace clock", async () => {
|
|
209
|
+
const harness = createHarness([
|
|
210
|
+
textCompletion("Let me query the current time."),
|
|
211
|
+
textCompletion("<think>This must remain private.</think>\nIt is Tuesday in the workspace timezone.")
|
|
212
|
+
]);
|
|
213
|
+
|
|
214
|
+
const result = await harness.run("What day is it today?");
|
|
215
|
+
|
|
216
|
+
assert.equal(result.status, "completed");
|
|
217
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["It is Tuesday in the workspace timezone."]);
|
|
218
|
+
assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
|
|
219
|
+
assert.match(
|
|
220
|
+
harness.completionRequests[0].messages[0].content,
|
|
221
|
+
/first use any available authoritative workspace clock action/u
|
|
222
|
+
);
|
|
223
|
+
assert.equal(
|
|
224
|
+
harness.completionRequests[1].messages.some((message) => message.content === "Let me query the current time."),
|
|
225
|
+
false
|
|
226
|
+
);
|
|
227
|
+
assert.equal(
|
|
228
|
+
harness.completionRequests[1].messages.some((message) => /Either call the required available tool now/u.test(message.content)),
|
|
229
|
+
true
|
|
230
|
+
);
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
harness.transcriptMessages
|
|
233
|
+
.filter((message) => message.kind === "chat")
|
|
234
|
+
.map((message) => message.contentText),
|
|
235
|
+
["What day is it today?", "It is Tuesday in the workspace timezone."]
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("native search, contract, and execution workflows can exceed four silent tool rounds", async () => {
|
|
240
|
+
const harness = createHarness([
|
|
241
|
+
toolCompletion("action_search", 1, { text: "I'll search first." }),
|
|
242
|
+
toolCompletion("action_contract", 2, { text: "Let me inspect that contract." }),
|
|
243
|
+
toolCompletion("action_execute", 3, { text: "I'll execute it now." }),
|
|
244
|
+
toolCompletion("action_contract", 4),
|
|
245
|
+
toolCompletion("action_execute", 5),
|
|
246
|
+
textCompletion("The requested operation completed successfully.")
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
await harness.run();
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(
|
|
252
|
+
harness.executedTools.map((request) => request.toolName),
|
|
253
|
+
["action_search", "action_contract", "action_execute", "action_contract", "action_execute"]
|
|
254
|
+
);
|
|
255
|
+
assert.deepEqual(assistantMessages(harness.streamEvents), ["The requested operation completed successfully."]);
|
|
256
|
+
assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
|
|
257
|
+
const assistantToolMessages = harness.completionRequests
|
|
258
|
+
.flatMap((request) => request.messages)
|
|
259
|
+
.filter((message) => Array.isArray(message.tool_calls));
|
|
260
|
+
assert.ok(assistantToolMessages.length > 0);
|
|
261
|
+
assert.equal(assistantToolMessages.every((message) => message.content === ""), true);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("tool-loop exhaustion returns the latest successful result with a hard output cap", async () => {
|
|
265
|
+
const mainRounds = Array.from({ length: 16 }, (_, index) =>
|
|
266
|
+
toolCompletion("action_execute", index + 1)
|
|
267
|
+
);
|
|
268
|
+
const harness = createHarness(
|
|
269
|
+
[
|
|
270
|
+
...mainRounds,
|
|
271
|
+
textCompletion("Let me prepare the answer."),
|
|
272
|
+
textCompletion("I'll summarize the result."),
|
|
273
|
+
textCompletion("Checking the final output.")
|
|
274
|
+
],
|
|
275
|
+
{
|
|
276
|
+
executeToolCall(_request, sequence) {
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
result: {
|
|
280
|
+
sequence,
|
|
281
|
+
payload: sequence === 16 ? "x".repeat(10_000) : "ok"
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
await harness.run();
|
|
289
|
+
|
|
290
|
+
const finalMessages = assistantMessages(harness.streamEvents);
|
|
291
|
+
assert.equal(harness.executedTools.length, 16);
|
|
292
|
+
assert.equal(harness.completionRequests.length, 19);
|
|
293
|
+
assert.equal(finalMessages.length, 1);
|
|
294
|
+
assert.ok(finalMessages[0].length <= 4000);
|
|
295
|
+
assert.match(finalMessages[0], /Latest successful result from action_execute/u);
|
|
296
|
+
assert.match(finalMessages[0], /"sequence": 16/u);
|
|
297
|
+
assert.match(finalMessages[0], /…\[truncated\]$/u);
|
|
298
|
+
assert.doesNotMatch(finalMessages[0], /Please narrow the request/u);
|
|
299
|
+
assert.equal(harness.streamEvents.some((event) => event.method === "sendAssistantDelta"), false);
|
|
300
|
+
});
|