@lenne.tech/nest-server 11.40.0 → 11.41.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 (27) hide show
  1. package/.claude/rules/configurable-features.md +2 -0
  2. package/FRAMEWORK-API.md +2 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +1 -0
  4. package/dist/core/modules/ai/core-ai.controller.js +6 -0
  5. package/dist/core/modules/ai/core-ai.controller.js.map +1 -1
  6. package/dist/core/modules/ai/interfaces/llm-provider.interface.d.ts +1 -0
  7. package/dist/core/modules/ai/models/core-ai-prompt.model.js +1 -1
  8. package/dist/core/modules/ai/models/core-ai-prompt.model.js.map +1 -1
  9. package/dist/core/modules/ai/models/core-ai-slot.model.js +1 -1
  10. package/dist/core/modules/ai/models/core-ai-slot.model.js.map +1 -1
  11. package/dist/core/modules/ai/providers/openai-compatible.provider.d.ts +4 -0
  12. package/dist/core/modules/ai/providers/openai-compatible.provider.js +78 -9
  13. package/dist/core/modules/ai/providers/openai-compatible.provider.js.map +1 -1
  14. package/dist/core/modules/ai/services/core-ai.service.d.ts +12 -1
  15. package/dist/core/modules/ai/services/core-ai.service.js +131 -13
  16. package/dist/core/modules/ai/services/core-ai.service.js.map +1 -1
  17. package/dist/tsconfig.build.tsbuildinfo +1 -1
  18. package/migration-guides/11.40.0-to-11.41.0.md +118 -0
  19. package/package.json +1 -1
  20. package/src/core/common/interfaces/server-options.interface.ts +17 -0
  21. package/src/core/modules/ai/README.md +26 -5
  22. package/src/core/modules/ai/core-ai.controller.ts +16 -0
  23. package/src/core/modules/ai/interfaces/llm-provider.interface.ts +15 -0
  24. package/src/core/modules/ai/models/core-ai-prompt.model.ts +10 -1
  25. package/src/core/modules/ai/models/core-ai-slot.model.ts +10 -1
  26. package/src/core/modules/ai/providers/openai-compatible.provider.ts +233 -12
  27. 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(input: CoreAiPromptInput, serviceOptions: ServiceOptions): Promise<CoreAiResponse> {
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
- connection = await this.connectionService.detectAndPersistCapabilities(connection.id).catch(() => connection);
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 (parsed && 'tool_calls' in parsed && !nudgedForFinal && iterations < maxIterations) {
465
- // The model emitted the protocol wrapper (e.g. an empty `{"tool_calls":[]}`
466
- // batch) but no user-facing answer. Nudge once for a proper final answer
467
- // instead of leaking the raw protocol JSON to the user.
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: completion.text, role: 'assistant' });
599
+ messages.push({ content: '(previous reply was machine output, not an answer)', role: 'assistant' });
470
600
  messages.push({
471
- content: 'You did not request any tool. Now reply with your final answer ONLY as {"final":"<your answer>"}.',
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 never surface a bare protocol wrapper. If the model
477
- // still returned only a `tool_calls`/`final`-shaped object, drop it so the
478
- // generic fallback message applies instead of leaking JSON.
479
- finalText = parsed && ('tool_calls' in parsed || 'final' in parsed) ? '' : completion.text;
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 agent/tool loop runs to completion first (emulated tool calling
647
- * needs the full model output to detect tool calls), then the final answer is
648
- * streamed in chunks. This gives a progressive UX without a second LLM call.
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
- const response = await this.prompt(input, serviceOptions);
652
- for (const action of response.actions ?? []) {
653
- yield { action, type: 'action' };
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
- { temperature: 0 },
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) {