@genesislcap/ai-assistant 15.21.0 → 15.21.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.
Files changed (29) hide show
  1. package/dist/ai-assistant.api.json +135 -1
  2. package/dist/ai-assistant.d.ts +152 -0
  3. package/dist/chat-driver.cjs +281 -81
  4. package/dist/chat-driver.cjs.map +2 -2
  5. package/dist/chat-driver.mjs +277 -81
  6. package/dist/chat-driver.mjs.map +2 -2
  7. package/dist/custom-elements.json +175 -8
  8. package/dist/dts/components/chat-driver/chat-driver.d.ts +39 -0
  9. package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
  10. package/dist/dts/components/orchestrating-driver/index.d.ts +2 -1
  11. package/dist/dts/components/orchestrating-driver/index.d.ts.map +1 -1
  12. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +101 -0
  13. package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
  14. package/dist/dts/config/fallback-agents.d.ts +7 -0
  15. package/dist/dts/config/fallback-agents.d.ts.map +1 -1
  16. package/dist/dts/state/debug-event-log.d.ts +1 -1
  17. package/dist/dts/state/debug-event-log.d.ts.map +1 -1
  18. package/dist/dts/utils/context-tokens.d.ts +25 -3
  19. package/dist/dts/utils/context-tokens.d.ts.map +1 -1
  20. package/dist/dts/utils/history-transform.d.ts +23 -3
  21. package/dist/dts/utils/history-transform.d.ts.map +1 -1
  22. package/dist/esm/components/chat-driver/chat-driver.js +72 -56
  23. package/dist/esm/components/orchestrating-driver/index.js +1 -1
  24. package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +266 -36
  25. package/dist/esm/config/fallback-agents.js +12 -1
  26. package/dist/esm/state/debug-event-log.js +7 -2
  27. package/dist/esm/utils/context-tokens.js +27 -3
  28. package/dist/esm/utils/history-transform.js +54 -5
  29. package/package.json +16 -16
@@ -2173,7 +2173,8 @@ ${att.content}` });
2173
2173
  return { images, texts };
2174
2174
  };
2175
2175
  for (const msg of history) {
2176
- if (msg.role === "system" || msg.role === "system-event" || msg.role === "synthetic-user" || msg.role === "compacted-summary" || msg.category === "reasoning" || msg.category === "narration" || msg.thinking)
2176
+ if (msg.role === "system" || msg.role === "system-event" || msg.role === "synthetic-user" || msg.role === "compacted-summary" || msg.category === "reasoning" || msg.category === "narration" || // Driver-authored prose no model produced — see ChatMessage.excludeFromProvider.
2177
+ msg.excludeFromProvider || msg.thinking)
2177
2178
  continue;
2178
2179
  if (msg.toolResult) {
2179
2180
  const resultImages = (_a = msg.toolResult.attachments) !== null && _a !== void 0 ? _a : [];
@@ -2436,12 +2437,16 @@ ${att.content}` });
2436
2437
  if (cost != null)
2437
2438
  base.cost = cost;
2438
2439
  base.model = servingModel;
2439
- if (response.stop_reason === "max_tokens") {
2440
- base.responseMeta = { finishReason: "max_tokens" };
2441
- }
2442
- if (response.stop_reason === "refusal") {
2443
- base.responseMeta = { finishReason: "refusal" };
2444
- }
2440
+ const responseMeta = {
2441
+ parts: {
2442
+ functionCall: blocks.filter((b) => b.type === "tool_use").length,
2443
+ thought: blocks.filter((b) => b.type === "thinking" || b.type === "redacted_thinking").length,
2444
+ text: blocks.filter((b) => b.type === "text").length
2445
+ }
2446
+ };
2447
+ if (response.stop_reason)
2448
+ responseMeta.finishReason = response.stop_reason;
2449
+ base.responseMeta = responseMeta;
2445
2450
  return base;
2446
2451
  }
2447
2452
  buildEndpoint() {
@@ -2990,7 +2995,8 @@ var GeminiTransport = class _GeminiTransport {
2990
2995
  const contents = [];
2991
2996
  const toolCallNameById = /* @__PURE__ */ new Map();
2992
2997
  for (const msg of history) {
2993
- if (msg.role === "system" || msg.role === "system-event" || msg.role === "synthetic-user" || msg.role === "compacted-summary" || msg.category === "reasoning" || msg.category === "narration" || msg.thinking)
2998
+ if (msg.role === "system" || msg.role === "system-event" || msg.role === "synthetic-user" || msg.role === "compacted-summary" || msg.category === "reasoning" || msg.category === "narration" || // Driver-authored prose no model produced — see ChatMessage.excludeFromProvider.
2999
+ msg.excludeFromProvider || msg.thinking)
2994
3000
  continue;
2995
3001
  const role = msg.role === "user" ? "user" : "model";
2996
3002
  if (msg.toolResult) {
@@ -3575,6 +3581,9 @@ var META_EVENT_IMPORTANCE = {
3575
3581
  "tool.unresolved": "normal",
3576
3582
  "subagent.started": "normal",
3577
3583
  "subagent.completed": "normal",
3584
+ // One per routing decision, with what the classifier answered and what the call cost. Same
3585
+ // level as the handoff it produces: you read them together to follow how a turn was routed.
3586
+ "routing.decided": "normal",
3578
3587
  "agent.handoff": "normal",
3579
3588
  "agent.pinned": "normal",
3580
3589
  "agent.unpinned": "normal",
@@ -3736,6 +3745,7 @@ var COMPACTION_SUMMARY_TOKEN_ESTIMATE = 1200;
3736
3745
  function reachesProvider(m) {
3737
3746
  if (m.role === "system-event" || m.role === "synthetic-user") return false;
3738
3747
  if (m.category === "reasoning" || m.category === "narration") return false;
3748
+ if (m.excludeFromProvider) return false;
3739
3749
  return !m.thinking;
3740
3750
  }
3741
3751
  function imageCount(m) {
@@ -4065,10 +4075,27 @@ function maskToolPayload(msg) {
4065
4075
  }
4066
4076
  return msg;
4067
4077
  }
4078
+ function foreignToolCallText(msg) {
4079
+ const names = (msg.toolCalls ?? []).map((tc) => tc.name).join(", ");
4080
+ const trace = names ? `[Earlier tool call: ${names}]` : "";
4081
+ return msg.content && trace ? `${msg.content}
4082
+ ${trace}` : msg.content || trace;
4083
+ }
4068
4084
  function transformHistoryForAgent(history, agentName) {
4085
+ const isForeign = (msg) => !!msg.agentName && msg.agentName !== agentName;
4086
+ const foreignCallIds = /* @__PURE__ */ new Set();
4087
+ for (const msg of history) {
4088
+ if (!isForeign(msg)) continue;
4089
+ for (const tc of msg.toolCalls ?? []) foreignCallIds.add(tc.id);
4090
+ if (msg.toolResult) foreignCallIds.add(msg.toolResult.toolCallId);
4091
+ }
4069
4092
  return history.map((msg) => {
4070
- if (!msg.agentName || msg.agentName === agentName) return msg;
4071
- return maskToolPayload(msg);
4093
+ const pairIsForeign = (msg.toolCalls ?? []).some((tc) => foreignCallIds.has(tc.id)) || (msg.toolResult ? foreignCallIds.has(msg.toolResult.toolCallId) : false);
4094
+ if (!pairIsForeign) return msg;
4095
+ if (msg.toolResult) {
4096
+ return { ...maskToolPayload(msg), excludeFromProvider: true };
4097
+ }
4098
+ return { ...msg, content: foreignToolCallText(msg), toolCalls: void 0 };
4072
4099
  });
4073
4100
  }
4074
4101
  function applyHistoryCap(history, cap) {
@@ -4272,6 +4299,8 @@ var DEFAULT_MAX_UNKNOWN_TOOL_CALLS = 5;
4272
4299
  var MAX_STALE_TOOL_CALLS = DEFAULT_MAX_UNKNOWN_TOOL_CALLS * 2;
4273
4300
  var MAX_MALFORMED_RETRIES = 5;
4274
4301
  var MAX_EMPTY_RESPONSE_RETRIES = 3;
4302
+ var MALFORMED_CALL_NUDGE = "IMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools.";
4303
+ var EMPTY_RESPONSE_NUDGE = "IMPORTANT: Your previous attempt returned nothing at all, which is never a valid response. Answer the user's last message now, in prose \u2014 even if all you can honestly say is that you cannot help with it and why. If a tool is the right way to answer, call it. Do not return an empty response.";
4275
4304
  var MAX_SETUP_TRANSPORT_RETRIES = 3;
4276
4305
  var SUGGESTIONS_HISTORY_WINDOW = 8;
4277
4306
  var HARVESTED_SUBAGENT_EVENTS = /* @__PURE__ */ new Set([
@@ -4658,7 +4687,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4658
4687
  spentUsd: e.spentUsd,
4659
4688
  isSubAgent: this.isSubAgent
4660
4689
  });
4661
- this.appendToHistory({ role: "assistant", content: this.budgetExhaustedBubble(e) });
4690
+ this.appendDriverNotice(this.budgetExhaustedBubble(e));
4662
4691
  return this.turnDone("budget-exhausted", budgetDetailOf(e));
4663
4692
  }
4664
4693
  /**
@@ -4694,7 +4723,7 @@ var ChatDriver = class _ChatDriver extends EventTarget {
4694
4723
  upstreamType: e.upstreamType,
4695
4724
  isSubAgent: this.isSubAgent
4696
4725
  });
4697
- this.appendToHistory({ role: "assistant", content: this.providerRefusedBubble() });
4726
+ this.appendDriverNotice(this.providerRefusedBubble());
4698
4727
  return this.turnDone("provider-refused", void 0, detail);
4699
4728
  }
4700
4729
  /** The typed failure reason on a loop result, or `undefined` for a clean turn / handoff. */
@@ -5218,12 +5247,12 @@ Output format (strict):
5218
5247
  summaryText = (await defaultProvider.prompt(userMessage, { systemPrompt }))?.trim() ?? "";
5219
5248
  } catch (e) {
5220
5249
  logger2.warn(`ChatDriver: compaction summary failed: ${e instanceof Error ? e.message : e}`);
5221
- this.appendToHistory({ role: "assistant", content: failMessage });
5250
+ this.appendDriverNotice(failMessage);
5222
5251
  return null;
5223
5252
  }
5224
5253
  if (!summaryText) {
5225
5254
  logger2.warn("ChatDriver: compaction summary was empty; leaving history untouched");
5226
- this.appendToHistory({ role: "assistant", content: failMessage });
5255
+ this.appendDriverNotice(failMessage);
5227
5256
  return null;
5228
5257
  }
5229
5258
  let tokensBefore;
@@ -5540,10 +5569,9 @@ Output format (strict):
5540
5569
  name: e instanceof Error ? e.name : void 0,
5541
5570
  message: e instanceof Error ? e.message : String(e)
5542
5571
  });
5543
- this.appendToHistory({
5544
- role: "assistant",
5545
- content: "Sorry, something went wrong on my end. Please try again in a moment."
5546
- });
5572
+ this.appendDriverNotice(
5573
+ "Sorry, something went wrong on my end. Please try again in a moment."
5574
+ );
5547
5575
  result = this.turnDone("exception");
5548
5576
  return result;
5549
5577
  } finally {
@@ -5805,10 +5833,9 @@ Output format (strict):
5805
5833
  name: e instanceof Error ? e.name : void 0,
5806
5834
  message: e instanceof Error ? e.message : String(e)
5807
5835
  });
5808
- this.appendToHistory({
5809
- role: "assistant",
5810
- content: "Sorry, something went wrong on my end. Please try again in a moment."
5811
- });
5836
+ this.appendDriverNotice(
5837
+ "Sorry, something went wrong on my end. Please try again in a moment."
5838
+ );
5812
5839
  result = this.turnDone("exception");
5813
5840
  return result;
5814
5841
  } finally {
@@ -5987,7 +6014,7 @@ Output format (strict):
5987
6014
  if (this.isSubAgent) {
5988
6015
  this.failSubAgent("provider_refused", void 0, this.providerRefusedDetail);
5989
6016
  } else {
5990
- this.appendToHistory({ role: "assistant", content: this.providerRefusedBubble() });
6017
+ this.appendDriverNotice(this.providerRefusedBubble());
5991
6018
  }
5992
6019
  return this.turnDone("provider-refused", void 0, this.providerRefusedDetail);
5993
6020
  }
@@ -6012,10 +6039,7 @@ Output format (strict):
6012
6039
  if (this.isSubAgent) {
6013
6040
  this.failSubAgent("budget_exhausted", this.budgetWallDetail);
6014
6041
  } else {
6015
- this.appendToHistory({
6016
- role: "assistant",
6017
- content: this.budgetExhaustedBubble(this.budgetWallDetail)
6018
- });
6042
+ this.appendDriverNotice(this.budgetExhaustedBubble(this.budgetWallDetail));
6019
6043
  }
6020
6044
  return this.turnDone("budget-exhausted", this.budgetWallDetail);
6021
6045
  }
@@ -6052,7 +6076,11 @@ Output format (strict):
6052
6076
  this.activeAgentLabel = await this.displayName(promptCtx);
6053
6077
  }
6054
6078
  const foldSuffix = this.buildFoldSystemPromptSuffix();
6055
- const retryNudge = malformedAttempts > 0 ? "\n\nIMPORTANT: Use only the structured function-call API to invoke tools. Do not write Python code or use Python-style syntax to call tools." : emptyResponseAttempts > 0 ? "\n\nIMPORTANT: You must respond to the user's message. Call the appropriate tool or provide a text response \u2014 do not return an empty response." : "";
6079
+ const retryNudge = malformedAttempts > 0 ? `
6080
+
6081
+ ${MALFORMED_CALL_NUDGE}` : emptyResponseAttempts > 0 ? `
6082
+
6083
+ ${EMPTY_RESPONSE_NUDGE}` : "";
6056
6084
  const frameworkSystemSuffix = `${foldSuffix}${retryNudge}`;
6057
6085
  const primer = normalizeForProvider([
6058
6086
  ...this.primerHistory ?? [],
@@ -6176,10 +6204,9 @@ ${tailBody}
6176
6204
  if (this.isSubAgent) {
6177
6205
  this.failSubAgent("context_exhausted");
6178
6206
  } else {
6179
- this.appendToHistory({
6180
- role: "assistant",
6181
- content: "I had to stop here \u2014 this conversation has filled the available context. Compact it to free up room, then ask me to continue."
6182
- });
6207
+ this.appendDriverNotice(
6208
+ "I had to stop here \u2014 this conversation has filled the available context. Compact it to free up room, then ask me to continue."
6209
+ );
6183
6210
  }
6184
6211
  return this.turnDone("context-exhausted");
6185
6212
  }
@@ -6214,10 +6241,9 @@ ${tailBody}
6214
6241
  if (this.isSubAgent) {
6215
6242
  this.failSubAgent("malformed_tool_call");
6216
6243
  } else {
6217
- this.appendToHistory({
6218
- role: "assistant",
6219
- content: "While working on your request, I repeatedly called my tools incorrectly. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6220
- });
6244
+ this.appendDriverNotice(
6245
+ "While working on your request, I repeatedly called my tools incorrectly. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6246
+ );
6221
6247
  }
6222
6248
  return this.turnDone("malformed-function-call");
6223
6249
  }
@@ -6235,10 +6261,9 @@ ${tailBody}
6235
6261
  if (this.isSubAgent) {
6236
6262
  this.failSubAgent("response_truncated");
6237
6263
  } else {
6238
- this.appendToHistory({
6239
- role: "assistant",
6240
- content: "My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once \u2014 try breaking your request into smaller steps."
6241
- });
6264
+ this.appendDriverNotice(
6265
+ "My response was cut off because a single step reached the model output limit. This usually means one step tried to produce too much at once \u2014 try breaking your request into smaller steps."
6266
+ );
6242
6267
  }
6243
6268
  return this.turnDone("response-truncated");
6244
6269
  }
@@ -6256,10 +6281,7 @@ ${tailBody}
6256
6281
  if (this.isSubAgent) {
6257
6282
  this.failSubAgent("context_exhausted");
6258
6283
  } else {
6259
- this.appendToHistory({
6260
- role: "assistant",
6261
- content: DEFAULT_CONTEXT_OVERFLOW_MESSAGE
6262
- });
6284
+ this.appendDriverNotice(DEFAULT_CONTEXT_OVERFLOW_MESSAGE);
6263
6285
  }
6264
6286
  return this.turnDone("context-exhausted");
6265
6287
  }
@@ -6312,10 +6334,9 @@ ${tailBody}
6312
6334
  if (this.isSubAgent) {
6313
6335
  this.failSubAgent("timeout");
6314
6336
  } else {
6315
- this.appendToHistory({
6316
- role: "assistant",
6317
- content: "The request timed out. You can ask me to try again, or break this into a smaller step."
6318
- });
6337
+ this.appendDriverNotice(
6338
+ "The request timed out. You can ask me to try again, or break this into a smaller step."
6339
+ );
6319
6340
  }
6320
6341
  return this.turnDone("exception");
6321
6342
  }
@@ -6355,6 +6376,11 @@ ${tailBody}
6355
6376
  provider: this.lastResolvedProviderName,
6356
6377
  attempt: emptyResponseAttempts,
6357
6378
  maxAttempts: MAX_EMPTY_RESPONSE_RETRIES,
6379
+ // How many tools the agent was holding. Every empty response in the session that
6380
+ // motivated this (GENC-1573) came from an agent with ZERO — a tool-less agent asked
6381
+ // something its prompt gives it no instruction for has nothing it is allowed to do,
6382
+ // and reads as a model fault unless this number is on the record.
6383
+ toolCount: this.toolDefinitions.length,
6358
6384
  // Provider diagnostic (Gemini: finishReason, thoughtsTokens, parts,
6359
6385
  // blockReason) so the timeline shows *why* the turn came back blank —
6360
6386
  // e.g. a high thoughtsTokens with a 'STOP' finish is "thought, then
@@ -6372,16 +6398,18 @@ ${tailBody}
6372
6398
  agent: this.activeAgentName,
6373
6399
  provider: this.lastResolvedProviderName,
6374
6400
  attempts: emptyResponseAttempts,
6401
+ // See the retry above: a zero here says the agent had no tools, which is most of the
6402
+ // explanation for a blank turn and is otherwise invisible.
6403
+ toolCount: this.toolDefinitions.length,
6375
6404
  isSubAgent: this.isSubAgent,
6376
6405
  ...response.responseMeta
6377
6406
  });
6378
6407
  if (this.isSubAgent) {
6379
6408
  this.failSubAgent(isRefusal ? "refusal" : "empty_response");
6380
6409
  } else {
6381
- this.appendToHistory({
6382
- role: "assistant",
6383
- content: isRefusal ? "This request was declined. Try rephrasing it, or breaking it into smaller steps." : "While working on your request, I repeatedly generated a blank response. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6384
- });
6410
+ this.appendDriverNotice(
6411
+ isRefusal ? "This request was declined. Try rephrasing it, or breaking it into smaller steps." : "While working on your request, I repeatedly generated a blank response. This often works on a second try \u2014 would you like me to try again? If it happens again, try breaking your request into smaller steps."
6412
+ );
6385
6413
  }
6386
6414
  return this.turnDone(failureReason2);
6387
6415
  } else {
@@ -6686,10 +6714,9 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6686
6714
  if (this.isSubAgent) {
6687
6715
  this.failSubAgent("unknown_tool_limit");
6688
6716
  } else {
6689
- this.appendToHistory({
6690
- role: "assistant",
6691
- content: "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue."
6692
- });
6717
+ this.appendDriverNotice(
6718
+ "I'm sorry, I repeatedly tried to use tools that aren't available to me, so I couldn't complete that. If a 'Download agent log' option appears in the Settings (cog) menu, you can download the log and share it with whoever set up this assistant to help fix the issue."
6719
+ );
6693
6720
  }
6694
6721
  return this.turnDone("unknown-tool-limit");
6695
6722
  }
@@ -6715,15 +6742,33 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6715
6742
  if (this.isSubAgent) {
6716
6743
  this.failSubAgent("max_iterations");
6717
6744
  } else {
6718
- this.appendToHistory({
6719
- role: "assistant",
6720
- content: "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off."
6721
- });
6745
+ this.appendDriverNotice(
6746
+ "I've reached my limit for this response. You can ask me to continue and I'll pick up where I left off."
6747
+ );
6722
6748
  }
6723
6749
  failureReason = "max-iterations";
6724
6750
  }
6725
6751
  return this.turnDone(failureReason);
6726
6752
  }
6753
+ /**
6754
+ * Append prose the DRIVER wrote rather than the model — a failure notice, a limit explanation,
6755
+ * the budget wall. Renders as an ordinary assistant bubble and is flagged
6756
+ * `excludeFromProvider`, so it never re-enters the next request.
6757
+ *
6758
+ * Every such notice must go through here. Written as one funnel rather than a flag repeated at
6759
+ * eleven call sites because the failure mode is silent: a notice that keeps the flag reads
6760
+ * identically in the transcript to one that loses it, and the damage only shows up a turn later
6761
+ * as the model echoing its own error message. Measured (GENC-1573): with the empty-response
6762
+ * notice in history, the next turn reproduced it verbatim in 45 output tokens — succeeding,
6763
+ * with no retry and no `turn.error`, while still showing the user a failure. From there the
6764
+ * loop sustains itself whether or not the model answers, which is why "try again" never worked.
6765
+ *
6766
+ * Not for anything the model produced, and not for a widget/interaction message: those are real
6767
+ * turn content and the model must keep seeing them.
6768
+ */
6769
+ appendDriverNotice(content) {
6770
+ this.appendToHistory({ role: "assistant", content, excludeFromProvider: true });
6771
+ }
6727
6772
  appendToHistory(message) {
6728
6773
  const tagged = {
6729
6774
  ...message,
@@ -6748,6 +6793,8 @@ RECOVERY: this tool failed once \u2014 you may retry it, or take a different val
6748
6793
  var DEFAULT_MAX_HANDOFFS = 3;
6749
6794
  var DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
6750
6795
  var DEFAULT_CLASSIFIER_RETRIES = 2;
6796
+ var CLASSIFIER_MESSAGE_CHARS = 600;
6797
+ var CLASSIFIER_TRANSCRIPT_CHARS = 3e3;
6751
6798
  var REQUEST_CONTINUATION_DEFINITION = {
6752
6799
  name: REQUEST_CONTINUATION_TOOL,
6753
6800
  description: "Call this when you have completed your part of the task but fulfilling the user's full request requires capabilities outside your domain. Do not call this if you can complete the request yourself. Pass a plain-language description of what still needs to be done \u2014 another specialist will be selected automatically.",
@@ -6772,10 +6819,19 @@ function isSpecialist(agent) {
6772
6819
  function isFallback(agent) {
6773
6820
  return agent.fallback === true;
6774
6821
  }
6822
+ var UNROUTABLE_PLACEHOLDER = "{{unroutable}}";
6823
+ var UNROUTABLE_INSTRUCTION = `When the request is substantive but nothing above covers it, say so directly. Do not offer to do it yourself, and do not imply a specialist will pick it up.
6824
+
6825
+ If the user's message is a short follow-up to something already discussed \u2014 "fix it", "try again", "why?" \u2014 read it against the conversation above and answer that, rather than treating it as a new request with no context.
6826
+
6827
+ Never end your turn without saying something. An empty reply is never a valid response: if you genuinely cannot help, say briefly that you cannot, and why.`;
6775
6828
  function buildFallbackSystemPrompt(fallback, specialists) {
6776
6829
  const agentList = specialists.map((s) => `- ${s.name}: ${s.description}`).join("\n");
6777
6830
  if (typeof fallback.systemPrompt === "string") {
6778
- return fallback.systemPrompt.replace("{{agents}}", agentList);
6831
+ const withAgents = fallback.systemPrompt.replace("{{agents}}", agentList);
6832
+ return withAgents.includes(UNROUTABLE_PLACEHOLDER) ? withAgents.replace(UNROUTABLE_PLACEHOLDER, UNROUTABLE_INSTRUCTION) : `${withAgents}
6833
+
6834
+ ${UNROUTABLE_INSTRUCTION}`;
6779
6835
  }
6780
6836
  if (typeof fallback.systemPrompt === "function") {
6781
6837
  return fallback.systemPrompt;
@@ -6784,7 +6840,9 @@ function buildFallbackSystemPrompt(fallback, specialists) {
6784
6840
 
6785
6841
  ${agentList}
6786
6842
 
6787
- Politely let the user know what you can help with and invite them to rephrase their request.`;
6843
+ Politely let the user know what you can help with and invite them to rephrase their request.
6844
+
6845
+ ${UNROUTABLE_INSTRUCTION}`;
6788
6846
  }
6789
6847
  var OrchestratingDriver = class extends EventTarget {
6790
6848
  constructor(providerRegistry, agents, options = {}) {
@@ -7015,11 +7073,19 @@ var OrchestratingDriver = class extends EventTarget {
7015
7073
  );
7016
7074
  const pinned = this.resolvePinnedAgent();
7017
7075
  if (!pinned) this.dispatchEvent(new CustomEvent("orchestrating-start"));
7018
- let currentAgent = pinned ?? await this.classify(input, history, this.activeAgent);
7076
+ let routed;
7077
+ if (pinned) {
7078
+ this.recordRouting("pinned", pinned, { durationMs: 0 });
7079
+ routed = { agent: pinned, decision: "pinned" };
7080
+ } else {
7081
+ routed = await this.classify(input, history, this.activeAgent);
7082
+ }
7083
+ let currentAgent = routed.agent;
7019
7084
  let isHandoff = false;
7020
7085
  let handoffs = 0;
7021
7086
  let handoffSummary = "";
7022
7087
  let remainingTask = "";
7088
+ const attempted = [];
7023
7089
  let lastResult;
7024
7090
  while (true) {
7025
7091
  if (this.cancelled) break;
@@ -7027,6 +7093,14 @@ var OrchestratingDriver = class extends EventTarget {
7027
7093
  let result;
7028
7094
  if (isHandoff) {
7029
7095
  const contextPrimer = handoffSummary ? [{ role: "user", content: `[Context from previous agent]: ${handoffSummary}` }] : [];
7096
+ if (isFallback(currentAgent)) {
7097
+ const work = remainingTask?.trim() || handoffSummary?.trim();
7098
+ contextPrimer.push({
7099
+ role: "user",
7100
+ content: (work ? `[Routing note]: no specialist is available for the remaining work: ${work}
7101
+ ` : "[Routing note]: no specialist is available for the work that remains.\n") + "Tell the user plainly what was established, that this part falls outside what any specialist here can do, and what they could do next. Do not claim you will do it yourself, and do not return an empty response."
7102
+ });
7103
+ }
7030
7104
  result = await this.chatDriver.continueFromHistory(contextPrimer);
7031
7105
  } else {
7032
7106
  this.userMessageAppended = true;
@@ -7050,9 +7124,13 @@ var OrchestratingDriver = class extends EventTarget {
7050
7124
  handoffSummary = result.summary;
7051
7125
  remainingTask = result.remainingTask;
7052
7126
  isHandoff = true;
7127
+ if (isSpecialist(currentAgent) && !attempted.includes(currentAgent.name)) {
7128
+ attempted.push(currentAgent.name);
7129
+ }
7053
7130
  const updatedHistory = this.chatDriver.getHistory();
7054
7131
  this.dispatchEvent(new CustomEvent("orchestrating-start"));
7055
- currentAgent = await this.classify(remainingTask, updatedHistory);
7132
+ routed = await this.classify(remainingTask, updatedHistory, void 0, attempted);
7133
+ currentAgent = routed.agent;
7056
7134
  }
7057
7135
  return lastResult?.reason === "done" ? lastResult : { reason: "done" };
7058
7136
  }
@@ -7198,28 +7276,99 @@ var OrchestratingDriver = class extends EventTarget {
7198
7276
  this.chatDriver.dispose();
7199
7277
  this.activeAgent = void 0;
7200
7278
  }
7201
- async classify(input, history, contextAgent) {
7279
+ /**
7280
+ * Record one routing decision on the debug timeline — what was chosen, why, what it cost, and
7281
+ * (on a failure) what went wrong.
7282
+ *
7283
+ * This is the event whose absence made GENC-1573 undiagnosable. `classify` reaches the fallback
7284
+ * by four different routes and every one of them only warned to the console; because it runs
7285
+ * outside any ChatDriver turn it produces no `kind:'turn'` entry either, so both the decision
7286
+ * and its spend were invisible in an exported log.
7287
+ *
7288
+ * `usage` is why the spend line exists at all. A classifier call is a real billed request that
7289
+ * never enters the transcript, and `sessionUsage` is derived from the transcript — so classifier
7290
+ * spend is absent from the session total by construction. Recording it per decision keeps it
7291
+ * auditable and tells a reconciler exactly where the gap comes from: sum these to close it.
7292
+ */
7293
+ recordRouting(decision, agent, detail = {}) {
7294
+ const { response, ballot, ...rest } = detail;
7295
+ const usage = response ? messageUsage(response) : void 0;
7296
+ recordMetaEvent(this.sessionKey, "routing.decided", {
7297
+ decision,
7298
+ to: agent.name,
7299
+ fallback: isFallback(agent),
7300
+ // The BALLOT, not `this.specialists`. `index` is a position in the ballot (see its
7301
+ // construction in `classify`), so publishing the unfiltered specialist list beside it made
7302
+ // `candidates[index]` resolve to the wrong agent whenever `excluded` was non-empty — and the
7303
+ // README sentence invited exactly that join. `this.specialists` stays recoverable as
7304
+ // `candidates ∪ excluded`. Absent where nothing was put to a vote, which is more truthful
7305
+ // than a full list. Reviewer catch on #2496.
7306
+ ...ballot ? { candidates: ballot } : {},
7307
+ ...rest,
7308
+ ...usage ? { usage } : {},
7309
+ ...response?.model ? { model: response.model } : {}
7310
+ });
7311
+ }
7312
+ /**
7313
+ * The recent-conversation excerpt for the classifier prompt, bounded in size as well as in
7314
+ * message count — see the `CLASSIFIER_MESSAGE_CHARS` docblock for why.
7315
+ */
7316
+ classifierTranscript(history) {
7317
+ const lines = history.filter((m) => (m.role === "user" || m.role === "assistant") && reachesProvider(m)).filter((m) => !!m.content?.trim()).slice(-this.classifierHistoryLength).map((m) => {
7318
+ const speaker = m.role === "user" ? "User" : "Assistant";
7319
+ const content = m.content ?? "";
7320
+ const body = content.length > CLASSIFIER_MESSAGE_CHARS ? `${content.slice(0, CLASSIFIER_MESSAGE_CHARS)}\u2026` : content;
7321
+ return `${speaker}: ${body}`;
7322
+ });
7323
+ while (lines.length > 1 && lines.join("\n").length > CLASSIFIER_TRANSCRIPT_CHARS) {
7324
+ lines.shift();
7325
+ }
7326
+ return lines.join("\n");
7327
+ }
7328
+ async classify(input, history, contextAgent, excludeAgents = []) {
7329
+ const classifyStartedAt = Date.now();
7202
7330
  if (this.specialists.length === 1 && !this.fallback) {
7203
- return this.specialists[0];
7331
+ const only = this.specialists[0];
7332
+ this.recordRouting("single-candidate", only, {
7333
+ durationMs: Date.now() - classifyStartedAt
7334
+ });
7335
+ return { agent: only, decision: "single-candidate" };
7204
7336
  }
7205
7337
  if (this.specialists.length === 0) {
7206
7338
  if (!this.fallback) {
7207
7339
  const routable = this.agents.filter((a) => !isFallback(a));
7208
7340
  if (routable.length === 1) {
7209
- return routable[0];
7341
+ this.recordRouting("no-specialists", routable[0], {
7342
+ durationMs: Date.now() - classifyStartedAt
7343
+ });
7344
+ return { agent: routable[0], decision: "no-specialists" };
7210
7345
  }
7211
7346
  }
7212
- return this.fallback ?? { name: "Assistant", fallback: true };
7347
+ const leaf = this.fallback ?? { name: "Assistant", fallback: true };
7348
+ this.recordRouting("no-specialists", leaf, { durationMs: Date.now() - classifyStartedAt });
7349
+ return { agent: leaf, decision: "no-specialists" };
7213
7350
  }
7214
- const agentList = this.specialists.map((a, i) => `${i}: ${a.name} \u2014 ${a.description}`).join("\n");
7215
- const recentMessages = history.filter((m) => m.role === "user" || m.role === "assistant").slice(-this.classifierHistoryLength).map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n");
7216
- const continuationHint = contextAgent && isSpecialist(contextAgent) ? `
7351
+ const ballot = excludeAgents.length ? this.specialists.filter((a) => !excludeAgents.includes(a.name)) : this.specialists;
7352
+ if (ballot.length === 0) {
7353
+ const leaf = this.fallback ?? this.specialists[0];
7354
+ this.recordRouting("ballot-exhausted", leaf, {
7355
+ excluded: excludeAgents,
7356
+ durationMs: Date.now() - classifyStartedAt
7357
+ });
7358
+ return { agent: leaf, decision: "ballot-exhausted" };
7359
+ }
7360
+ const agentList = ballot.map((a, i) => `${i}: ${a.name} \u2014 ${a.description}`).join("\n");
7361
+ const recentMessages = this.classifierTranscript(history);
7362
+ const continuationHint = contextAgent && isSpecialist(contextAgent) && !excludeAgents.includes(contextAgent.name) ? `
7217
7363
 
7218
7364
  The previous turn was handled by "${contextAgent.name}". If the new message is a refinement, correction, or continuation of that work, prefer "${contextAgent.name}" \u2014 unless the topic has clearly shifted to another specialist's domain.` : "";
7365
+ const attemptedHint = excludeAgents.length ? `
7366
+
7367
+ Already attempted this turn and handed the work on, so not listed above: ${excludeAgents.join(", ")}. Do not expect another agent to repeat their part.` : "";
7219
7368
  const classifierPrompt = `You are a routing classifier. Call select_agent with the index of the best matching agent for the user message. Use -1 only if the request is entirely unrelated to every agent listed \u2014 prefer the closest match when in doubt. If the request spans multiple agents, pick the one that should act first; I will re-classify and route again once that agent is done.
7220
7369
 
7221
7370
  Agents:
7222
- ${agentList}${continuationHint}${recentMessages ? `
7371
+ ${agentList}${continuationHint}${attemptedHint}${recentMessages ? `
7223
7372
 
7224
7373
  Recent conversation:
7225
7374
  ${recentMessages}` : ""}`;
@@ -7237,8 +7386,12 @@ ${recentMessages}` : ""}`;
7237
7386
  }
7238
7387
  }
7239
7388
  };
7240
- let routedNoMatch = false;
7389
+ let decision = "classifier-failed";
7390
+ let lastError;
7391
+ let attempts = 0;
7392
+ let lastResponse;
7241
7393
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
7394
+ attempts = attempt + 1;
7242
7395
  try {
7243
7396
  const options = {
7244
7397
  systemPrompt: classifierPrompt,
@@ -7249,32 +7402,56 @@ ${recentMessages}` : ""}`;
7249
7402
  logger2.warn(
7250
7403
  "OrchestratingDriver: default AI provider does not implement chat() \u2014 cannot classify."
7251
7404
  );
7405
+ decision = "provider-unavailable";
7252
7406
  break;
7253
7407
  }
7254
7408
  const response = await classifierProvider.chat([], input, options);
7409
+ lastResponse = response;
7255
7410
  const tc = response.toolCalls?.[0];
7256
7411
  const index = tc?.name === "select_agent" ? tc.args.agent_index : -1;
7257
- if (index >= 0 && index < this.specialists.length) {
7258
- return this.specialists[index];
7412
+ if (index >= 0 && index < ballot.length) {
7413
+ const chosen = ballot[index];
7414
+ this.recordRouting("classified", chosen, {
7415
+ index,
7416
+ attempts,
7417
+ durationMs: Date.now() - classifyStartedAt,
7418
+ response,
7419
+ ballot: ballot.map((a) => a.name),
7420
+ ...excludeAgents.length ? { excluded: excludeAgents } : {}
7421
+ });
7422
+ return { agent: chosen, decision: "classified" };
7259
7423
  }
7260
- routedNoMatch = true;
7424
+ decision = "no-match";
7261
7425
  break;
7262
7426
  } catch (e) {
7263
7427
  if (e instanceof BudgetExhaustedError) throw e;
7428
+ lastError = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
7264
7429
  logger2.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
7265
7430
  if (attempt === this.classifierRetries) {
7266
7431
  logger2.error("OrchestratingDriver: classifier failed after all retries, using fallback");
7267
7432
  }
7268
7433
  }
7269
7434
  }
7270
- if (this.fallback) return this.fallback;
7271
- if (routedNoMatch) {
7435
+ const routingDetail = {
7436
+ attempts,
7437
+ durationMs: Date.now() - classifyStartedAt,
7438
+ ballot: ballot.map((a) => a.name),
7439
+ ...lastError ? { error: lastError } : {},
7440
+ ...lastResponse ? { response: lastResponse } : {},
7441
+ ...excludeAgents.length ? { excluded: excludeAgents } : {}
7442
+ };
7443
+ if (this.fallback) {
7444
+ this.recordRouting(decision, this.fallback, routingDetail);
7445
+ return { agent: this.fallback, decision };
7446
+ }
7447
+ if (decision === "no-match") {
7272
7448
  const specialistNames = this.specialists.map((s) => s.name).join(", ");
7273
7449
  this.appendInlineMessage(
7274
7450
  `I'm not sure how to help with that. I can assist with: ${specialistNames}.`
7275
7451
  );
7276
7452
  }
7277
- return this.specialists[0];
7453
+ this.recordRouting(decision, this.specialists[0], routingDetail);
7454
+ return { agent: this.specialists[0], decision };
7278
7455
  }
7279
7456
  /**
7280
7457
  * Returns the agent that the current turn is locked to, if any. Flow-owner
@@ -7296,11 +7473,22 @@ ${recentMessages}` : ""}`;
7296
7473
  }
7297
7474
  return match;
7298
7475
  }
7476
+ /**
7477
+ * Append prose the ORCHESTRATOR wrote — the handoff cap, the no-specialist apology. Flagged
7478
+ * `excludeFromProvider` for the same reason the driver's own notices are: it is not something a
7479
+ * model said, and feeding it back invites the model to echo it. See
7480
+ * `ChatMessage.excludeFromProvider`.
7481
+ */
7299
7482
  appendInlineMessage(content) {
7300
7483
  const history = this.chatDriver.getHistory();
7301
7484
  this.chatDriver.loadHistory([
7302
7485
  ...history,
7303
- { role: "assistant", content, timestamp: (/* @__PURE__ */ new Date()).toISOString() }
7486
+ {
7487
+ role: "assistant",
7488
+ content,
7489
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
7490
+ excludeFromProvider: true
7491
+ }
7304
7492
  ]);
7305
7493
  this.dispatchEvent(
7306
7494
  new CustomEvent("history-updated", { detail: this.chatDriver.getHistory() })
@@ -7516,7 +7704,11 @@ Politely let the user know what you can help with and invite them to rephrase th
7516
7704
  var strictFallbackAgent = {
7517
7705
  name: "Assistant",
7518
7706
  fallback: true,
7519
- systemPrompt: `You are a concise assistant. The user's request is outside your scope. Respond with a single short sentence stating what you can assist with. Do not elaborate. The available specialists are: {{agents}}.`
7707
+ systemPrompt: `You are a concise assistant. The user's request is outside your scope. The available specialists are: {{agents}}.
7708
+
7709
+ {{unroutable}}
7710
+
7711
+ Answer in a single short sentence. Do not elaborate.`
7520
7712
  };
7521
7713
 
7522
7714
  // src/utils/usage-rows.ts
@@ -7681,10 +7873,12 @@ export {
7681
7873
  AnthropicTransport,
7682
7874
  ChatDriver,
7683
7875
  DEFAULT_PROVIDER_REFUSED_MESSAGE,
7876
+ EMPTY_RESPONSE_NUDGE,
7684
7877
  GEMINI_CACHED_INPUT_MULTIPLIER,
7685
7878
  GEMINI_LONG_CONTEXT_THRESHOLD,
7686
7879
  GeminiProvider,
7687
7880
  GeminiTransport,
7881
+ MALFORMED_CALL_NUDGE,
7688
7882
  MutableAIProviderRegistry,
7689
7883
  NOOP_ACTIVITY_BUS,
7690
7884
  OrchestratingDriver,
@@ -7693,6 +7887,8 @@ export {
7693
7887
  REQUEST_CONTINUATION_TOOL,
7694
7888
  SUPPORTED_ANTHROPIC_MODEL_IDS,
7695
7889
  SUPPORTED_GEMINI_MODEL_IDS,
7890
+ UNROUTABLE_INSTRUCTION,
7891
+ UNROUTABLE_PLACEHOLDER,
7696
7892
  addUsage,
7697
7893
  agenticActivityBus,
7698
7894
  anthropicRatesFor,