@inspectr/mcplab 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +2 -2
  2. package/dist/app/assets/index-Cwogc_kk.js +267 -0
  3. package/dist/app/assets/index-D5OYchKq.css +2 -0
  4. package/dist/app/index.html +2 -2
  5. package/dist/app-server/app-context.d.ts +6 -22
  6. package/dist/app-server/app-context.d.ts.map +1 -1
  7. package/dist/app-server/assistant-agent.d.ts +10 -0
  8. package/dist/app-server/assistant-agent.d.ts.map +1 -0
  9. package/dist/app-server/assistant-agent.js +29 -0
  10. package/dist/app-server/assistant-agent.js.map +1 -0
  11. package/dist/app-server/assistant-common.d.ts +7 -34
  12. package/dist/app-server/assistant-common.d.ts.map +1 -1
  13. package/dist/app-server/assistant-common.js +33 -194
  14. package/dist/app-server/assistant-common.js.map +1 -1
  15. package/dist/app-server/assistant-events.d.ts +10 -6
  16. package/dist/app-server/assistant-events.d.ts.map +1 -1
  17. package/dist/app-server/assistant-events.js +23 -11
  18. package/dist/app-server/assistant-events.js.map +1 -1
  19. package/dist/app-server/assistant-graph-nodes.d.ts +226 -0
  20. package/dist/app-server/assistant-graph-nodes.d.ts.map +1 -0
  21. package/dist/app-server/assistant-graph-nodes.js +365 -0
  22. package/dist/app-server/assistant-graph-nodes.js.map +1 -0
  23. package/dist/app-server/assistant-graph-state.d.ts +64 -0
  24. package/dist/app-server/assistant-graph-state.d.ts.map +1 -0
  25. package/dist/app-server/assistant-graph-state.js +137 -0
  26. package/dist/app-server/assistant-graph-state.js.map +1 -0
  27. package/dist/app-server/assistant-mcp-context-tools.d.ts +17 -0
  28. package/dist/app-server/assistant-mcp-context-tools.d.ts.map +1 -0
  29. package/dist/app-server/assistant-mcp-context-tools.js +149 -0
  30. package/dist/app-server/assistant-mcp-context-tools.js.map +1 -0
  31. package/dist/app-server/assistant-model.d.ts +24 -0
  32. package/dist/app-server/assistant-model.d.ts.map +1 -0
  33. package/dist/app-server/assistant-model.js +240 -0
  34. package/dist/app-server/assistant-model.js.map +1 -0
  35. package/dist/app-server/assistant-runtime-contract.d.ts +46 -0
  36. package/dist/app-server/assistant-runtime-contract.d.ts.map +1 -0
  37. package/dist/app-server/assistant-runtime-contract.js +2 -0
  38. package/dist/app-server/assistant-runtime-contract.js.map +1 -0
  39. package/dist/app-server/assistant-runtime-policy.d.ts +22 -0
  40. package/dist/app-server/assistant-runtime-policy.d.ts.map +1 -0
  41. package/dist/app-server/assistant-runtime-policy.js +71 -0
  42. package/dist/app-server/assistant-runtime-policy.js.map +1 -0
  43. package/dist/app-server/assistant-service.d.ts +23 -0
  44. package/dist/app-server/assistant-service.d.ts.map +1 -0
  45. package/dist/app-server/assistant-service.js +185 -0
  46. package/dist/app-server/assistant-service.js.map +1 -0
  47. package/dist/app-server/assistant-session-routes.d.ts +25 -0
  48. package/dist/app-server/assistant-session-routes.d.ts.map +1 -0
  49. package/dist/app-server/assistant-session-routes.js +210 -0
  50. package/dist/app-server/assistant-session-routes.js.map +1 -0
  51. package/dist/app-server/assistant-tracing.d.ts +5 -0
  52. package/dist/app-server/assistant-tracing.d.ts.map +1 -0
  53. package/dist/app-server/assistant-tracing.js +30 -0
  54. package/dist/app-server/assistant-tracing.js.map +1 -0
  55. package/dist/app-server/browser-provider-learning.js +1 -1
  56. package/dist/app-server/browser-provider-learning.js.map +1 -1
  57. package/dist/app-server/langchain-assistant-runtime.d.ts +11 -0
  58. package/dist/app-server/langchain-assistant-runtime.d.ts.map +1 -0
  59. package/dist/app-server/langchain-assistant-runtime.js +852 -0
  60. package/dist/app-server/langchain-assistant-runtime.js.map +1 -0
  61. package/dist/app-server/one-shot-json.d.ts +26 -0
  62. package/dist/app-server/one-shot-json.d.ts.map +1 -0
  63. package/dist/app-server/one-shot-json.js +168 -0
  64. package/dist/app-server/one-shot-json.js.map +1 -0
  65. package/dist/app-server/result-assistant-domain.d.ts +46 -48
  66. package/dist/app-server/result-assistant-domain.d.ts.map +1 -1
  67. package/dist/app-server/result-assistant-domain.js +148 -133
  68. package/dist/app-server/result-assistant-domain.js.map +1 -1
  69. package/dist/app-server/result-assistant.d.ts +2 -2
  70. package/dist/app-server/result-assistant.d.ts.map +1 -1
  71. package/dist/app-server/result-assistant.js +43 -311
  72. package/dist/app-server/result-assistant.js.map +1 -1
  73. package/dist/app-server/router.d.ts.map +1 -1
  74. package/dist/app-server/router.js +14 -16
  75. package/dist/app-server/router.js.map +1 -1
  76. package/dist/app-server/runs-routes.js +1 -1
  77. package/dist/app-server/runs-routes.js.map +1 -1
  78. package/dist/app-server/scenario-assistant-domain.d.ts +43 -136
  79. package/dist/app-server/scenario-assistant-domain.d.ts.map +1 -1
  80. package/dist/app-server/scenario-assistant-domain.js +377 -215
  81. package/dist/app-server/scenario-assistant-domain.js.map +1 -1
  82. package/dist/app-server/scenario-assistant.d.ts +3 -3
  83. package/dist/app-server/scenario-assistant.d.ts.map +1 -1
  84. package/dist/app-server/scenario-assistant.js +52 -406
  85. package/dist/app-server/scenario-assistant.js.map +1 -1
  86. package/dist/app-server/settings-store.d.ts.map +1 -1
  87. package/dist/app-server/settings-store.js +2 -5
  88. package/dist/app-server/settings-store.js.map +1 -1
  89. package/dist/app-server/tool-analysis-domain.d.ts +1 -1
  90. package/dist/app-server/tool-analysis-domain.d.ts.map +1 -1
  91. package/dist/app-server/tool-analysis-domain.js +5 -4
  92. package/dist/app-server/tool-analysis-domain.js.map +1 -1
  93. package/dist/app-server/types.d.ts +1 -1
  94. package/dist/app-server/types.d.ts.map +1 -1
  95. package/dist/cli.js +2 -1
  96. package/dist/cli.js.map +1 -1
  97. package/package.json +10 -4
  98. package/dist/app/assets/index-8I9ZyPIK.js +0 -243
  99. package/dist/app/assets/index-jX7LoIQP.css +0 -2
@@ -0,0 +1,852 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { BaseCallbackHandler } from '@langchain/core/callbacks/base';
3
+ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
4
+ import { tool } from '@langchain/core/tools';
5
+ import { Command, MemorySaver } from '@langchain/langgraph';
6
+ import { createAgent, createMiddleware, dynamicSystemPromptMiddleware, humanInTheLoopMiddleware, isToolApprovalInterrupt, MiddlewareError, modelCallLimitMiddleware, toolErrorMiddleware } from 'langchain';
7
+ import { isAbortError } from '@inspectr/mcplab-core';
8
+ import { createAssistantChatModel } from './assistant-model.js';
9
+ import { withAssistantTracing } from './assistant-tracing.js';
10
+ import { AssistantToolDecisionConflictError, AssistantTurnBudgetExceededError, MAX_ASSISTANT_MODEL_CALLS, mergeAssistantSuggestions, resultPreview, uniqueToolCallId, validateToolCallDecisions } from './assistant-runtime-policy.js';
11
+ const DENIED_REASON = 'User denied tool call';
12
+ const MAX_DENY_MESSAGE_CHARS = 2000;
13
+ // Each model round and tool batch is a few graph steps; the model-call budget is the real limit.
14
+ const GRAPH_RECURSION_LIMIT = MAX_ASSISTANT_MODEL_CALLS * 10;
15
+ export function createLangChainAssistantRuntimeFactory(options = {}) {
16
+ const checkpointer = options.checkpointer ?? new MemorySaver();
17
+ return {
18
+ async create(definition) {
19
+ const runtime = new LangChainAssistantRuntime(definition, checkpointer);
20
+ await runtime.initialize(options.createChatModel ?? createAssistantChatModel);
21
+ return runtime.session();
22
+ }
23
+ };
24
+ }
25
+ class LangChainAssistantRuntime {
26
+ definition;
27
+ checkpointer;
28
+ config = { configurable: { thread_id: `assistant-${randomUUID()}` } };
29
+ agent;
30
+ calls = [];
31
+ validationErrors = new Map();
32
+ executing = new Set();
33
+ /** Calls answered by settling; a late tool completion must not change them again. */
34
+ settled = new Set();
35
+ suggestionsByMessageId = new Map();
36
+ reasoningByMessageId = new Map();
37
+ createdAt = new Map();
38
+ turnIds = new Set();
39
+ /** Which turn and streamed message each model run started for; late tokens are matched against it. */
40
+ modelRuns = new Map();
41
+ history = [];
42
+ turn;
43
+ running;
44
+ runController;
45
+ revision = 0;
46
+ projection;
47
+ disposed = false;
48
+ constructor(definition, checkpointer) {
49
+ this.definition = definition;
50
+ this.checkpointer = checkpointer;
51
+ this.projection = {
52
+ revision: 0,
53
+ messages: structuredClone(definition.initialMessages ?? []),
54
+ pendingToolCalls: []
55
+ };
56
+ }
57
+ async initialize(createChatModel) {
58
+ const tokens = new TokenSink((runId) => this.onModelRunStart(runId), (token, runId) => this.onToken(token, runId), (text, part, runId) => this.onReasoning(text, part, runId));
59
+ const setup = await createChatModel(this.definition, {
60
+ callbacks: [tokens],
61
+ hasStreamedText: () => Boolean(this.turn?.streaming?.text)
62
+ });
63
+ const interruptOn = Object.fromEntries(this.definition.tools
64
+ .filter((item) => this.definition.approvalPolicy(item.name) === 'approval_required')
65
+ .map((item) => [
66
+ item.name,
67
+ {
68
+ allowedDecisions: ['approve', 'edit', 'reject']
69
+ }
70
+ ]));
71
+ this.agent = createAgent({
72
+ model: setup.model,
73
+ tools: this.definition.tools.map((item) => this.toLangChainTool(item)),
74
+ middleware: [
75
+ this.turnMiddleware(),
76
+ // Tool failures are feedback for the model, not a failed turn.
77
+ toolErrorMiddleware({
78
+ onError: (error) => isAbortError(error) ? undefined : JSON.stringify({ error: errorText(error) })
79
+ }),
80
+ dynamicSystemPromptMiddleware(() => this.definition.getSystemPrompt()),
81
+ ...setup.middleware,
82
+ humanInTheLoopMiddleware({ interruptMode: 'per_call', interruptOn }),
83
+ modelCallLimitMiddleware({ runLimit: MAX_ASSISTANT_MODEL_CALLS, exitBehavior: 'error' })
84
+ ],
85
+ checkpointer: this.checkpointer,
86
+ // The agent stamps this on every reply, and OpenAI rejects message names with spaces.
87
+ name: 'mcplab_assistant'
88
+ });
89
+ }
90
+ session() {
91
+ return {
92
+ sendMessage: (text, signal, turnId) => this.track(this.sendMessage(text, signal, turnId)),
93
+ resolveToolCalls: (decisions, signal) => this.track(this.resolveToolCalls(decisions, signal)),
94
+ getSnapshot: () => structuredClone(this.projection),
95
+ cancel: (turnId) => this.cancel(turnId),
96
+ dispose: () => this.dispose()
97
+ };
98
+ }
99
+ // ---- Session operations (the service serializes them) ----
100
+ async sendMessage(text, signal, requestedTurnId) {
101
+ this.assertUsable();
102
+ const turnId = requestedTurnId ?? randomUUID();
103
+ if (this.turnIds.has(turnId))
104
+ throw Object.assign(new Error('Assistant turn ID has already been used'), {
105
+ statusCode: 409
106
+ });
107
+ // A new message replaces a paused batch; earlier interrupted runs may also have left calls open.
108
+ await this.settleOpenToolCalls(this.turn?.paused ? 'superseded' : 'cancelled');
109
+ await this.resetTurnBudget();
110
+ this.turnIds.add(turnId);
111
+ this.createdAt.set(turnId, new Date().toISOString());
112
+ this.turn = {
113
+ id: turnId,
114
+ proposals: [],
115
+ decided: new Set(),
116
+ announced: new Set(),
117
+ paused: false
118
+ };
119
+ this.publish();
120
+ this.emit('turn_started');
121
+ return this.run({ messages: [new HumanMessage({ id: turnId, content: text })] }, signal);
122
+ }
123
+ async resolveToolCalls(decisions, signal) {
124
+ this.assertUsable();
125
+ const turn = this.turn;
126
+ if (!turn?.paused)
127
+ throw new AssistantToolDecisionConflictError();
128
+ const interrupts = await this.pendingInterrupts();
129
+ const pending = this.calls.filter((call) => interrupts.has(call.id));
130
+ const validated = validateToolCallDecisions(decisions, pending);
131
+ // Validate the whole batch before recording any decision, so a rejected request changes nothing.
132
+ if (validated.some(({ decision }) => decision.decision === 'approve' &&
133
+ decision.arguments !== undefined &&
134
+ !isRecord(decision.arguments)))
135
+ throw Object.assign(new Error('Edited tool arguments must be a JSON object'), {
136
+ statusCode: 400
137
+ });
138
+ if (validated.some(({ decision }) => decision.message !== undefined &&
139
+ (decision.decision !== 'deny' ||
140
+ typeof decision.message !== 'string' ||
141
+ decision.message.length > MAX_DENY_MESSAGE_CHARS)))
142
+ throw Object.assign(new Error(`A deny reason must be text of at most ${MAX_DENY_MESSAGE_CHARS} characters`), { statusCode: 400 });
143
+ const resume = {};
144
+ for (const { decision, call } of validated) {
145
+ turn.decided.add(call.id);
146
+ if (decision.decision === 'deny') {
147
+ const message = decision.message?.trim();
148
+ this.updateCall(call.id, {
149
+ status: 'denied',
150
+ error: DENIED_REASON,
151
+ ...(message ? { denyMessage: message } : {})
152
+ });
153
+ this.emit('tool_call_denied', { pendingToolCallId: call.id });
154
+ resume[interrupts.get(call.id)] = {
155
+ type: 'reject',
156
+ message: JSON.stringify({
157
+ denied: true,
158
+ reason: DENIED_REASON,
159
+ ...(message ? { message } : {})
160
+ })
161
+ };
162
+ }
163
+ else {
164
+ const edited = decision.arguments !== undefined;
165
+ this.updateCall(call.id, {
166
+ status: 'approved',
167
+ ...(edited ? { arguments: decision.arguments } : {})
168
+ });
169
+ this.emit('tool_call_approved', { pendingToolCallId: call.id });
170
+ resume[interrupts.get(call.id)] = edited
171
+ ? { type: 'edit', edited_action: { name: call.publicToolName, args: decision.arguments } }
172
+ : { type: 'approve' };
173
+ }
174
+ }
175
+ turn.paused = false;
176
+ return this.run(new Command({ resume }), signal);
177
+ }
178
+ async cancel(turnId) {
179
+ if (this.disposed || this.turn?.id !== turnId)
180
+ return;
181
+ if (this.running) {
182
+ // Abort the run directly; it settles its own calls before rejecting.
183
+ this.runController?.abort('cancelled');
184
+ await this.running.catch(() => undefined);
185
+ return;
186
+ }
187
+ if (!this.turn.paused)
188
+ return;
189
+ await this.settleOpenToolCalls('cancelled');
190
+ this.endTurn();
191
+ this.emit('session_snapshot');
192
+ }
193
+ async dispose() {
194
+ if (this.disposed)
195
+ return;
196
+ this.disposed = true;
197
+ this.runController?.abort('disposed');
198
+ await this.running?.catch(() => undefined);
199
+ await this.checkpointer.deleteThread(this.config.configurable.thread_id);
200
+ }
201
+ // ---- Agent execution ----
202
+ async run(input, external) {
203
+ // The runtime owns the run's signal so disposal can stop it without the service.
204
+ const controller = new AbortController();
205
+ const abort = () => controller.abort(external?.reason);
206
+ external?.addEventListener('abort', abort, { once: true });
207
+ if (external?.aborted)
208
+ abort();
209
+ this.runController = controller;
210
+ const signal = controller.signal;
211
+ try {
212
+ await withAssistantTracing(async () => {
213
+ const stream = await this.agent.stream(input, {
214
+ ...this.config,
215
+ signal,
216
+ recursionLimit: GRAPH_RECURSION_LIMIT,
217
+ streamMode: 'values'
218
+ });
219
+ for await (const values of stream)
220
+ this.onValues(values);
221
+ });
222
+ if (signal.aborted)
223
+ throw new Error('Request aborted');
224
+ }
225
+ catch (error) {
226
+ return this.failTurn(error, signal);
227
+ }
228
+ finally {
229
+ external?.removeEventListener('abort', abort);
230
+ if (this.runController === controller)
231
+ this.runController = undefined;
232
+ }
233
+ return this.finishRun();
234
+ }
235
+ async finishRun() {
236
+ const turn = this.turn;
237
+ const interrupts = await this.pendingInterrupts();
238
+ if (interrupts.size) {
239
+ const waiting = this.calls.filter((call) => interrupts.has(call.id));
240
+ // A new batch (the first, or one the model asked for after earlier decisions) is a request;
241
+ // a batch the user has only partly decided is still the same request.
242
+ const newBatch = waiting.some((call) => !turn.announced.has(call.id));
243
+ for (const call of waiting)
244
+ turn.announced.add(call.id);
245
+ turn.paused = true;
246
+ this.publish();
247
+ if (newBatch)
248
+ this.emit('tool_call_requested', {
249
+ pendingToolCalls: waiting,
250
+ pendingToolCallId: waiting[0]?.id
251
+ });
252
+ return {
253
+ type: newBatch ? 'tool_call_request' : 'tool_call_resolved',
254
+ text: newBatch ? this.lastAssistantText() : '',
255
+ pendingToolCalls: structuredClone(waiting)
256
+ };
257
+ }
258
+ const last = [...this.history].reverse().find((message) => AIMessage.isInstance(message));
259
+ const text = last ? messageText(last) : '';
260
+ const suggestions = mergeAssistantSuggestions(turn.proposals);
261
+ if (last?.id && suggestions)
262
+ this.suggestionsByMessageId.set(last.id, suggestions);
263
+ this.endTurn();
264
+ this.emit('assistant_message_completed', { text, ...(suggestions ? { suggestions } : {}) });
265
+ return { type: 'assistant_message', text, ...(suggestions ? { suggestions } : {}) };
266
+ }
267
+ async failTurn(wrapped, signal) {
268
+ const error = unwrapMiddlewareError(wrapped);
269
+ const cancelled = Boolean(signal?.aborted) || isAbortError(error);
270
+ const budget = error instanceof Error && error.name === 'ModelCallLimitMiddlewareError';
271
+ if (this.disposed)
272
+ throw error;
273
+ try {
274
+ await this.settleOpenToolCalls(cancelled ? 'cancelled' : budget ? 'budget' : 'failure', error);
275
+ }
276
+ catch {
277
+ // Keep the original failure; the next message settles any remaining calls.
278
+ }
279
+ this.endTurn();
280
+ this.emit('session_snapshot');
281
+ if (budget)
282
+ throw new AssistantTurnBudgetExceededError();
283
+ throw error;
284
+ }
285
+ /**
286
+ * Gives every unanswered tool call a result so the model history stays valid: calls that
287
+ * finished keep their result, the rest are skipped or cancelled. Pending writes from a
288
+ * partially resumed batch are committed first, so no call is answered twice.
289
+ */
290
+ async settleOpenToolCalls(reason, error) {
291
+ let state = await this.agent.getState(this.config);
292
+ if (state.tasks.length) {
293
+ await this.agent.updateState(this.config, { messages: [] }, 'tools');
294
+ state = await this.agent.getState(this.config);
295
+ }
296
+ const messages = state.values.messages ?? [];
297
+ const answered = new Set(messages.filter((message) => ToolMessage.isInstance(message)).map((m) => m.tool_call_id));
298
+ const open = messages
299
+ .filter((message) => AIMessage.isInstance(message))
300
+ .flatMap((message) => message.tool_calls ?? [])
301
+ .filter((call) => call.id && !answered.has(call.id));
302
+ if (open.length) {
303
+ const results = open.map((call) => {
304
+ const known = this.calls.find((item) => item.id === call.id);
305
+ // A call that finished before the interruption keeps its real result.
306
+ if (known?.status === 'approved' && known.resultPreview !== undefined)
307
+ return new ToolMessage({
308
+ content: known.resultPreview,
309
+ tool_call_id: call.id,
310
+ name: call.name
311
+ });
312
+ this.settled.add(call.id);
313
+ const feedback = settleFeedback(reason, this.executing.has(call.id), error);
314
+ this.updateCall(call.id, {
315
+ status: reason === 'superseded' && !this.executing.has(call.id) ? 'denied' : 'error',
316
+ error: feedback
317
+ });
318
+ return new ToolMessage({
319
+ content: JSON.stringify({ error: feedback }),
320
+ tool_call_id: call.id,
321
+ name: call.name,
322
+ status: 'error'
323
+ });
324
+ });
325
+ await this.agent.updateState(this.config, { messages: results }, 'tools');
326
+ state = await this.agent.getState(this.config);
327
+ }
328
+ this.executing.clear();
329
+ this.onValues(state.values);
330
+ }
331
+ /**
332
+ * modelCallLimitMiddleware resets its run counter only when the agent finishes normally; a
333
+ * failed or cancelled run would otherwise carry its count into the next user turn.
334
+ */
335
+ async resetTurnBudget() {
336
+ const state = await this.agent.getState(this.config);
337
+ if (state.values.runModelCallCount)
338
+ await this.agent.updateState(this.config, { runModelCallCount: 0 });
339
+ }
340
+ async pendingInterrupts() {
341
+ const state = await this.agent.getState(this.config);
342
+ const pending = new Map();
343
+ for (const task of state.tasks) {
344
+ for (const item of task.interrupts) {
345
+ if (!item.id)
346
+ continue;
347
+ const approval = { id: item.id, value: item.value };
348
+ if (!isToolApprovalInterrupt(approval))
349
+ continue;
350
+ const callId = approval.value.tool_call_id;
351
+ if (!this.turn?.decided.has(callId))
352
+ pending.set(callId, approval.id);
353
+ }
354
+ }
355
+ return pending;
356
+ }
357
+ // ---- Middleware: MCPlab bookkeeping around standard model and tool calls ----
358
+ turnMiddleware() {
359
+ return createMiddleware({
360
+ name: 'McplabAssistantTurn',
361
+ wrapModelCall: async (request, handler) => {
362
+ const id = `msg-${randomUUID()}`;
363
+ const createdAt = new Date().toISOString();
364
+ if (this.turn)
365
+ this.turn.streaming = { id, text: '', createdAt };
366
+ this.createdAt.set(id, createdAt);
367
+ const response = await handler(request);
368
+ this.keepReasoning(id);
369
+ return this.normalizeModelResponse(response, id);
370
+ },
371
+ wrapToolCall: async (request, handler) => {
372
+ const call = request.toolCall;
373
+ const callId = call.id ?? '';
374
+ const validationError = this.validationErrors.get(callId);
375
+ if (validationError) {
376
+ this.updateCall(callId, { status: 'error', error: validationError });
377
+ return new ToolMessage({
378
+ content: JSON.stringify({ error: validationError }),
379
+ tool_call_id: callId,
380
+ name: call.name,
381
+ status: 'error'
382
+ });
383
+ }
384
+ const result = await handler(request);
385
+ if (!ToolMessage.isInstance(result))
386
+ return result;
387
+ const normalized = withJsonErrorContent(result);
388
+ this.recordToolResult(callId, normalized);
389
+ return normalized;
390
+ }
391
+ });
392
+ }
393
+ normalizeModelResponse(response, id) {
394
+ const usedIds = new Set(this.calls.map((call) => call.id));
395
+ const requested = [
396
+ ...(response.tool_calls ?? []).map((call) => ({
397
+ providerId: call.id,
398
+ name: call.name,
399
+ args: isRecord(call.args) ? call.args : {},
400
+ validationError: isRecord(call.args)
401
+ ? undefined
402
+ : `Invalid arguments for tool '${call.name}': expected a JSON object.`
403
+ })),
404
+ ...(response.invalid_tool_calls ?? []).map((call) => ({
405
+ providerId: call.id,
406
+ name: call.name ?? 'unknown',
407
+ args: {},
408
+ validationError: `Invalid arguments for tool '${call.name ?? 'unknown'}': ${call.error ?? 'malformed tool arguments'}${call.args ? ` (raw: ${call.args})` : ''}`
409
+ }))
410
+ ];
411
+ const toolCalls = requested.map((call) => {
412
+ const callId = uniqueToolCallId(call.providerId, usedIds);
413
+ const known = this.definition.tools.find((item) => item.name === call.name);
414
+ const display = known?.display;
415
+ const argumentLabels = known ? schemaFieldTitles(known.inputSchema) : undefined;
416
+ this.calls.push({
417
+ id: callId,
418
+ server: known?.server ?? 'unknown',
419
+ tool: known?.tool ?? call.name,
420
+ publicToolName: call.name,
421
+ arguments: call.args,
422
+ ...(call.validationError ? { validationError: call.validationError } : {}),
423
+ status: 'pending',
424
+ createdAt: new Date().toISOString(),
425
+ requiresApproval: this.definition.approvalPolicy(call.name) === 'approval_required',
426
+ ...(display?.title ? { title: display.title } : {}),
427
+ ...(display?.description ? { description: display.description } : {}),
428
+ ...(display?.hints ? { hints: display.hints } : {}),
429
+ ...(argumentLabels ? { argumentLabels } : {})
430
+ });
431
+ if (call.validationError)
432
+ this.validationErrors.set(callId, call.validationError);
433
+ return { id: callId, name: call.name, args: call.args, type: 'tool_call' };
434
+ });
435
+ const additionalKwargs = { ...response.additional_kwargs };
436
+ delete additionalKwargs.tool_calls;
437
+ // OpenAI's Responses API replays its raw output, which carries the provider's call IDs.
438
+ const responseMetadata = { ...response.response_metadata };
439
+ if (toolCalls.some((call, index) => call.id !== requested[index].providerId))
440
+ delete responseMetadata.output;
441
+ return new AIMessage({
442
+ content: withNormalizedToolUseBlocks(response.content, toolCalls),
443
+ id,
444
+ additional_kwargs: additionalKwargs,
445
+ response_metadata: responseMetadata,
446
+ usage_metadata: response.usage_metadata,
447
+ tool_calls: toolCalls,
448
+ invalid_tool_calls: []
449
+ });
450
+ }
451
+ toLangChainTool(definitionTool) {
452
+ return tool(async (args, config) => {
453
+ const callId = config?.toolCall?.id;
454
+ const turn = this.turn;
455
+ if (callId) {
456
+ this.executing.add(callId);
457
+ // Approval happens before the tool runs, so this is when execution really starts.
458
+ this.updateCall(callId, { startedAt: new Date().toISOString() });
459
+ if (this.calls.find((call) => call.id === callId)?.status === 'pending') {
460
+ this.updateCall(callId, { status: 'approved' });
461
+ this.emit('tool_call_approved', { pendingToolCallId: callId });
462
+ }
463
+ }
464
+ try {
465
+ const value = await definitionTool.execute(args, config?.signal);
466
+ const proposal = definitionTool.extractProposal?.(value);
467
+ const stillCurrent = turn && this.turn === turn && !(callId && this.settled.has(callId));
468
+ if (proposal && stillCurrent)
469
+ turn.proposals.push(proposal);
470
+ return resultPreview(JSON.stringify(value));
471
+ }
472
+ finally {
473
+ if (callId)
474
+ this.executing.delete(callId);
475
+ }
476
+ }, {
477
+ name: definitionTool.name,
478
+ description: definitionTool.description ?? '',
479
+ schema: asJsonSchema(definitionTool.inputSchema)
480
+ });
481
+ }
482
+ recordToolResult(callId, result) {
483
+ const current = this.calls.find((call) => call.id === callId);
484
+ if (!current || this.settled.has(callId) || this.disposed)
485
+ return;
486
+ const content = messageText(result);
487
+ if (current.startedAt)
488
+ this.updateCall(callId, { completedAt: new Date().toISOString() });
489
+ if (current.status === 'denied') {
490
+ this.updateCall(callId, { resultPreview: resultPreview(content) });
491
+ }
492
+ else if (result.status === 'error') {
493
+ this.updateCall(callId, {
494
+ status: 'error',
495
+ error: errorFromContent(content),
496
+ resultPreview: resultPreview(content)
497
+ });
498
+ }
499
+ else {
500
+ this.updateCall(callId, { status: 'approved', resultPreview: content });
501
+ }
502
+ this.emit('tool_call_resolved', {
503
+ pendingToolCallId: callId,
504
+ pendingToolCall: this.calls.find((call) => call.id === callId)
505
+ });
506
+ }
507
+ // ---- Snapshot projection ----
508
+ onModelRunStart(runId) {
509
+ const streaming = this.turn?.streaming;
510
+ if (this.turn && streaming)
511
+ this.modelRuns.set(runId, { turn: this.turn, messageId: streaming.id });
512
+ }
513
+ onToken(token, runId) {
514
+ const origin = this.modelRuns.get(runId);
515
+ const streaming = this.turn?.streaming;
516
+ // A run abandoned by cancellation can keep emitting; only the current turn's message accepts text.
517
+ if (!origin || origin.turn !== this.turn || !streaming || streaming.id !== origin.messageId)
518
+ return;
519
+ if (!token)
520
+ return;
521
+ const delta = streaming.text ? token : token.trimStart();
522
+ if (!delta)
523
+ return;
524
+ if (streaming.reasoning && streaming.reasoning.durationMs === undefined)
525
+ streaming.reasoning.durationMs = Date.now() - streaming.reasoning.startedAt;
526
+ const offset = streaming.text.length;
527
+ streaming.text += delta;
528
+ this.publish();
529
+ this.emit('assistant_message_delta', {
530
+ turnId: this.turn.id,
531
+ messageId: streaming.id,
532
+ offset,
533
+ delta,
534
+ createdAt: streaming.createdAt,
535
+ revision: this.revision
536
+ });
537
+ }
538
+ onReasoning(text, part, runId) {
539
+ const origin = this.modelRuns.get(runId);
540
+ const streaming = this.turn?.streaming;
541
+ if (!origin || origin.turn !== this.turn || !streaming || streaming.id !== origin.messageId)
542
+ return;
543
+ const reasoning = (streaming.reasoning ??= { text: '', startedAt: Date.now() });
544
+ const newPart = reasoning.text !== '' && part !== undefined && part !== reasoning.part;
545
+ reasoning.part = part ?? reasoning.part;
546
+ const delta = reasoning.text ? (newPart ? `\n\n${text}` : text) : text.trimStart();
547
+ if (!delta)
548
+ return;
549
+ const offset = reasoning.text.length;
550
+ reasoning.text += delta;
551
+ this.publish();
552
+ this.emit('assistant_reasoning_delta', {
553
+ turnId: this.turn.id,
554
+ messageId: streaming.id,
555
+ offset,
556
+ delta,
557
+ createdAt: streaming.createdAt,
558
+ revision: this.revision
559
+ });
560
+ }
561
+ /** Keeps a finished model call's reasoning for its message, once the call has returned. */
562
+ keepReasoning(messageId) {
563
+ const reasoning = this.turn?.streaming?.reasoning;
564
+ if (this.turn?.streaming?.id !== messageId || !reasoning?.text.trim())
565
+ return;
566
+ reasoning.durationMs ??= Date.now() - reasoning.startedAt;
567
+ this.reasoningByMessageId.set(messageId, {
568
+ text: reasoning.text.trim(),
569
+ durationMs: reasoning.durationMs
570
+ });
571
+ }
572
+ onValues(values) {
573
+ // Interrupt chunks carry no state; keep the history from the last full update.
574
+ if (!Array.isArray(values.messages))
575
+ return;
576
+ this.history = values.messages;
577
+ const streaming = this.turn?.streaming;
578
+ if (streaming && this.history.some((message) => message.id === streaming.id))
579
+ this.turn.streaming = undefined;
580
+ this.publish();
581
+ }
582
+ publish() {
583
+ if (this.disposed)
584
+ return;
585
+ this.revision += 1;
586
+ const turn = this.turn;
587
+ const active = turn && (this.running || turn.paused);
588
+ const streaming = active ? turn.streaming : undefined;
589
+ this.projection = {
590
+ revision: this.revision,
591
+ messages: [
592
+ ...structuredClone(this.definition.initialMessages ?? []),
593
+ ...this.history.flatMap((message) => this.toUiMessage(message))
594
+ ],
595
+ pendingToolCalls: structuredClone(this.calls),
596
+ ...(active ? { activeTurnId: turn.id } : {}),
597
+ ...(streaming && (streaming.text || streaming.reasoning?.text)
598
+ ? {
599
+ streamingMessage: {
600
+ id: streaming.id,
601
+ turnId: turn.id,
602
+ text: streaming.text,
603
+ createdAt: streaming.createdAt,
604
+ ...(streaming.reasoning?.text
605
+ ? {
606
+ reasoning: {
607
+ text: streaming.reasoning.text,
608
+ ...(streaming.reasoning.durationMs !== undefined
609
+ ? { durationMs: streaming.reasoning.durationMs }
610
+ : {})
611
+ }
612
+ }
613
+ : {})
614
+ }
615
+ }
616
+ : {})
617
+ };
618
+ }
619
+ toUiMessage(message) {
620
+ const id = message.id ?? '';
621
+ const createdAt = this.createdAt.get(id) ?? new Date().toISOString();
622
+ if (HumanMessage.isInstance(message))
623
+ return [{ id, role: 'user', text: messageText(message), createdAt }];
624
+ if (AIMessage.isInstance(message)) {
625
+ const callIds = (message.tool_calls ?? []).map((call) => call.id ?? '');
626
+ const first = this.calls.find((call) => call.id === callIds[0]);
627
+ const suggestions = this.suggestionsByMessageId.get(id);
628
+ const reasoning = this.reasoningByMessageId.get(id);
629
+ if (!callIds.length)
630
+ return [
631
+ {
632
+ id,
633
+ role: 'assistant',
634
+ text: messageText(message),
635
+ createdAt,
636
+ ...(suggestions ? { suggestions } : {}),
637
+ ...(reasoning ? { reasoning } : {})
638
+ }
639
+ ];
640
+ return [
641
+ {
642
+ id,
643
+ role: 'assistant',
644
+ text: messageText(message),
645
+ createdAt,
646
+ ...(reasoning ? { reasoning } : {}),
647
+ pendingToolCallId: callIds[0],
648
+ pendingToolCallIds: callIds,
649
+ toolRequestServer: first?.server,
650
+ toolRequestName: first?.tool,
651
+ toolRequestPublicName: first?.publicToolName
652
+ }
653
+ ];
654
+ }
655
+ if (ToolMessage.isInstance(message)) {
656
+ const call = this.calls.find((item) => item.id === message.tool_call_id);
657
+ const content = messageText(message);
658
+ const toolId = id || `msg-${message.tool_call_id}`;
659
+ if (!this.createdAt.has(toolId))
660
+ this.createdAt.set(toolId, new Date().toISOString());
661
+ return [
662
+ {
663
+ id: toolId,
664
+ role: 'tool',
665
+ text: call?.error && message.status === 'error'
666
+ ? `Tool error (${call.server}::${call.tool}): ${call.error}`
667
+ : (call?.resultPreview ?? content),
668
+ createdAt: this.createdAt.get(toolId)
669
+ }
670
+ ];
671
+ }
672
+ return [];
673
+ }
674
+ // ---- Helpers ----
675
+ track(operation) {
676
+ const running = operation.finally(() => {
677
+ if (this.running === running) {
678
+ this.running = undefined;
679
+ this.publish();
680
+ }
681
+ });
682
+ this.running = running;
683
+ return running;
684
+ }
685
+ endTurn() {
686
+ this.modelRuns.clear();
687
+ this.turn = undefined;
688
+ this.publish();
689
+ }
690
+ lastAssistantText() {
691
+ const last = [...this.history].reverse().find((message) => AIMessage.isInstance(message));
692
+ return last ? messageText(last) : '';
693
+ }
694
+ updateCall(id, update) {
695
+ const index = this.calls.findIndex((call) => call.id === id);
696
+ if (index < 0)
697
+ return;
698
+ this.calls[index] = { ...this.calls[index], ...update };
699
+ this.publish();
700
+ }
701
+ emit(type, payload = {}) {
702
+ if (this.disposed)
703
+ return;
704
+ try {
705
+ this.definition.onEvent?.({
706
+ type,
707
+ ts: new Date().toISOString(),
708
+ payload: structuredClone(payload)
709
+ });
710
+ }
711
+ catch {
712
+ // Observers (SSE broadcast) must not break the conversation.
713
+ }
714
+ }
715
+ assertUsable() {
716
+ if (this.disposed)
717
+ throw new Error('Assistant session is disposed');
718
+ }
719
+ }
720
+ /** Receives model tokens in order; attached to each model so a token is delivered once. */
721
+ class TokenSink extends BaseCallbackHandler {
722
+ onStart;
723
+ onToken;
724
+ onReasoning;
725
+ name = 'mcplab_assistant_tokens';
726
+ awaitHandlers = true;
727
+ lc_prefer_streaming = true;
728
+ constructor(onStart, onToken, onReasoning) {
729
+ super();
730
+ this.onStart = onStart;
731
+ this.onToken = onToken;
732
+ this.onReasoning = onReasoning;
733
+ }
734
+ handleChatModelStart(_llm, _messages, runId) {
735
+ this.onStart(runId);
736
+ }
737
+ handleLLMNewToken(token, _index, runId, _parentRunId, _tags, fields) {
738
+ // Reasoning arrives as standard content blocks next to (empty) answer text.
739
+ for (const block of reasoningBlocks(fields?.chunk))
740
+ this.onReasoning(block.text, block.part, runId);
741
+ this.onToken(token, runId);
742
+ }
743
+ }
744
+ function reasoningBlocks(chunk) {
745
+ const content = isRecord(chunk) && isRecord(chunk.message) ? chunk.message.content : undefined;
746
+ if (!Array.isArray(content))
747
+ return [];
748
+ return content.flatMap((block) => isRecord(block) && block.type === 'reasoning' && typeof block.reasoning === 'string'
749
+ ? [
750
+ {
751
+ text: block.reasoning,
752
+ ...(typeof block.index === 'number' ? { part: block.index } : {})
753
+ }
754
+ ]
755
+ : []);
756
+ }
757
+ /** Readable labels for argument fields, where the input schema gives property titles. */
758
+ function schemaFieldTitles(schema) {
759
+ const properties = isRecord(schema) && isRecord(schema.properties) ? schema.properties : {};
760
+ const titles = Object.entries(properties).flatMap(([key, property]) => isRecord(property) && typeof property.title === 'string' && property.title.trim()
761
+ ? [[key, property.title.trim()]]
762
+ : []);
763
+ return titles.length ? Object.fromEntries(titles) : undefined;
764
+ }
765
+ /** LangChain wraps errors raised in middleware; callers need the original (status codes, categories). */
766
+ function unwrapMiddlewareError(error) {
767
+ let current = error;
768
+ while (current instanceof MiddlewareError && current.cause !== undefined)
769
+ current = current.cause;
770
+ return current;
771
+ }
772
+ /** Error results always reach the model as {"error": ...}, whichever layer produced them. */
773
+ function withJsonErrorContent(result) {
774
+ if (result.status !== 'error')
775
+ return result;
776
+ const text = messageText(result);
777
+ try {
778
+ if (isRecord(JSON.parse(text)))
779
+ return result;
780
+ }
781
+ catch {
782
+ // Plain-text error from LangChain's tool node.
783
+ }
784
+ return new ToolMessage({
785
+ content: JSON.stringify({ error: text }),
786
+ tool_call_id: result.tool_call_id,
787
+ name: result.name,
788
+ status: 'error',
789
+ ...(result.id ? { id: result.id } : {})
790
+ });
791
+ }
792
+ function errorFromContent(content) {
793
+ try {
794
+ const parsed = JSON.parse(content);
795
+ if (isRecord(parsed) && typeof parsed.error === 'string')
796
+ return parsed.error;
797
+ }
798
+ catch {
799
+ // Not JSON; use the text as-is.
800
+ }
801
+ return content;
802
+ }
803
+ function errorText(error) {
804
+ return error instanceof Error ? error.message : String(error);
805
+ }
806
+ function settleFeedback(reason, wasExecuting, error) {
807
+ if (reason === 'budget')
808
+ return 'Assistant turn model-call budget exhausted';
809
+ if (reason === 'superseded')
810
+ return wasExecuting
811
+ ? 'Tool execution outcome unknown because a new user message superseded the turn'
812
+ : 'Tool call skipped because a new user message superseded the turn';
813
+ if (reason === 'cancelled')
814
+ return wasExecuting
815
+ ? 'Tool execution outcome unknown because cancellation interrupted the response'
816
+ : 'Tool call cancelled before execution';
817
+ const detail = error instanceof Error ? error.message : String(error ?? '');
818
+ return detail || 'Assistant turn failed before the tool call completed';
819
+ }
820
+ /** Anthropic replays tool_use content blocks, so their IDs must match the normalized calls. */
821
+ function withNormalizedToolUseBlocks(content, calls) {
822
+ if (!Array.isArray(content))
823
+ return content;
824
+ let index = 0;
825
+ return content.map((block) => {
826
+ if (!isRecord(block) || block.type !== 'tool_use')
827
+ return block;
828
+ const call = calls[index++];
829
+ return call ? { ...block, id: call.id, input: call.args } : block;
830
+ });
831
+ }
832
+ function messageText(message) {
833
+ const content = message.content;
834
+ if (typeof content === 'string')
835
+ return content.trim();
836
+ if (!Array.isArray(content))
837
+ return '';
838
+ return content
839
+ .filter((part) => isRecord(part) && part.type === 'text' && typeof part.text === 'string')
840
+ .map((part) => part.text)
841
+ .join('')
842
+ .trim();
843
+ }
844
+ function asJsonSchema(schema) {
845
+ if (isRecord(schema))
846
+ return { ...schema, type: schema.type ?? 'object' };
847
+ return { type: 'object', properties: {}, additionalProperties: true };
848
+ }
849
+ function isRecord(value) {
850
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
851
+ }
852
+ //# sourceMappingURL=langchain-assistant-runtime.js.map