@lenne.tech/nest-server 11.40.0 → 11.41.1
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/.claude/rules/configurable-features.md +2 -0
- package/.claude/rules/testing.md +3 -3
- package/FRAMEWORK-API.md +2 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
- package/dist/core/modules/ai/core-ai.controller.js +6 -0
- package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
- package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +3 -0
- package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
- package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +9 -1
- package/dist/core/modules/ai/providers/openai-compatible.provider.js +130 -12
- package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
- package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
- package/dist/core/modules/ai/services/core-ai.service.js +131 -13
- package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/migration-guides/11.40.0-to-11.41.0.md +118 -0
- package/migration-guides/11.41.0-to-11.41.1.md +114 -0
- package/package.json +1 -1
- package/src/core/common/interfaces/server-options.interface.ts +17 -0
- package/src/core/modules/ai/README.md +26 -5
- package/src/core/modules/ai/core-ai.controller.ts +16 -0
- package/src/core/modules/ai/interfaces/llm-provider.interface.ts +39 -0
- package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
- package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
- package/src/core/modules/ai/providers/openai-compatible.provider.ts +380 -17
- package/src/core/modules/ai/services/core-ai.service.ts +333 -23
|
@@ -65,6 +65,15 @@ export interface AiRunContext {
|
|
|
65
65
|
currentUser: ServiceOptions['currentUser'];
|
|
66
66
|
history: { content: string; role: string }[];
|
|
67
67
|
language?: string;
|
|
68
|
+
/**
|
|
69
|
+
* Optional per-action hook, invoked as soon as a tool action completes.
|
|
70
|
+
*
|
|
71
|
+
* Exists so a streaming caller can surface progress WHILE the agent loop runs.
|
|
72
|
+
* Without it `promptStream` can only emit the action list once the whole run has
|
|
73
|
+
* resolved, which makes the "watch the agent work" affordance purely decorative:
|
|
74
|
+
* every action arrives in one burst after the work is already done.
|
|
75
|
+
*/
|
|
76
|
+
onAction?: (action: CoreAiAction) => void;
|
|
68
77
|
provider: import('../interfaces/llm-provider.interface').ILlmProvider;
|
|
69
78
|
tenantId?: string;
|
|
70
79
|
tools: IAiTool[];
|
|
@@ -123,7 +132,11 @@ export class CoreAiService {
|
|
|
123
132
|
* Nutzer mit ID `{{userId}}` …" gets the real value substituted at run time.
|
|
124
133
|
* Unknown tokens are left as-is so plain text with curly braces survives.
|
|
125
134
|
*/
|
|
126
|
-
async prompt(
|
|
135
|
+
async prompt(
|
|
136
|
+
input: CoreAiPromptInput,
|
|
137
|
+
serviceOptions: ServiceOptions,
|
|
138
|
+
hooks?: { onAction?: (action: CoreAiAction) => void },
|
|
139
|
+
): Promise<CoreAiResponse> {
|
|
127
140
|
const mode = input.mode || ConfigService.get<string>('ai.defaultMode') || 'auto';
|
|
128
141
|
const resolvedInput = await this.resolvePromptPlaceholders(input, serviceOptions);
|
|
129
142
|
const run = await this.prepareRun(resolvedInput, serviceOptions);
|
|
@@ -131,6 +144,9 @@ export class CoreAiService {
|
|
|
131
144
|
if (!run) {
|
|
132
145
|
return this.unavailableResponse(resolvedInput, serviceOptions?.language);
|
|
133
146
|
}
|
|
147
|
+
if (hooks?.onAction) {
|
|
148
|
+
run.onAction = hooks.onAction;
|
|
149
|
+
}
|
|
134
150
|
const response = mode === 'plan' ? await this.runPlan(resolvedInput, run) : await this.runAuto(resolvedInput, run);
|
|
135
151
|
// Attach the compact token-budget summary after the run was recorded.
|
|
136
152
|
await this.attachBudgetSummary(response, run);
|
|
@@ -185,6 +201,50 @@ export class CoreAiService {
|
|
|
185
201
|
return response;
|
|
186
202
|
}
|
|
187
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Connection ids whose capability detection just failed, with the timestamp after
|
|
206
|
+
* which they may be probed again. Process-local and deliberately so: it holds
|
|
207
|
+
* nothing worth sharing, entries expire on their own, and a multi-replica
|
|
208
|
+
* deployment re-probing once per replica is exactly the intended cost.
|
|
209
|
+
*/
|
|
210
|
+
protected readonly detectionBackoff = new Map<string, number>();
|
|
211
|
+
|
|
212
|
+
/** How long a thrown capability detection suppresses the next attempt. */
|
|
213
|
+
protected readonly detectionBackoffMs = 5 * 60 * 1000;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* True while a recent detection failure still suppresses the next attempt.
|
|
217
|
+
*
|
|
218
|
+
* Checks the STORED DEADLINE rather than mere presence: a bare `has()` would keep
|
|
219
|
+
* a connection suppressed until some unrelated failure happened to sweep the map,
|
|
220
|
+
* turning a five-minute backoff into a permanent one.
|
|
221
|
+
*/
|
|
222
|
+
protected detectionSuppressed(connectionId: string): boolean {
|
|
223
|
+
const until = this.detectionBackoff.get(connectionId);
|
|
224
|
+
if (until === undefined) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
if (until > Date.now()) {
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
this.detectionBackoff.delete(connectionId);
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Record a failed detection and drop expired entries in the same pass, so the map
|
|
236
|
+
* cannot grow with connection ids that were retried long ago.
|
|
237
|
+
*/
|
|
238
|
+
protected rememberFailedDetection(connectionId: string): void {
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [id, until] of this.detectionBackoff) {
|
|
241
|
+
if (until <= now) {
|
|
242
|
+
this.detectionBackoff.delete(id);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.detectionBackoff.set(connectionId, now + this.detectionBackoffMs);
|
|
246
|
+
}
|
|
247
|
+
|
|
188
248
|
/**
|
|
189
249
|
* Common per-run setup: rate limit, budget, connection, provider, role-filtered
|
|
190
250
|
* tools, tool context and conversation history.
|
|
@@ -225,9 +285,22 @@ export class CoreAiService {
|
|
|
225
285
|
(connection.supportsJsonResponse === undefined ||
|
|
226
286
|
connection.supportsNativeTools === undefined ||
|
|
227
287
|
connection.contextWindow === undefined) &&
|
|
228
|
-
typeof this.connectionService?.detectAndPersistCapabilities === 'function'
|
|
288
|
+
typeof this.connectionService?.detectAndPersistCapabilities === 'function' &&
|
|
289
|
+
!this.detectionSuppressed(connection.id)
|
|
229
290
|
) {
|
|
230
|
-
|
|
291
|
+
const detected = await this.connectionService.detectAndPersistCapabilities(connection.id).catch(() => undefined);
|
|
292
|
+
if (detected) {
|
|
293
|
+
connection = detected;
|
|
294
|
+
} else {
|
|
295
|
+
// Detection THREW, so nothing was persisted — deliberately, because a
|
|
296
|
+
// transient blip must not pin a wrong flag forever. Without a backoff that
|
|
297
|
+
// correctness costs a re-probe on EVERY subsequent prompt, ahead of the rate
|
|
298
|
+
// limiter and outside budget accounting: an endpoint that answers the first
|
|
299
|
+
// probe and then times out on the retry burns a full timeout per user prompt,
|
|
300
|
+
// indefinitely. Suppress re-detection briefly instead — still nothing
|
|
301
|
+
// persisted, still self-healing, but bounded.
|
|
302
|
+
this.rememberFailedDetection(connection.id);
|
|
303
|
+
}
|
|
231
304
|
}
|
|
232
305
|
|
|
233
306
|
await this.checkRateLimit(currentUser?.id);
|
|
@@ -326,6 +399,33 @@ export class CoreAiService {
|
|
|
326
399
|
},
|
|
327
400
|
);
|
|
328
401
|
const toolSchemas = this.promptBuilder.buildToolSchemas(tools);
|
|
402
|
+
// The JSON output contract belongs to the EMULATED protocol only: `output_contract`
|
|
403
|
+
// and `tool_protocol_emulated` both carry `capability: 'emulated'`, so a NATIVE-tools
|
|
404
|
+
// run is asked for PROSE and is handed no JSON shape to fill. Forcing
|
|
405
|
+
// `response_format: {type:'json_object'}` on top is a contradiction the model can only
|
|
406
|
+
// resolve by inventing a shape of its own — which the final-answer branch below then
|
|
407
|
+
// has to catch. Measured against an OpenAI-compatible hosting endpoint on
|
|
408
|
+
// 2026-09-03 (`Mistral-Medium-3.5-128B`, varying only `response_format` and the
|
|
409
|
+
// offered tools):
|
|
410
|
+
//
|
|
411
|
+
// native + on -> `{"answer":"A clear sky is blue."}`, and with tools offered
|
|
412
|
+
// `{"name":"<a_tool>","parameters":{}}` in `content` with NO
|
|
413
|
+
// native `tool_calls` at all — the tool call is LOST, not merely
|
|
414
|
+
// mis-rendered (the run ends at `iterations=1, actions=0`)
|
|
415
|
+
// native + off -> prose plus real `tool_calls`
|
|
416
|
+
// emulated + on -> `{"tool_calls":[…]}`
|
|
417
|
+
// emulated + off -> `{"tool_calls":[…]}`
|
|
418
|
+
//
|
|
419
|
+
// Harmful in exactly ONE cell, and both emulated cells work — so narrow per call
|
|
420
|
+
// rather than clearing the connection flag, which would cost the emulated protocol
|
|
421
|
+
// its deterministic parse for no gain.
|
|
422
|
+
//
|
|
423
|
+
// `capability: 'emulated'` is a DEFAULT, not an invariant: an admin can point a
|
|
424
|
+
// prompt slot at `native` via `CoreAiSlotService`, and such a run would then be asked
|
|
425
|
+
// for JSON while this narrows it off. The degradation is graceful — prompt-driven
|
|
426
|
+
// JSON is the fallback the module is built around, and `extractJsonObject` is
|
|
427
|
+
// lenient — so the gate is well-correlated rather than exact.
|
|
428
|
+
const jsonMode = provider.capabilities.nativeTools ? { jsonResponse: false } : {};
|
|
329
429
|
|
|
330
430
|
const messages: LlmMessage[] = [{ content: systemPrompt, role: 'system' }];
|
|
331
431
|
for (const turn of history) {
|
|
@@ -339,6 +439,7 @@ export class CoreAiService {
|
|
|
339
439
|
});
|
|
340
440
|
|
|
341
441
|
const maxIterations = ConfigService.get<number>('ai.maxIterations') ?? 5;
|
|
442
|
+
const maxRunMs = ConfigService.get<number>('ai.maxRunMs') ?? 0;
|
|
342
443
|
const confirm = !!input.confirm;
|
|
343
444
|
const actions: CoreAiAction[] = [];
|
|
344
445
|
const pendingActions: CoreAiAction[] = [];
|
|
@@ -350,13 +451,30 @@ export class CoreAiService {
|
|
|
350
451
|
let pendingQuestion: { options?: { label: string; value: string }[]; question: string } | undefined;
|
|
351
452
|
let requiresConfirmation = false;
|
|
352
453
|
|
|
454
|
+
// `maxRunMs` is the wall-clock ceiling for the whole run. `maxIterations` alone
|
|
455
|
+
// does not bound the time: each iteration carries the provider's PER-CALL
|
|
456
|
+
// timeout (120 s by default) and compaction can add another call on top, so the
|
|
457
|
+
// iteration cap multiplies rather than limits the duration. Checked before
|
|
458
|
+
// starting an iteration — never mid-call, so an in-flight completion is always
|
|
459
|
+
// consumed rather than paid for and thrown away.
|
|
460
|
+
const runStartedAt = Date.now();
|
|
461
|
+
const runDeadlineExceeded = () => maxRunMs > 0 && Date.now() - runStartedAt >= maxRunMs;
|
|
462
|
+
|
|
353
463
|
while (iterations < maxIterations) {
|
|
464
|
+
if (iterations > 0 && runDeadlineExceeded()) {
|
|
465
|
+
this.logger.warn(
|
|
466
|
+
`AI run stopped after ${Date.now() - runStartedAt}ms (ai.maxRunMs=${maxRunMs}) at iteration ` +
|
|
467
|
+
`${iterations}/${maxIterations} — answering with what the run has so far`,
|
|
468
|
+
);
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
354
471
|
iterations++;
|
|
355
472
|
// Keep the session within the model's context window before every call:
|
|
356
473
|
// LLM-driven compaction first (summarize), then hard trim as a fallback.
|
|
357
474
|
await this.compactMessages(messages, connection);
|
|
358
475
|
this.fitMessagesToContext(messages, connection);
|
|
359
476
|
const completion = await provider.chat(messages, toolSchemas, {
|
|
477
|
+
...jsonMode,
|
|
360
478
|
maxTokens: connection.defaultMaxTokens,
|
|
361
479
|
temperature: connection.defaultTemperature,
|
|
362
480
|
});
|
|
@@ -434,6 +552,7 @@ export class CoreAiService {
|
|
|
434
552
|
for (const call of toolCalls) {
|
|
435
553
|
const action = await this.executeToolCall(call, tools, context, input);
|
|
436
554
|
actions.push(action);
|
|
555
|
+
run.onAction?.(action);
|
|
437
556
|
results.push({ name: action.name, result: action.result, success: action.success });
|
|
438
557
|
// Detect the ask_user_question sentinel — model paused to clarify with the user.
|
|
439
558
|
const sentinel = this.extractAskUserQuestion(action);
|
|
@@ -458,25 +577,40 @@ export class CoreAiService {
|
|
|
458
577
|
|
|
459
578
|
// No tool calls → this is the final answer.
|
|
460
579
|
const parsed = this.extractJsonObject(completion.text);
|
|
580
|
+
const residue = this.isProtocolResidue(completion.text, parsed, this.hitOutputCeiling(completion));
|
|
461
581
|
if (parsed && typeof parsed.final === 'string') {
|
|
462
582
|
finalText = parsed.final;
|
|
463
583
|
finalData = parsed.data ?? undefined;
|
|
464
|
-
} else if (
|
|
465
|
-
// The model
|
|
466
|
-
//
|
|
467
|
-
//
|
|
584
|
+
} else if (residue && !nudgedForFinal && iterations < maxIterations) {
|
|
585
|
+
// The model returned a bare JSON value where a user-facing answer belongs:
|
|
586
|
+
// an empty `{"tool_calls":[]}` batch, an echoed TOOL_RESULTS blob, or a
|
|
587
|
+
// half-formed tool call. Nudge once for a real answer instead of leaking the
|
|
588
|
+
// raw protocol JSON to the user. The wording follows the contract this run
|
|
589
|
+
// actually gave the model — telling an EMULATED run to drop the JSON would
|
|
590
|
+
// contradict its own output contract.
|
|
591
|
+
//
|
|
592
|
+
// The off-contract output is NOT fed back verbatim. The tool-call path above
|
|
593
|
+
// refuses that for a documented reason ("never the raw text, which may carry
|
|
594
|
+
// […] a model-hallucinated TOOL_RESULTS block"), and this branch's own input
|
|
595
|
+
// may be exactly such a blob — re-admitting it as an accepted assistant turn
|
|
596
|
+
// would let the next answer be grounded in data no tool returned. The nudge
|
|
597
|
+
// needs the TURN, not its content.
|
|
468
598
|
nudgedForFinal = true;
|
|
469
|
-
messages.push({ content:
|
|
599
|
+
messages.push({ content: '(previous reply was machine output, not an answer)', role: 'assistant' });
|
|
470
600
|
messages.push({
|
|
471
|
-
content:
|
|
601
|
+
content: provider.capabilities.nativeTools
|
|
602
|
+
? 'That was not an answer for the user. Reply now in plain natural language — no JSON, no code fences.'
|
|
603
|
+
: 'You did not request any tool. Now reply with your final answer ONLY as {"final":"<your answer>"}.',
|
|
472
604
|
role: 'user',
|
|
473
605
|
});
|
|
474
606
|
continue;
|
|
475
607
|
} else {
|
|
476
|
-
// Plain-text answer — but
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
608
|
+
// Plain-text answer — but a bare JSON value is protocol residue, never
|
|
609
|
+
// something to show a user, so drop it and let the generic fallback message
|
|
610
|
+
// apply instead of leaking JSON. A whitespace-only answer is dropped for the
|
|
611
|
+
// same reason: it is TRUTHY, so leaving it in place would defeat the
|
|
612
|
+
// `no_final_answer` fallback below and render an empty chat bubble.
|
|
613
|
+
finalText = residue || !completion.text?.trim() ? '' : completion.text;
|
|
480
614
|
}
|
|
481
615
|
break;
|
|
482
616
|
}
|
|
@@ -615,15 +749,26 @@ export class CoreAiService {
|
|
|
615
749
|
}
|
|
616
750
|
messages.push({ content: `TOOL_RESULTS:\n${this.capToolResults(JSON.stringify(results))}`, role: 'user' });
|
|
617
751
|
|
|
618
|
-
// 5. Final summary call.
|
|
752
|
+
// 5. Final summary call. The plan prompt's JSON contract covers the PLAN only —
|
|
753
|
+
// `buildPlanSystemPrompt` drops `output_contract` — so the summary is a
|
|
754
|
+
// natural-language answer and must not be forced into JSON.
|
|
619
755
|
this.fitMessagesToContext(messages, connection);
|
|
620
|
-
const finalCompletion = await provider.chat(messages, [], chatOptions);
|
|
756
|
+
const finalCompletion = await provider.chat(messages, [], { ...chatOptions, jsonResponse: false });
|
|
621
757
|
this.accumulateUsage(usage, finalCompletion);
|
|
758
|
+
// Same residue guard as `runAuto`: this call sits directly after a TOOL_RESULTS
|
|
759
|
+
// push, so an echo of those results is the likeliest off-contract output — and
|
|
760
|
+
// it must not reach the user verbatim. Plan mode has no nudge loop, so a bare
|
|
761
|
+
// value degrades straight to the plan's own summary.
|
|
622
762
|
const finalParsed = this.extractJsonObject(finalCompletion.text);
|
|
763
|
+
const finalResidue = this.isProtocolResidue(
|
|
764
|
+
finalCompletion.text,
|
|
765
|
+
finalParsed,
|
|
766
|
+
this.hitOutputCeiling(finalCompletion),
|
|
767
|
+
);
|
|
623
768
|
const finalText =
|
|
624
769
|
finalParsed && typeof finalParsed.final === 'string'
|
|
625
770
|
? finalParsed.final
|
|
626
|
-
: finalCompletion.text || parsed?.summary || this.translate('done', language);
|
|
771
|
+
: (finalResidue ? '' : finalCompletion.text) || parsed?.summary || this.translate('done', language);
|
|
627
772
|
|
|
628
773
|
const response = this.baseResponse(connection.id, input);
|
|
629
774
|
response.actions = actions;
|
|
@@ -643,15 +788,65 @@ export class CoreAiService {
|
|
|
643
788
|
* (for SSE). Emits `action` events for executed tools, then the answer as
|
|
644
789
|
* `token` chunks, then a `final` event with the full response.
|
|
645
790
|
*
|
|
646
|
-
* Note: the
|
|
647
|
-
*
|
|
648
|
-
*
|
|
791
|
+
* Note: the ANSWER is not token-streamed from the model — the agent loop has to
|
|
792
|
+
* run to completion first (emulated tool calling needs the full model output to
|
|
793
|
+
* detect tool calls, and the final answer arrives wrapped in JSON), so the text is
|
|
794
|
+
* chunked after the fact. `action` events, however, ARE emitted as each tool
|
|
795
|
+
* completes: they are the only genuine real-time signal a caller gets, and on a
|
|
796
|
+
* multi-step turn they are the difference between a silent minute and visible
|
|
797
|
+
* progress.
|
|
649
798
|
*/
|
|
650
799
|
async *promptStream(input: CoreAiPromptInput, serviceOptions: ServiceOptions): AsyncGenerator<AiStreamEvent> {
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
800
|
+
// Bridge the callback-style hook into the generator: tools complete while we are
|
|
801
|
+
// awaiting the run, so buffer them and hand them out whenever the consumer asks.
|
|
802
|
+
const pending: CoreAiAction[] = [];
|
|
803
|
+
let notify: (() => void) | undefined;
|
|
804
|
+
const runPromise = this.prompt(input, serviceOptions, {
|
|
805
|
+
onAction: (action) => {
|
|
806
|
+
pending.push(action);
|
|
807
|
+
notify?.();
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
// Held in an object rather than a plain `let`: it is assigned from the promise
|
|
812
|
+
// callbacks below, i.e. never inside the loop body, which reads to a linter as an
|
|
813
|
+
// unmodified loop condition.
|
|
814
|
+
const run = { settled: false };
|
|
815
|
+
const settle: Promise<{ error?: Error; response?: CoreAiResponse }> = runPromise.then(
|
|
816
|
+
(response) => {
|
|
817
|
+
run.settled = true;
|
|
818
|
+
notify?.();
|
|
819
|
+
return { response };
|
|
820
|
+
},
|
|
821
|
+
(error) => {
|
|
822
|
+
run.settled = true;
|
|
823
|
+
notify?.();
|
|
824
|
+
return { error: error as Error };
|
|
825
|
+
},
|
|
826
|
+
);
|
|
827
|
+
|
|
828
|
+
for (;;) {
|
|
829
|
+
while (pending.length) {
|
|
830
|
+
yield { action: pending.shift() as CoreAiAction, type: 'action' };
|
|
831
|
+
}
|
|
832
|
+
if (run.settled) {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
// Wake on the next action OR on the run finishing, whichever comes first.
|
|
836
|
+
await new Promise<void>((resolve) => {
|
|
837
|
+
notify = resolve;
|
|
838
|
+
if (run.settled || pending.length) {
|
|
839
|
+
resolve();
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
notify = undefined;
|
|
654
843
|
}
|
|
844
|
+
|
|
845
|
+
const outcome = await settle;
|
|
846
|
+
if (outcome.error) {
|
|
847
|
+
throw outcome.error;
|
|
848
|
+
}
|
|
849
|
+
const response = outcome.response as CoreAiResponse;
|
|
655
850
|
for (const token of this.chunkText(response.text)) {
|
|
656
851
|
yield { token, type: 'token' };
|
|
657
852
|
}
|
|
@@ -1062,6 +1257,116 @@ export class CoreAiService {
|
|
|
1062
1257
|
return undefined;
|
|
1063
1258
|
}
|
|
1064
1259
|
|
|
1260
|
+
/**
|
|
1261
|
+
* True when the completion stopped because it ran out of output budget
|
|
1262
|
+
* (`finish_reason: 'length'`) rather than because the model was done.
|
|
1263
|
+
*
|
|
1264
|
+
* The distinction decides whether unparseable JSON is residue or prose — see
|
|
1265
|
+
* {@link isBareJsonValue}. Read defensively off the provider-agnostic `raw`
|
|
1266
|
+
* payload: not every backend reports a `finish_reason`, and an absent one must
|
|
1267
|
+
* read as "finished normally", never as "truncated".
|
|
1268
|
+
*/
|
|
1269
|
+
protected hitOutputCeiling(completion: LlmResponse): boolean {
|
|
1270
|
+
return (completion?.raw as { choices?: { finish_reason?: string }[] })?.choices?.[0]?.finish_reason === 'length';
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
/**
|
|
1274
|
+
* True when the answer is nothing but a bare JSON value — no prose around it and
|
|
1275
|
+
* no markdown fence.
|
|
1276
|
+
*
|
|
1277
|
+
* Under either output contract that is protocol residue rather than an answer: the
|
|
1278
|
+
* emulated contract permits exactly `{"final":…}` / `{"tool_calls":…}` (both
|
|
1279
|
+
* handled before this check), and the native contract asks for prose.
|
|
1280
|
+
*
|
|
1281
|
+
* Three boundaries, each chosen deliberately:
|
|
1282
|
+
*
|
|
1283
|
+
* - **Arrays count too.** `{"success":true,"data":{"contacts":[…]}}` and
|
|
1284
|
+
* `[{"id":"…","displayName":"…"}]` are the same failure to a user, and a
|
|
1285
|
+
* `find_*` tool returns a collection — an echo of its result is as likely to
|
|
1286
|
+
* arrive as a bare array as wrapped in an object.
|
|
1287
|
+
* - **A truncated value counts, but only when the completion actually hit the
|
|
1288
|
+
* output ceiling** (`truncated`, i.e. `finish_reason: 'length'`). Tool-result
|
|
1289
|
+
* echoes are long, so running out of budget mid-object is their most likely
|
|
1290
|
+
* shape — and it neither closes its brace nor parses. Requiring the caller to
|
|
1291
|
+
* supply that fact keeps prose that merely opens with a brace safe.
|
|
1292
|
+
* - **A FENCED value is NOT matched.** A ```json block is how a model presents
|
|
1293
|
+
* JSON the user actually asked for; suppressing it would break the legitimate
|
|
1294
|
+
* "give me that as JSON" request.
|
|
1295
|
+
*
|
|
1296
|
+
* Deliberately stricter than {@link extractJsonObject}, which is lenient by design
|
|
1297
|
+
* so a protocol object survives surrounding noise. Leniency is right when looking
|
|
1298
|
+
* FOR the protocol and wrong when deciding what may reach the user: prose that
|
|
1299
|
+
* merely quotes a JSON snippet must pass through untouched.
|
|
1300
|
+
*/
|
|
1301
|
+
protected isBareJsonValue(text: string, truncated = false): boolean {
|
|
1302
|
+
const trimmed = (text ?? '').trim();
|
|
1303
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
try {
|
|
1307
|
+
JSON.parse(trimmed);
|
|
1308
|
+
return true;
|
|
1309
|
+
} catch {
|
|
1310
|
+
// Unparseable. Only a TRUNCATED answer is residue: the model began a machine
|
|
1311
|
+
// payload and ran out of output budget mid-object, which is the LIKELIEST
|
|
1312
|
+
// shape of an echoed tool result — those are long, so they hit the ceiling
|
|
1313
|
+
// before they close. Anything else that merely opens with a brace is prose
|
|
1314
|
+
// ("{Platzhalter} wird ersetzt durch …") and must reach the user untouched.
|
|
1315
|
+
return truncated;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* True when the ENTIRE answer is a markdown-fenced JSON value.
|
|
1321
|
+
*
|
|
1322
|
+
* The counterpart to {@link isBareJsonValue}: a fence is normally a deliberate
|
|
1323
|
+
* presentation choice and must reach the user. The one exception is our own
|
|
1324
|
+
* protocol vocabulary, which the model has no reason to present — see
|
|
1325
|
+
* {@link isProtocolResidue}.
|
|
1326
|
+
*/
|
|
1327
|
+
protected isFencedJsonValue(text: string): boolean {
|
|
1328
|
+
const fence = (text ?? '').trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
|
1329
|
+
const inner = fence?.[1]?.trim();
|
|
1330
|
+
if (!inner || (!inner.startsWith('{') && !inner.startsWith('['))) {
|
|
1331
|
+
return false;
|
|
1332
|
+
}
|
|
1333
|
+
try {
|
|
1334
|
+
JSON.parse(inner);
|
|
1335
|
+
return true;
|
|
1336
|
+
} catch {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
/**
|
|
1342
|
+
* True when the model's answer is machine output rather than something to show a
|
|
1343
|
+
* user. The union of two rules, because neither covers the other:
|
|
1344
|
+
*
|
|
1345
|
+
* 1. **Any BARE JSON value** ({@link isBareJsonValue}) — under either output
|
|
1346
|
+
* contract, an answer that is nothing but a JSON value is protocol residue.
|
|
1347
|
+
* 2. **A fenced value carrying our protocol vocabulary** — a `tool_calls` batch,
|
|
1348
|
+
* or a `final` that is not a string. A fence otherwise means "the user asked
|
|
1349
|
+
* for JSON", but no user asks for the orchestrator's own wire format.
|
|
1350
|
+
*
|
|
1351
|
+
* Rule 2 exists because the predecessor guard used the LENIENT
|
|
1352
|
+
* {@link extractJsonObject}, which strips fences — so it caught a fenced empty
|
|
1353
|
+
* `{"tool_calls":[]}` batch, the very case its comment named. Replacing it with the
|
|
1354
|
+
* bare-only check alone would have silently un-fixed that. `parsed` is passed in
|
|
1355
|
+
* rather than re-derived so the caller's single lenient parse is reused.
|
|
1356
|
+
*
|
|
1357
|
+
* What deliberately stays OUT of both rules: prose that merely quotes a JSON
|
|
1358
|
+
* snippet, which is a legitimate answer and was a false positive of the old
|
|
1359
|
+
* lenient-only check.
|
|
1360
|
+
*/
|
|
1361
|
+
protected isProtocolResidue(text: string, parsed: any | null, truncated = false): boolean {
|
|
1362
|
+
if (this.isBareJsonValue(text, truncated)) {
|
|
1363
|
+
return true;
|
|
1364
|
+
}
|
|
1365
|
+
const protocolShaped =
|
|
1366
|
+
!!parsed && ('tool_calls' in parsed || ('final' in parsed && typeof parsed.final !== 'string'));
|
|
1367
|
+
return protocolShaped && this.isFencedJsonValue(text);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1065
1370
|
/**
|
|
1066
1371
|
* Robustly extract a single JSON object from an LLM text response (tolerates
|
|
1067
1372
|
* markdown code fences and surrounding prose).
|
|
@@ -1206,7 +1511,12 @@ export class CoreAiService {
|
|
|
1206
1511
|
{ content: transcript, role: 'user' },
|
|
1207
1512
|
],
|
|
1208
1513
|
[],
|
|
1209
|
-
|
|
1514
|
+
// Prose, not JSON: the system prompt above asks for "strict prose, no markdown",
|
|
1515
|
+
// and a connection flagged `supportsJsonResponse` would otherwise make the
|
|
1516
|
+
// provider send `response_format: json_object` — so the summary came back
|
|
1517
|
+
// JSON-wrapped and was spliced into the transcript that way, which is the
|
|
1518
|
+
// opposite of what compaction is for.
|
|
1519
|
+
{ jsonResponse: false, temperature: 0 },
|
|
1210
1520
|
);
|
|
1211
1521
|
const text = (summary?.text || '').trim();
|
|
1212
1522
|
if (!text) {
|