@jskit-ai/assistant-core 0.2.7 → 0.2.8

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.
Files changed (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +13 -10
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -1,29 +1,96 @@
1
+ import { defaultSettingsMiddleware, extractReasoningMiddleware, wrapLanguageModel } from "ai";
2
+ import {
3
+ LEAKED_MARKUP, REASONING, checkTexts, clipOldResult, compactMessages, createTool, remindOnRepeat, reportsActions, runLoop,
4
+ stoppedShort, stripNotes, toolRecord, unsupportedClaims, windowLimits, withinBudget, withoutClosingWords, withoutMarkup
5
+ } from "@jskit-ai/agent/server";
1
6
  import { conversationRequestText } from "../continuity.js";
2
7
  import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
3
- import { consumeCompletionStream, sanitizeAssistantMessageText } from "../../lib/assistantCompletion.js";
4
- import { runAssistantToolLoop } from "../../lib/assistantToolLoop.js";
5
- import { isAssistantProgressOnlyText } from "../../../shared/assistantResponseText.js";
6
8
 
7
- // Preserve the original assistant service's model-facing action result. The
8
- // durable receipt keeps its full ok/result/error envelope in common storage.
9
- const toolResultContent = result => JSON.stringify(result.ok ? result.result ?? null : { error: result.error });
9
+ // The agent engine's account of a reply that stopped early, and its checks, speak for a
10
+ // generic assistant in an app.
11
+ const NAME = "the assistant";
12
+ const APP = "the app";
13
+ const CHECKS = checkTexts(APP);
14
+ // The engine's step limit for work with app tools; a reply without tools is one step anyway.
15
+ const MAX_STEPS = 40;
16
+ // The engine's sizes are for a window of a million tokens (WINDOW_TOKENS). Many catalogue
17
+ // models (DeepSeek's chat models, Groq's, GPT-4o) have 128k: sized for 1M, their requests
18
+ // would fail long before compaction began, while sized for 128k a larger window only
19
+ // compacts sooner. The former character cap was about 64k tokens of history.
20
+ const CONTEXT_TOKENS = 128_000;
21
+ const OUTPUT_LIMIT = "The assistant exceeded the configured response limit.";
22
+ const PROGRESS_LIMIT = "Application tool progress text exceeds the configured API limit.";
10
23
 
11
- /** Direct API inference uses the same authorized connection catalogue as other JSKIT services. */
24
+ // The model sees an action's result, or its error. The durable receipt keeps
25
+ // its full ok/result/error envelope in common storage.
26
+ const toolOutput = result => result.ok ? result.result ?? null : { error: result.error };
27
+
28
+ /** A turn whose caller follows no phase events. */
29
+ function ignoreEvent() {}
30
+
31
+ // The engine's state note: what the system knows for certain this turn. It carries
32
+ // the time, so no turn needs an extra action to learn it.
33
+ function stateNote() {
34
+ const now = new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "short", timeZone: "UTC" }).format(new Date());
35
+ return ["Current state, from the system. It is the truth; earlier messages may be out of date:",
36
+ `- The time: ${now} UTC. The person may be in another time zone.`].join("\n");
37
+ }
38
+
39
+ const content = message => message.content?.length
40
+ ? [...(message.text ? [{ type: "text", text: message.text }] : []), ...message.content] : message.text;
41
+
42
+ // A recorded action's receipt id is the provider's call id under its step (`reply-N:`). The
43
+ // model reads the provider's own id, as it does within a reply: providers restrict their ids
44
+ // (Anthropic's take only letters, digits, "_" and "-"; Mistral's are nine characters).
45
+ const providerCallId = id => id.replace(/^reply-\d+:/u, "");
46
+
47
+ // A saved turn as model messages: the authored request, its recorded application
48
+ // actions with their results (long old results cut, as the engine reads them), the answer.
49
+ function turnMessages(turn) {
50
+ return [
51
+ ...(turn.user ? [{ role: "user", content: content({ ...turn.user, text: conversationRequestText(turn.user) }) }] : []),
52
+ ...(turn.system ? [{ role: "user", content: content({ ...turn.system, text: conversationRequestText({ ...turn.system, origin: "application" }) }) }] : []),
53
+ ...(turn.metadata?.applicationTools || []).filter(call => call.result).flatMap(call => [
54
+ { role: "assistant", content: [{ type: "tool-call", toolCallId: providerCallId(call.id), toolName: call.name,
55
+ input: call.arguments ? JSON.parse(call.arguments) : {} }] },
56
+ clipOldResult({ role: "tool", content: [{ type: "tool-result", toolCallId: providerCallId(call.id), toolName: call.name,
57
+ output: { type: "json", value: toolOutput(call.result) } }] })
58
+ ]),
59
+ ...(turn.assistant ? [{ role: "assistant", content: turn.assistant.text }] : [])
60
+ ];
61
+ }
62
+
63
+ /**
64
+ * Direct API inference runs on the agent engine with the same authorized connection catalogue as other JSKIT services.
65
+ * The application passes the conversation runtime its authorized AI connection resolver, or its own API client factory.
66
+ * @param {object} dependencies
67
+ * @param {{ resolve(request: { context: object, integrationId: string }): Promise<object> }} [dependencies.connections]
68
+ * @param {{ resolveClient(request: { context: object, configuration: object }): Promise<{ enabled: boolean, model: object, defaultModel: string }> }} [dependencies.apiClientFactory]
69
+ * @param {typeof fetch} [dependencies.fetch]
70
+ * @param {Record<string, number>} [dependencies.limits]
71
+ */
12
72
  export function createApiConversationDriver({ connections, apiClientFactory, fetch, limits = {} }) {
13
- if (apiClientFactory !== undefined && typeof apiClientFactory?.resolveClient !== "function") {
73
+ // readable-check: allow method-probe - input validation: the API client factory an application passes the conversation runtime
74
+ if (apiClientFactory !== undefined && (!apiClientFactory || typeof apiClientFactory.resolveClient !== "function")) {
14
75
  throw new TypeError("An API client factory requires resolveClient({ context, configuration }).");
15
76
  }
16
- if (!apiClientFactory && typeof connections?.resolve !== "function") throw new TypeError("API conversations require an authorized AI connection resolver.");
77
+ // readable-check: allow method-probe - input validation: the AI connection resolver an application passes the conversation runtime
78
+ if (!apiClientFactory && (!connections || typeof connections.resolve !== "function")) {
79
+ throw new TypeError("API conversations require an authorized AI connection resolver.");
80
+ }
17
81
  const maximumOutput = limits.maxOutputCharacters ?? 64_000;
18
- const maximumHistory = limits.maxHistoryCharacters ?? 256_000;
19
82
  const maximumToolProgress = limits.maxApiToolProgressCharacters;
20
- const preserveWhitespace = apiClientFactory?.preserveWhitespace !== false;
21
- for (const limit of [maximumOutput, maximumHistory]) {
83
+ // The runtime ends a turn with an error past its application action-call limit (32 by
84
+ // default), so the step limit stays within it and a long turn ends with the model's account.
85
+ const maximumSteps = limits.maxSteps ?? Math.min(MAX_STEPS, limits.maxToolCalls ?? 32);
86
+ const contextTokens = limits.contextTokens ?? CONTEXT_TOKENS;
87
+ for (const limit of [maximumOutput, maximumSteps, contextTokens]) {
22
88
  if (!Number.isSafeInteger(limit) || limit < 1) throw new TypeError("Invalid API conversation limit.");
23
89
  }
24
90
  if (maximumToolProgress !== undefined && (!Number.isSafeInteger(maximumToolProgress) || maximumToolProgress < 1)) {
25
91
  throw new TypeError("Invalid API application tool progress limit.");
26
92
  }
93
+ const window = windowLimits(contextTokens);
27
94
  const driver = Object.freeze({
28
95
  admissionBeforeDispatch: true,
29
96
  attachmentTypes: ["text", "image", "file"],
@@ -34,110 +101,204 @@ export function createApiConversationDriver({ connections, apiClientFactory, fet
34
101
  validateConversationConfiguration(configuration, { engine: "API", connections, apiClientFactory, connectionRequired: true,
35
102
  efforts: ["none", "minimal", "low", "medium", "high", "xhigh"] });
36
103
  },
37
- async run({ configuration, context, input, history, requestHistory, continuity, tools, signal, accept, beforeDispatch, onMessage }) {
104
+ // `summary` is the stored summary of the turns before `history`; `saveSummary`
105
+ // stores a new one, so the next turn reads only the turns after it.
106
+ async run({ configuration, context, input, history, requestHistory, continuity, tools, signal, accept, beforeDispatch,
107
+ onMessage, onEvent = ignoreEvent, summary = "", saveSummary }) {
38
108
  const { createAiConnectionClient } = await import("../../lib/aiConnectionClient.js");
39
- const content = message => message.content?.length
40
- ? [...(message.text ? [{ type: "text", text: message.text }] : []), ...message.content] : message.text;
41
- const messages = [
42
- { role: "system", content: configuration.systemPrompt },
43
- ...(continuity ? [{ role: "user", content: continuity }] : []),
44
- ...(requestHistory ?? history.flatMap(turn => [
45
- ...(turn.user ? [{ role: "user", content: content({ ...turn.user, text: conversationRequestText(turn.user) }) }] : []),
46
- ...(turn.system ? [{ role: "user", content: content({ ...turn.system, text: conversationRequestText({ ...turn.system, origin: "application" }) }) }] : []),
47
- ...(turn.metadata?.applicationTools || []).filter(call => call.result).flatMap(call => [
48
- { role: "assistant", content: "", tool_calls: [{ id: call.id, type: "function", function: { name: call.name, arguments: call.arguments } }] },
49
- { role: "tool", tool_call_id: call.id, content: toolResultContent(call.result) }
50
- ]),
51
- ...(turn.assistant ? [{ role: "assistant", content: turn.assistant.text }] : [])
52
- ])),
53
- { role: "user", content: content(input) }
54
- ];
55
- let accepted = false;
56
- let round = 0;
57
- let responseId;
58
- let responseIsCommentary = false;
59
- async function prepareClient(inferenceMessages = messages) {
60
- if (inferenceMessages.reduce((size, message) => size + (typeof message.content === "string" ? message.content.length
61
- : message.content.reduce((count, part) => count + (part.type === "text" ? part.text.length : 0), 0)) +
62
- (message.tool_calls ? JSON.stringify(message.tool_calls).length : 0), 0) > maximumHistory) {
63
- throw new Error("This conversation exceeds the configured API history limit. Start another conversation or increase the limit.");
64
- }
65
- let client;
109
+ // A broken receipt, a lost authorization or a limit ends the reply with its
110
+ // own error: the engine ends at once with an aborted signal's reason.
111
+ const stop = new AbortController();
112
+ const stopped = AbortSignal.any([signal, stop.signal]);
113
+ const fail = error => {
114
+ if (!stop.signal.aborted) stop.abort(error);
115
+ return stop.signal.reason;
116
+ };
117
+
118
+ async function resolveModel() {
66
119
  if (apiClientFactory) {
67
- client = await apiClientFactory.resolveClient({ context, configuration });
68
- if (!client?.enabled || typeof client.createChatCompletionStream !== "function") {
69
- throw new Error("Assistant provider is not configured.");
70
- }
120
+ const client = await apiClientFactory.resolveClient({ context, configuration });
121
+ if (!client?.enabled || !client.model) throw new Error("Assistant provider is not configured.");
71
122
  validateConnectionModel(configuration, { model: client.defaultModel });
72
- } else {
73
- const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
74
- validateConnectionModel(configuration, connection);
75
- client = createAiConnectionClient(connection, { fetch, effort: configuration.effort,
76
- reportUnavailableToolCalls: Boolean(tools),
77
- maxOutputTokens: limits.maxOutputTokens, timeoutMs: limits.timeoutMs });
123
+ return client.model;
78
124
  }
79
- signal.throwIfAborted();
125
+ const connection = await connections.resolve({ context, integrationId: configuration.integrationId });
126
+ validateConnectionModel(configuration, connection);
127
+ return createAiConnectionClient(connection, { fetch }).model;
128
+ }
129
+ async function authorize() {
130
+ const model = await resolveModel();
131
+ stopped.throwIfAborted();
80
132
  await beforeDispatch();
81
- if (!accepted) { await accept(); accepted = true; }
82
- return client;
133
+ return model;
83
134
  }
84
- return runAssistantToolLoop({
85
- messages, input: input.text, toolSet: { tools: tools?.descriptors || [] }, preserveWhitespace,
86
- toToolSchema: tool => tools.schemas.find(schema => schema.function.name === tool.name),
87
- async complete(messages, schemas) {
88
- const id = responseId = `reply-${++round}`;
89
- responseIsCommentary = false;
90
- const client = await prepareClient(messages);
91
- const completion = await consumeCompletionStream(
92
- await client.createChatCompletionStream({ signal, messages, tools: schemas }),
93
- async text => { if (text.trim()) await onMessage({ id, outputId: id, role: "assistant", text, complete: false }); },
94
- { signal, preserveWhitespace, requireCallIds: true, callIdPrefix: `${id}:`,
95
- limits: { maxOutputCharacters: maximumOutput, maxToolCalls: tools ? limits.maxToolCalls ?? 32 : 0,
96
- maxToolArgumentBytes: tools?.maximumArgumentBytes ?? 0 } }
97
- );
98
- const { toolCalls: calls, toolCallSource } = completion;
99
- // Environment clients expose the provider's original OpenAI spelling;
100
- // SDK connection clients use the normalized equivalent.
101
- const finishReason = completion.finishReason === "tool_calls" ? "tool-calls" : completion.finishReason;
102
- const text = sanitizeAssistantMessageText(completion.assistantText, { preserveWhitespace });
103
- if (finishReason === "length") throw new Error("The model reached its output token limit before completing the answer. Increase maxOutputTokens in the conversation limits.");
104
- if (calls.length) {
105
- if (finishReason !== (toolCallSource === "text" ? "stop" : "tool-calls")) throw new Error("The model stream ended without confirming its application tool calls.");
106
- } else if (finishReason !== "stop") {
107
- throw new Error("The model stream ended without confirming a completed answer.");
108
- }
109
- if (calls.length && maximumToolProgress !== undefined && text.length > maximumToolProgress) {
110
- throw new Error("Application tool progress text exceeds the configured API limit.");
111
- }
112
- if (text.trim() && (calls.length || isAssistantProgressOnlyText(text))) {
113
- responseIsCommentary = true;
114
- await onMessage({ id, outputId: id, role: "commentary", text, complete: true });
115
- }
116
- return completion;
117
- },
118
- async executeToolCalls(calls, { toolFailures, toolSuccesses }) {
119
- // The original preflight runs before inference, but never before the
120
- // same authorized connection and durable admission as ordinary sends.
121
- if (!accepted) await prepareClient();
122
- const failures = [];
123
- for (const call of calls) {
124
- const result = await tools.execute(call);
125
- messages.push({ role: "tool", tool_call_id: call.id, content: toolResultContent(result) });
126
- if (result.ok) toolSuccesses.push({ name: call.name, result: result.result });
127
- else {
128
- const failure = { name: call.name, error: result.error };
129
- failures.push(failure);
130
- toolFailures.push(failure);
135
+
136
+ // Admission precedes every model call. Each later call (steps, checks,
137
+ // summaries, accounts) authorizes the same connection again. Reasoning a
138
+ // model writes inline in <think> tags is reasoning, never reply text.
139
+ // `stepWords` are the streaming step's words so far, without that reasoning: its
140
+ // actions run only once its model call has ended, so they are all said by then.
141
+ let admitted = false;
142
+ let stepWords = "";
143
+ const model = wrapLanguageModel({ model: await authorize(), middleware: [
144
+ { specificationVersion: "v4", async transformParams({ params }) {
145
+ if (admitted) await authorize().catch(error => { throw fail(error); });
146
+ admitted = true;
147
+ return params;
148
+ }, async wrapStream({ doStream }) {
149
+ const result = await doStream();
150
+ stepWords = "";
151
+ return { ...result, stream: result.stream.pipeThrough(new TransformStream({ transform(part, controller) {
152
+ if (part.type === "text-delta") stepWords += part.delta;
153
+ controller.enqueue(part);
154
+ } })) };
155
+ } },
156
+ extractReasoningMiddleware({ tagName: "think" }),
157
+ ...(configuration.effort ? [defaultSettingsMiddleware({ settings: { reasoning: configuration.effort } })] : [])
158
+ ] });
159
+ await accept();
160
+
161
+ let step = 0;
162
+ const repeats = {};
163
+ async function runTool(call) {
164
+ // A step's progress is shown and held to its limits before any of its actions
165
+ // runs. Another action after an unknown outcome could repeat it.
166
+ try {
167
+ await stepProgress();
168
+ return await tools.execute(call);
169
+ } catch (error) { throw fail(error); }
170
+ }
171
+ /** One engine tool call, recorded under its step, with the model-facing result. */
172
+ function runEngineTool(name, value, { toolCallId }) {
173
+ return runTool({
174
+ id: `reply-${step + 1}:${toolCallId}`, name, arguments: JSON.stringify(value ?? {}) }).then(toolOutput);
175
+ }
176
+ const engineTools = Object.fromEntries((tools?.schemas || []).map(({ function: tool }) => {
177
+ // The engine's copy of the frozen definition is the one its repeat reminder wraps.
178
+ const engineTool = createTool(Object.freeze({ id: tool.name, description: tool.description, parameters: tool.parameters,
179
+ execute: (value, options) => runEngineTool(tool.name, value, options) }));
180
+ remindOnRepeat(engineTool, repeats);
181
+ return [tool.name, engineTool];
182
+ }));
183
+
184
+ const request = { role: "user", content: content(input) };
185
+ let earlier = summary;
186
+ let recorded = requestHistory ?? history.flatMap(turnMessages);
187
+ // The runtime's own history is compacted between turns; the summary and
188
+ // its last turn are stored, so later turns read only what follows.
189
+ if (!requestHistory && saveSummary) {
190
+ let started = false;
191
+ try {
192
+ const compacted = await compactMessages({ model, app: APP, messages: recorded, previous: summary, limits: window.summaries,
193
+ onCompacting: async () => { started = true; await onEvent({ type: "phase", phase: "compacting" }); } });
194
+ if (compacted.saved) {
195
+ // The summary ends where a turn starts.
196
+ let covered = 0;
197
+ let throughTurnId = "";
198
+ for (const turn of history) {
199
+ const size = turnMessages(turn).length;
200
+ if (covered + size > compacted.saved.covers) break;
201
+ covered += size;
202
+ throughTurnId = turn.turnId;
131
203
  }
204
+ await saveSummary({ text: compacted.summary, throughTurnId });
205
+ ({ summary: earlier, messages: recorded } = compacted);
132
206
  }
133
- return failures;
134
- },
135
- async finish(text, { metadata = {} } = {}) {
136
- const id = responseIsCommentary ? `reply-${++round}` : responseId;
137
- await onMessage({ id, outputId: id, role: "assistant", text, complete: true });
138
- return { metadata };
207
+ } catch (error) {
208
+ stopped.throwIfAborted();
209
+ // The engine's last-resort cut below still keeps the reply within its window.
210
+ console.error(`Compacting a conversation: ${error?.message || error}`);
139
211
  }
140
- });
212
+ if (started) await onEvent({ type: "phase", phase: "working" });
213
+ }
214
+ const messages = withinBudget([...(continuity ? [{ role: "user", content: continuity }] : []), ...recorded, request], window.history);
215
+
216
+ // Each step streams as reply-N. A step that also calls tools ends as
217
+ // commentary; the reply's last words become the answer.
218
+ // Output past its limit stops showing at once and fails the reply before the
219
+ // step's actions run, or when it ends: aborting a stream mid-way leaves Node 26
220
+ // an unhandled rejection.
221
+ let answer = "";
222
+ let overLimit = false;
223
+ // The engine's reply joins every step's words; the answer leaves out those
224
+ // already shown as commentary, unless they are all there is.
225
+ const shown = [];
226
+ const unsaid = text => shown.reduce((rest, said) => rest === said ? ""
227
+ : rest.startsWith(`${said}\n\n`) ? rest.slice(said.length + 2) : rest, text) || text;
228
+ // A step whose actions run completes as commentary before the first of them;
229
+ // one whose calls don't run (an action it wasn't given) does when it ends.
230
+ let progress = { id: "", words: "", shown: null };
231
+ function stepProgress() {
232
+ const id = `reply-${step + 1}`;
233
+ if (progress.id !== id) progress = { id, words: stepWords, shown: showProgress(id, stepWords) };
234
+ return progress.shown;
235
+ }
236
+ async function showProgress(id, words) {
237
+ if (words.length > maximumOutput) throw new Error(OUTPUT_LIMIT);
238
+ const text = withoutMarkup(words);
239
+ if (maximumToolProgress !== undefined && text.length > maximumToolProgress) throw new Error(PROGRESS_LIMIT);
240
+ if (answer === id) answer = "";
241
+ if (text) await onMessage({ id, outputId: id, role: "commentary", text, complete: true });
242
+ }
243
+ async function onText(text) {
244
+ overLimit ||= text.length > maximumOutput;
245
+ const visible = withoutMarkup(text);
246
+ // Words streamed after their step's progress was shown are already in it.
247
+ if (!visible || overLimit || progress.id === `reply-${step + 1}`) return;
248
+ answer = `reply-${step + 1}`;
249
+ await onMessage({ id: answer, outputId: answer, role: "assistant", text: visible, complete: false });
250
+ }
251
+ async function onStep(result) {
252
+ const id = `reply-${++step}`;
253
+ if (overLimit) return fail(new Error(OUTPUT_LIMIT));
254
+ if (result.finishReason === "length") return fail(new Error("The model reached its output token limit before completing the answer. Increase maxOutputTokens in the conversation limits."));
255
+ if (!result.toolCalls.length) return;
256
+ const text = withoutMarkup(result.text);
257
+ if (maximumToolProgress !== undefined && text.length > maximumToolProgress) return fail(new Error(PROGRESS_LIMIT));
258
+ if (answer === id) answer = "";
259
+ if (!text) return;
260
+ shown.push(...result.content.filter(part => part.type === "text").map(part => part.text.trim()).filter(Boolean));
261
+ if (progress.id === id && progress.words === result.text) return;
262
+ await onMessage({ id, outputId: id, role: "commentary", text, complete: true });
263
+ }
264
+ const pass = async history => {
265
+ const result = await runLoop({ model, name: NAME, instructions: configuration.systemPrompt, history, tools: engineTools,
266
+ summary: earlier, state: stateNote(), maxSteps: maximumSteps, onText, onStep, signal: stopped, clean: text => stripNotes(unsaid(text)),
267
+ // With nothing done yet, a failed call is the reply's error, not an account.
268
+ throwIf: (_error, steps) => !step && !steps.length,
269
+ // A configured effort reaches every provider the standard way.
270
+ reasoning: configuration.effort ? {} : REASONING.on,
271
+ ...(limits.maxOutputTokens ? { maxOutputTokens: limits.maxOutputTokens } : {}) });
272
+ stopped.throwIfAborted();
273
+ return result;
274
+ };
275
+
276
+ let { text, transcript, unfinished, account } = await pass(messages);
277
+ // A check sends the reply back once, carrying on from its own steps.
278
+ let check = "";
279
+ async function carryOn(name, note) {
280
+ check = name;
281
+ const steps = withoutClosingWords(transcript);
282
+ const next = await pass([...messages, ...steps.map(clipOldResult), { role: "user", content: note }]);
283
+ ({ text, unfinished } = next);
284
+ transcript = [...steps, ...next.transcript];
285
+ }
286
+ const working = Object.keys(engineTools).length > 0;
287
+ if (working && LEAKED_MARKUP.test(text)) await carryOn("markup", CHECKS.markup);
288
+ // The engine's own account of a reply that stopped early already says where it got to.
289
+ else if (working && !account && stoppedShort(text.trim())) await carryOn("stoppedShort", CHECKS.stoppedShort);
290
+ if (!check && working && reportsActions(text)) {
291
+ const claims = await unsupportedClaims({ model, reply: text, record: toolRecord(transcript, [], APP) });
292
+ stopped.throwIfAborted();
293
+ if (claims.length) await carryOn("unsupported", CHECKS.unsupported(claims));
294
+ }
295
+ text = withoutMarkup(text);
296
+ if (text.length > maximumOutput) throw new Error(OUTPUT_LIMIT);
297
+ if (text) {
298
+ const id = answer || `reply-${step + 1}`;
299
+ await onMessage({ id, outputId: id, role: "assistant", text, complete: true });
300
+ }
301
+ return { metadata: { ...(unfinished ? { unfinished } : {}), ...(check ? { check } : {}) } };
141
302
  }
142
303
  });
143
304
  return driver;
@@ -1,10 +1,23 @@
1
1
  import { createConversationSystemPrompt } from "../systemPrompt.js";
2
2
 
3
- /** One instance per native Claude conversation; process ownership stays with the host. */
4
- export function createClaudeConversationAdapter({ getProcess, isActive, startProcess, stopProcess } = {}) {
5
- for (const callback of [getProcess, isActive, startProcess, stopProcess]) {
6
- if (typeof callback !== "function") throw new TypeError("Claude requires process and activity callbacks.");
7
- }
3
+ /**
4
+ * @typedef {import("../claudeProcess.js").ClaudeCodeProcess} ClaudeCodeProcess
5
+ */
6
+
7
+ /**
8
+ * The conversation owner's control of its one native process.
9
+ * @typedef {object} ClaudeProcessControl
10
+ * @property {() => ClaudeCodeProcess | null} getProcess
11
+ * @property {() => boolean} isActive
12
+ * @property {(input: object) => Promise<ClaudeCodeProcess>} startProcess
13
+ * @property {(reason: string) => Promise<unknown>} stopProcess
14
+ */
15
+
16
+ /**
17
+ * One instance per native Claude conversation; process ownership stays with the host.
18
+ * @param {ClaudeProcessControl} control
19
+ */
20
+ export function createClaudeConversationAdapter({ getProcess, isActive, startProcess, stopProcess }) {
8
21
  const instructions = createConversationSystemPrompt();
9
22
  let pending = Promise.resolve();
10
23
  let updateIdentity;
@@ -63,5 +76,15 @@ export function createClaudeConversationAdapter({ getProcess, isActive, startPro
63
76
  return operation;
64
77
  }
65
78
 
66
- return Object.freeze({ prepare, instructionArguments, invalidate: instructions.invalidate, whenIdle: () => pending });
79
+ /** The latest queued preparation. */
80
+ function whenIdle() {
81
+ return pending;
82
+ }
83
+
84
+ return Object.freeze({
85
+ prepare,
86
+ instructionArguments,
87
+ invalidate: instructions.invalidate,
88
+ whenIdle
89
+ });
67
90
  }