@genesislcap/ai-assistant 15.20.2 → 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
@@ -1,13 +1,27 @@
1
- import { __awaiter } from "tslib";
1
+ import { __awaiter, __rest } from "tslib";
2
2
  import { BudgetExhaustedError } from '@genesislcap/foundation-ai';
3
3
  import { validateStaticAgentProviders } from '../../config/validate-providers';
4
4
  import { recordMetaEvent } from '../../state/debug-event-log';
5
+ import { reachesProvider } from '../../utils/context-tokens';
5
6
  import { transformHistoryForAgent } from '../../utils/history-transform';
6
7
  import { logger } from '../../utils/logger';
8
+ import { messageUsage } from '../../utils/sum-usage';
7
9
  import { ChatDriver, REQUEST_CONTINUATION_TOOL, } from '../chat-driver/chat-driver';
8
10
  const DEFAULT_MAX_HANDOFFS = 3;
9
11
  const DEFAULT_CLASSIFIER_HISTORY_LENGTH = 4;
10
12
  const DEFAULT_CLASSIFIER_RETRIES = 2;
13
+ /**
14
+ * Per-message and whole-block ceilings, in characters, on the recent-conversation excerpt in the
15
+ * classifier prompt.
16
+ *
17
+ * `classifierHistoryLength` bounds the message COUNT and nothing bounded their size, so the
18
+ * excerpt was as large as the last few messages happened to be — measured at 32,937 characters in
19
+ * one session (GENC-1573), on every routed turn, to answer one multiple-choice question. Routing
20
+ * reads the topic, not the detail: the opening of each message carries what the user asked for,
21
+ * and a truncated tail costs a router nothing.
22
+ */
23
+ const CLASSIFIER_MESSAGE_CHARS = 600;
24
+ const CLASSIFIER_TRANSCRIPT_CHARS = 3000;
11
25
  const REQUEST_CONTINUATION_DEFINITION = {
12
26
  name: REQUEST_CONTINUATION_TOOL,
13
27
  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 — another specialist will be selected automatically.",
@@ -32,18 +46,66 @@ function isSpecialist(agent) {
32
46
  function isFallback(agent) {
33
47
  return agent.fallback === true;
34
48
  }
49
+ /**
50
+ * Placeholder a consumer can put in its fallback prompt to position
51
+ * {@link UNROUTABLE_INSTRUCTION} itself, instead of having it appended.
52
+ *
53
+ * @beta
54
+ */
55
+ export const UNROUTABLE_PLACEHOLDER = '{{unroutable}}';
56
+ /**
57
+ * The instruction a fallback agent needs and, left to itself, tends not to have.
58
+ *
59
+ * A fallback prompt written by a consumer reliably covers two cases — small talk, and a request
60
+ * that belongs to a specialist — because those are the ones you think of when you write it. The
61
+ * case it exists FOR is the third: a substantive request that no specialist covers, and no way to
62
+ * act on it, because a fallback holds no tools and is denied the continuation tool as a leaf.
63
+ *
64
+ * Create's fallback prompt is the worked example (GENC-1573). It covered chit-chat and
65
+ * "out of scope, here are the specialists", said nothing about a request nothing can serve, and
66
+ * nothing about a terse follow-up ("fix it", "try again") aimed at an answer already given. Asked
67
+ * to fix a Kotlin null pointer no agent could touch, it returned nine blank turns across three
68
+ * page loads — a model with no instruction covering its situation and no tool to reach for.
69
+ *
70
+ * So the framework supplies it. Appended to a string-form prompt (or substituted at
71
+ * {@link UNROUTABLE_PLACEHOLDER} if the consumer positions it), because a fallback with no answer
72
+ * for its own reason to exist is a defect in every host, not a matter of taste. It is additive and
73
+ * static: being fixed it cannot destabilise a cached prefix, and the fallback prompt already
74
+ * changes whenever the agents array does (`{{agents}}` interpolates the specialist list). The
75
+ * function form still passes through untouched — a consumer that wants full control has it there.
76
+ *
77
+ * DELIBERATELY CONFINED TO WHAT A TERSE PROMPT CAN ALSO OBEY, and it must stay that way. Since
78
+ * this is appended by default it lands on top of prompts written with their own tone rules, and
79
+ * an earlier draft asked the model to "state what you can establish… and suggest what the user
80
+ * could do next" — a direct contradiction of {@link strictFallbackAgent}, a preset THIS PACKAGE
81
+ * ships, whose whole instruction is "a single short sentence… Do not elaborate". What is left
82
+ * here is compatible with brevity: say so, don't promise work you cannot do, read a short
83
+ * follow-up in context, and never answer with nothing. Adding anything that presumes room to
84
+ * elaborate breaks a shipped preset — put it in the consumer's own prompt instead.
85
+ *
86
+ * @beta
87
+ */
88
+ export const 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.
89
+
90
+ If the user's message is a short follow-up to something already discussed — "fix it", "try again", "why?" — read it against the conversation above and answer that, rather than treating it as a new request with no context.
91
+
92
+ 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.`;
35
93
  function buildFallbackSystemPrompt(fallback, specialists) {
36
94
  const agentList = specialists.map((s) => `- ${s.name}: ${s.description}`).join('\n');
37
95
  if (typeof fallback.systemPrompt === 'string') {
38
- return fallback.systemPrompt.replace('{{agents}}', agentList);
96
+ const withAgents = fallback.systemPrompt.replace('{{agents}}', agentList);
97
+ return withAgents.includes(UNROUTABLE_PLACEHOLDER)
98
+ ? withAgents.replace(UNROUTABLE_PLACEHOLDER, UNROUTABLE_INSTRUCTION)
99
+ : `${withAgents}\n\n${UNROUTABLE_INSTRUCTION}`;
39
100
  }
40
101
  if (typeof fallback.systemPrompt === 'function') {
41
102
  // Function-form fallback prompt — pass through unchanged. The `{{agents}}`
42
103
  // substitution is a string-template convenience; consumers using the
43
- // function form can compose the agent list themselves if they want it.
104
+ // function form can compose the agent list themselves if they want it, and
105
+ // {@link UNROUTABLE_INSTRUCTION} is exported for them to include.
44
106
  return fallback.systemPrompt;
45
107
  }
46
- return `You are a helpful assistant. You cannot directly help with the user's request, but the following specialists are available:\n\n${agentList}\n\nPolitely let the user know what you can help with and invite them to rephrase their request.`;
108
+ return `You are a helpful assistant. You cannot directly help with the user's request, but the following specialists are available:\n\n${agentList}\n\nPolitely let the user know what you can help with and invite them to rephrase their request.\n\n${UNROUTABLE_INSTRUCTION}`;
47
109
  }
48
110
  /**
49
111
  * Orchestrates multiple specialist agents. Sits between `FoundationAiAssistant`
@@ -303,11 +365,28 @@ export class OrchestratingDriver extends EventTarget {
303
365
  const pinned = this.resolvePinnedAgent();
304
366
  if (!pinned)
305
367
  this.dispatchEvent(new CustomEvent('orchestrating-start'));
306
- let currentAgent = pinned !== null && pinned !== void 0 ? pinned : (yield this.classify(input, history, this.activeAgent));
368
+ let routed;
369
+ if (pinned) {
370
+ // `classify` is never entered on a pin, so there is no ladder to time — but the field stays
371
+ // unconditional across every `routing.decided`, and 0 is the honest reading: no time was
372
+ // spent deciding, because nothing was decided.
373
+ this.recordRouting('pinned', pinned, { durationMs: 0 });
374
+ routed = { agent: pinned, decision: 'pinned' };
375
+ }
376
+ else {
377
+ routed = yield this.classify(input, history, this.activeAgent);
378
+ }
379
+ let currentAgent = routed.agent;
307
380
  let isHandoff = false;
308
381
  let handoffs = 0;
309
382
  let handoffSummary = '';
310
383
  let remainingTask = '';
384
+ /**
385
+ * Specialists that have already handled part of THIS turn and handed on. Fed to each
386
+ * re-classify so the remaining work cannot be routed back to an agent that just declined it
387
+ * — see the `excludeAgents` parameter on `classify`.
388
+ */
389
+ const attempted = [];
311
390
  // The outcome of the agent turn that ENDS the loop — see the return below.
312
391
  let lastResult;
313
392
  while (true) {
@@ -323,6 +402,34 @@ export class OrchestratingDriver extends EventTarget {
323
402
  const contextPrimer = handoffSummary
324
403
  ? [{ role: 'user', content: `[Context from previous agent]: ${handoffSummary}` }]
325
404
  : [];
405
+ // A handoff that reaches the fallback is the one case where routing has genuinely run out
406
+ // of options, and it used to be the worst-served: the fallback holds no tools (it is
407
+ // denied the continuation tool as a leaf), so it was handed a remaining task phrased as
408
+ // work — "debug and fix the null pointer in the event handler" — with no instruction for
409
+ // what to do when nothing can. Measured (GENC-1573), it answered with three consecutive
410
+ // blank turns.
411
+ //
412
+ // Naming the situation is what it was missing. The remaining task and the fact that no
413
+ // specialist covers it are both real, useful things to tell a user, and an agent told
414
+ // them plainly has something to say instead of nothing. Transient primer, so it steers
415
+ // this turn without becoming a permanent instruction in the transcript.
416
+ if (isFallback(currentAgent)) {
417
+ // `remainingTask` is model-supplied — it arrives from `request_continuation`'s
418
+ // `remaining_task` through an unvalidated cast — and this is the one primer where a
419
+ // degraded input is already known to be in play, since it exists because the fallback
420
+ // answered this path with three blank turns. Name the situation without the clause
421
+ // rather than render "the remaining work: undefined".
422
+ const work = (remainingTask === null || remainingTask === void 0 ? void 0 : remainingTask.trim()) || (handoffSummary === null || handoffSummary === void 0 ? void 0 : handoffSummary.trim());
423
+ contextPrimer.push({
424
+ role: 'user',
425
+ content: (work
426
+ ? `[Routing note]: no specialist is available for the remaining work: ${work}\n`
427
+ : '[Routing note]: no specialist is available for the work that remains.\n') +
428
+ 'Tell the user plainly what was established, that this part falls outside what any ' +
429
+ 'specialist here can do, and what they could do next. Do not claim you will do it ' +
430
+ 'yourself, and do not return an empty response.',
431
+ });
432
+ }
326
433
  // oxlint-disable-next-line no-await-in-loop
327
434
  result = yield this.chatDriver.continueFromHistory(contextPrimer);
328
435
  }
@@ -358,10 +465,14 @@ export class OrchestratingDriver extends EventTarget {
358
465
  handoffSummary = result.summary;
359
466
  remainingTask = result.remainingTask;
360
467
  isHandoff = true;
468
+ if (isSpecialist(currentAgent) && !attempted.includes(currentAgent.name)) {
469
+ attempted.push(currentAgent.name);
470
+ }
361
471
  const updatedHistory = this.chatDriver.getHistory();
362
472
  this.dispatchEvent(new CustomEvent('orchestrating-start'));
363
473
  // oxlint-disable-next-line no-await-in-loop
364
- currentAgent = yield this.classify(remainingTask, updatedHistory);
474
+ routed = yield this.classify(remainingTask, updatedHistory, undefined, attempted);
475
+ currentAgent = routed.agent;
365
476
  }
366
477
  // The orchestrated turn reports the outcome of the agent turn that ENDED it.
367
478
  // The hardcoded `{ reason: 'done' }` that used to live here made every
@@ -565,16 +676,85 @@ export class OrchestratingDriver extends EventTarget {
565
676
  this.activeAgent = undefined;
566
677
  });
567
678
  }
568
- classify(input, history, contextAgent) {
569
- return __awaiter(this, void 0, void 0, function* () {
570
- var _a, _b;
679
+ /**
680
+ * Record one routing decision on the debug timeline — what was chosen, why, what it cost, and
681
+ * (on a failure) what went wrong.
682
+ *
683
+ * This is the event whose absence made GENC-1573 undiagnosable. `classify` reaches the fallback
684
+ * by four different routes and every one of them only warned to the console; because it runs
685
+ * outside any ChatDriver turn it produces no `kind:'turn'` entry either, so both the decision
686
+ * and its spend were invisible in an exported log.
687
+ *
688
+ * `usage` is why the spend line exists at all. A classifier call is a real billed request that
689
+ * never enters the transcript, and `sessionUsage` is derived from the transcript — so classifier
690
+ * spend is absent from the session total by construction. Recording it per decision keeps it
691
+ * auditable and tells a reconciler exactly where the gap comes from: sum these to close it.
692
+ */
693
+ recordRouting(decision, agent, detail = {}) {
694
+ const { response, ballot } = detail, rest = __rest(detail, ["response", "ballot"]);
695
+ const usage = response ? messageUsage(response) : undefined;
696
+ recordMetaEvent(this.sessionKey, 'routing.decided', Object.assign(Object.assign(Object.assign(Object.assign({ decision, to: agent.name, fallback: isFallback(agent) }, (ballot ? { candidates: ballot } : {})), rest), (usage ? { usage } : {})), ((response === null || response === void 0 ? void 0 : response.model) ? { model: response.model } : {})));
697
+ }
698
+ /**
699
+ * The recent-conversation excerpt for the classifier prompt, bounded in size as well as in
700
+ * message count — see the `CLASSIFIER_MESSAGE_CHARS` docblock for why.
701
+ */
702
+ classifierTranscript(history) {
703
+ const lines = history
704
+ // `reachesProvider`, not a role test. This is fed CANONICAL history, and a classify call is a
705
+ // real billed provider request — so without the predicate the driver's own failure notices
706
+ // (`excludeFromProvider`) arrive here as the assistant's last utterance, and the loop the
707
+ // rest of GENC-1573 closes stays open at the one request that decides where the turn goes.
708
+ // It also drops the `reasoning` / `narration` splits, which are `role: 'assistant'` too and
709
+ // would otherwise show up as duplicate `Assistant:` lines. Reviewer catch on #2496.
710
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && reachesProvider(m))
711
+ // Blank rows need their own test: `reachesProvider` keys on role, category and the exclusion
712
+ // flag, never on content, so a tool-call carrier or an interaction placeholder still passes
713
+ // it and renders as a bare `Assistant: ` — a line that spends prompt on nothing and tells a
714
+ // router less than omitting it does.
715
+ .filter((m) => { var _a; return !!((_a = m.content) === null || _a === void 0 ? void 0 : _a.trim()); })
716
+ .slice(-this.classifierHistoryLength)
717
+ .map((m) => {
718
+ var _a;
719
+ const speaker = m.role === 'user' ? 'User' : 'Assistant';
720
+ const content = (_a = m.content) !== null && _a !== void 0 ? _a : '';
721
+ const body = content.length > CLASSIFIER_MESSAGE_CHARS
722
+ ? `${content.slice(0, CLASSIFIER_MESSAGE_CHARS)}…`
723
+ : content;
724
+ return `${speaker}: ${body}`;
725
+ });
726
+ // Drop from the OLDEST end if the block is still too big: the most recent message is the one
727
+ // being routed, so it is the last thing that may be sacrificed.
728
+ while (lines.length > 1 && lines.join('\n').length > CLASSIFIER_TRANSCRIPT_CHARS) {
729
+ lines.shift();
730
+ }
731
+ return lines.join('\n');
732
+ }
733
+ classify(input_1, history_1, contextAgent_1) {
734
+ return __awaiter(this, arguments, void 0, function* (input, history, contextAgent,
735
+ /**
736
+ * Specialists to leave off the ballot — the agents that have already handled (and handed off)
737
+ * part of this turn. Without it a re-classify can hand the remaining work straight back to
738
+ * the agent that just declined it, and in the worst case ping-pong until `maxHandoffs`.
739
+ */
740
+ excludeAgents = []) {
741
+ var _a, _b, _c;
742
+ // Started before every early return, and EVERY exit below reports against it — so
743
+ // `durationMs` is present on every `routing.decided`, and a decision that made no call says so
744
+ // with a ~0ms reading rather than an absent field. Uniform on purpose: a field that is always
745
+ // there is far easier to document, and to trust, than a per-decision matrix of when it is.
746
+ const classifyStartedAt = Date.now();
571
747
  // Single-candidate short-circuits. No point asking the LLM to route
572
748
  // when there's only one viable choice. Skipped if a fallback is
573
749
  // configured — that's an explicit "escape hatch" signal from the
574
750
  // consumer, and we preserve the LLM's ability to send unrelated
575
751
  // messages there by returning -1 from select_agent.
576
752
  if (this.specialists.length === 1 && !this.fallback) {
577
- return this.specialists[0];
753
+ const only = this.specialists[0];
754
+ this.recordRouting('single-candidate', only, {
755
+ durationMs: Date.now() - classifyStartedAt,
756
+ });
757
+ return { agent: only, decision: 'single-candidate' };
578
758
  }
579
759
  if (this.specialists.length === 0) {
580
760
  // No classifier-eligible specialists. If exactly one non-fallback
@@ -586,23 +766,45 @@ export class OrchestratingDriver extends EventTarget {
586
766
  if (!this.fallback) {
587
767
  const routable = this.agents.filter((a) => !isFallback(a));
588
768
  if (routable.length === 1) {
589
- return routable[0];
769
+ this.recordRouting('no-specialists', routable[0], {
770
+ durationMs: Date.now() - classifyStartedAt,
771
+ });
772
+ return { agent: routable[0], decision: 'no-specialists' };
590
773
  }
591
774
  }
592
- return (_a = this.fallback) !== null && _a !== void 0 ? _a : { name: 'Assistant', fallback: true };
775
+ const leaf = (_a = this.fallback) !== null && _a !== void 0 ? _a : { name: 'Assistant', fallback: true };
776
+ this.recordRouting('no-specialists', leaf, { durationMs: Date.now() - classifyStartedAt });
777
+ return { agent: leaf, decision: 'no-specialists' };
778
+ }
779
+ // The ballot for THIS classification. Indices are positions in `ballot`, not in
780
+ // `this.specialists`, so an exclusion cannot silently shift what an index means.
781
+ const ballot = excludeAgents.length
782
+ ? this.specialists.filter((a) => !excludeAgents.includes(a.name))
783
+ : this.specialists;
784
+ if (ballot.length === 0) {
785
+ // Every specialist has already had a go at this turn. Asking again can only return one of
786
+ // them, so go straight to the escape hatch. `durationMs` is reported even though it is ~0,
787
+ // because that is the documented signal for "no network call happened" — omitting it left a
788
+ // reader with no way to tell this record from a truncated one.
789
+ const leaf = (_b = this.fallback) !== null && _b !== void 0 ? _b : this.specialists[0];
790
+ this.recordRouting('ballot-exhausted', leaf, {
791
+ excluded: excludeAgents,
792
+ durationMs: Date.now() - classifyStartedAt,
793
+ });
794
+ return { agent: leaf, decision: 'ballot-exhausted' };
593
795
  }
594
- const agentList = this.specialists
595
- .map((a, i) => `${i}: ${a.name} — ${a.description}`)
596
- .join('\n');
597
- const recentMessages = history
598
- .filter((m) => m.role === 'user' || m.role === 'assistant')
599
- .slice(-this.classifierHistoryLength)
600
- .map((m) => `${m.role === 'user' ? 'User' : 'Assistant'}: ${m.content}`)
601
- .join('\n');
602
- const continuationHint = contextAgent && isSpecialist(contextAgent)
796
+ const agentList = ballot.map((a, i) => `${i}: ${a.name} — ${a.description}`).join('\n');
797
+ const recentMessages = this.classifierTranscript(history);
798
+ const continuationHint = contextAgent && isSpecialist(contextAgent) && !excludeAgents.includes(contextAgent.name)
603
799
  ? `\n\nThe previous turn was handled by "${contextAgent.name}". If the new message is a refinement, correction, or continuation of that work, prefer "${contextAgent.name}" — unless the topic has clearly shifted to another specialist's domain.`
604
800
  : '';
605
- 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 — 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.\n\nAgents:\n${agentList}${continuationHint}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
801
+ // Naming what has already been tried, rather than just removing it from the list, so the
802
+ // model can tell "nobody here covers this" from "I haven't looked hard enough" — the latter
803
+ // is what makes a classifier pick a poor closest match.
804
+ const attemptedHint = excludeAgents.length
805
+ ? `\n\nAlready attempted this turn and handed the work on, so not listed above: ${excludeAgents.join(', ')}. Do not expect another agent to repeat their part.`
806
+ : '';
807
+ 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 — 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.\n\nAgents:\n${agentList}${continuationHint}${attemptedHint}${recentMessages ? `\n\nRecent conversation:\n${recentMessages}` : ''}`;
606
808
  const routingTool = {
607
809
  name: 'select_agent',
608
810
  description: 'Select the most appropriate agent for the user message.',
@@ -617,12 +819,15 @@ export class OrchestratingDriver extends EventTarget {
617
819
  },
618
820
  },
619
821
  };
620
- // True only when the classifier RAN and answered "no match" (index -1). The
621
- // no-fallback apology below is gated on it: an exhausted-retries exit never
622
- // earned that apology, and emitting it there put a routing excuse ahead of
623
- // the real error the routed turn is about to produce.
624
- let routedNoMatch = false;
822
+ // Which exit the loop is heading for. Was a bare `routedNoMatch` boolean; now the reason is
823
+ // reported on the timeline as well as gating the apology, so it has to distinguish all three
824
+ // of "the classifier said none of these", "it never answered", and "it could not be called".
825
+ let decision = 'classifier-failed';
826
+ let lastError;
827
+ let attempts = 0;
828
+ let lastResponse;
625
829
  for (let attempt = 0; attempt <= this.classifierRetries; attempt += 1) {
830
+ attempts = attempt + 1;
626
831
  try {
627
832
  const options = {
628
833
  systemPrompt: classifierPrompt,
@@ -633,17 +838,22 @@ export class OrchestratingDriver extends EventTarget {
633
838
  const classifierProvider = this.providerRegistry.default();
634
839
  if (!classifierProvider.chat) {
635
840
  logger.warn('OrchestratingDriver: default AI provider does not implement chat() — cannot classify.');
841
+ decision = 'provider-unavailable';
636
842
  break;
637
843
  }
638
844
  // oxlint-disable-next-line no-await-in-loop
639
845
  const response = yield classifierProvider.chat([], input, options);
640
- const tc = (_b = response.toolCalls) === null || _b === void 0 ? void 0 : _b[0];
846
+ lastResponse = response;
847
+ const tc = (_c = response.toolCalls) === null || _c === void 0 ? void 0 : _c[0];
641
848
  const index = (tc === null || tc === void 0 ? void 0 : tc.name) === 'select_agent' ? tc.args.agent_index : -1;
642
- if (index >= 0 && index < this.specialists.length) {
643
- return this.specialists[index];
849
+ if (index >= 0 && index < ballot.length) {
850
+ const chosen = ballot[index];
851
+ this.recordRouting('classified', chosen, Object.assign({ index,
852
+ attempts, durationMs: Date.now() - classifyStartedAt, response, ballot: ballot.map((a) => a.name) }, (excludeAgents.length ? { excluded: excludeAgents } : {})));
853
+ return { agent: chosen, decision: 'classified' };
644
854
  }
645
855
  // index === -1 — fall through to fallback
646
- routedNoMatch = true;
856
+ decision = 'no-match';
647
857
  break;
648
858
  }
649
859
  catch (e) {
@@ -655,24 +865,33 @@ export class OrchestratingDriver extends EventTarget {
655
865
  // narrow: a generic classifier failure must still retry and fall back.
656
866
  if (e instanceof BudgetExhaustedError)
657
867
  throw e;
868
+ lastError = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
658
869
  logger.warn(`OrchestratingDriver: classifier attempt ${attempt + 1} failed:`, e);
659
870
  if (attempt === this.classifierRetries) {
660
871
  logger.error('OrchestratingDriver: classifier failed after all retries, using fallback');
661
872
  }
662
873
  }
663
874
  }
664
- if (this.fallback)
665
- return this.fallback;
875
+ // Everything from here reaches the fallback (or, with none configured, a no-op specialist).
876
+ // `durationMs` is the whole ladder: a decision that reached this point in a few tens of
877
+ // milliseconds did not make a network call at all, and that gap between "the classifier
878
+ // declined" and "the classifier never ran" is precisely what an exported log could not show.
879
+ const routingDetail = Object.assign(Object.assign(Object.assign({ attempts, durationMs: Date.now() - classifyStartedAt, ballot: ballot.map((a) => a.name) }, (lastError ? { error: lastError } : {})), (lastResponse ? { response: lastResponse } : {})), (excludeAgents.length ? { excluded: excludeAgents } : {}));
880
+ if (this.fallback) {
881
+ this.recordRouting(decision, this.fallback, routingDetail);
882
+ return { agent: this.fallback, decision };
883
+ }
666
884
  // No fallback configured. Route to the first specialist as a no-op — but
667
885
  // only APOLOGISE when the classifier actually said "none of these". On an
668
886
  // exhausted-retries exit the classifier never expressed an opinion, so the
669
887
  // apology would be a routing excuse the user reads immediately above the
670
888
  // real error that turn is about to produce.
671
- if (routedNoMatch) {
889
+ if (decision === 'no-match') {
672
890
  const specialistNames = this.specialists.map((s) => s.name).join(', ');
673
891
  this.appendInlineMessage(`I'm not sure how to help with that. I can assist with: ${specialistNames}.`);
674
892
  }
675
- return this.specialists[0];
893
+ this.recordRouting(decision, this.specialists[0], routingDetail);
894
+ return { agent: this.specialists[0], decision };
676
895
  });
677
896
  }
678
897
  /**
@@ -695,11 +914,22 @@ export class OrchestratingDriver extends EventTarget {
695
914
  }
696
915
  return match;
697
916
  }
917
+ /**
918
+ * Append prose the ORCHESTRATOR wrote — the handoff cap, the no-specialist apology. Flagged
919
+ * `excludeFromProvider` for the same reason the driver's own notices are: it is not something a
920
+ * model said, and feeding it back invites the model to echo it. See
921
+ * `ChatMessage.excludeFromProvider`.
922
+ */
698
923
  appendInlineMessage(content) {
699
924
  const history = this.chatDriver.getHistory();
700
925
  this.chatDriver.loadHistory([
701
926
  ...history,
702
- { role: 'assistant', content, timestamp: new Date().toISOString() },
927
+ {
928
+ role: 'assistant',
929
+ content,
930
+ timestamp: new Date().toISOString(),
931
+ excludeFromProvider: true,
932
+ },
703
933
  ]);
704
934
  this.dispatchEvent(new CustomEvent('history-updated', { detail: this.chatDriver.getHistory() }));
705
935
  }
@@ -17,10 +17,21 @@ export const friendlyFallbackAgent = {
17
17
  * elaboration. The `{{agents}}` placeholder is replaced at runtime by
18
18
  * `OrchestratingDriver` with the generated specialist list.
19
19
  *
20
+ * The `{{unroutable}}` placeholder positions `UNROUTABLE_INSTRUCTION` — the framework's
21
+ * instruction for a substantive request no specialist covers, which is otherwise APPENDED to a
22
+ * string-form prompt. Positioned rather than appended for one reason: this preset's whole point is
23
+ * brevity, and appended text would be the last thing the model reads. Putting the block above the
24
+ * brevity rule leaves "a single short sentence" with the recency, so the two compose instead of
25
+ * competing. A prompt whose tone rules matter should do the same.
26
+ *
20
27
  * @beta
21
28
  */
22
29
  export const strictFallbackAgent = {
23
30
  name: 'Assistant',
24
31
  fallback: true,
25
- 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}}.`,
32
+ systemPrompt: `You are a concise assistant. The user's request is outside your scope. The available specialists are: {{agents}}.
33
+
34
+ {{unroutable}}
35
+
36
+ Answer in a single short sentence. Do not elaborate.`,
26
37
  };
@@ -57,6 +57,9 @@ export const META_EVENT_IMPORTANCE = {
57
57
  'tool.unresolved': 'normal',
58
58
  'subagent.started': 'normal',
59
59
  'subagent.completed': 'normal',
60
+ // One per routing decision, with what the classifier answered and what the call cost. Same
61
+ // level as the handoff it produces: you read them together to follow how a turn was routed.
62
+ 'routing.decided': 'normal',
60
63
  'agent.handoff': 'normal',
61
64
  'agent.pinned': 'normal',
62
65
  'agent.unpinned': 'normal',
@@ -219,11 +222,13 @@ export const DEBUG_LOG_README = [
219
222
  "kind:'turn'.`agentSnapshot` — the active agent's own view of its internal state, captured at that turn. An agent opts into this by exposing a `getDebugSnapshot()` that returns JSON-serializable per-state info; stateful/flow agents wire it automatically, so you can watch a flow advance turn-by-turn (e.g. current step, cursor, collected fields, pending changes). Absent for agents that don't expose one.",
220
223
  "kind:'event' — a meta/lifecycle event. `type` names it (see below); `detail` carries structured data. `detail.placement` is the emitting UI instance: 'bubble' (collapsed), 'panel' (popped-out), or 'standalone'.",
221
224
  "Each 'event' also has an `importance`: 'high' (failures/limits — turn.error, tool.failed, subagent.failed, file.read-failed, suggestions.failed, context.threshold-crossed), 'normal' (session flow — connects, turns, retries, handoffs, agent/provider changes, interactions, sub-agent start/complete), or 'low' (skippable UI/bookkeeping noise — panel.toggled, attachment.added, driver.wired/unwired, context.updated, context.condensed). To skim, ignore importance:'low'; to triage a failure, filter to importance:'high' then read the nearby messages and turns. A 'high' turn.error is often preceded by one or more 'normal' turn.retry events for the same reason — read them together to see how many attempts were made before bailing. 'message' and 'turn' entries carry no importance — they are the substance, always read them.",
222
- 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused/context-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, for context-exhausted the estimated contextTokens against the guard that stopped the turn plus the iterations reached — the driver ending a runaway tool loop at the edge of the context window rather than issuing a request the provider would reject, so the transcript survives intact and can still be compacted, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused/context_exhausted; budget_exhausted, provider_refused and context_exhausted are all terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
225
+ 'Event types: assistant.connected/disconnected (mount + placement + whether the session was created or restored), assistant.popout/popin (window placement), driver.created/wired/unwired (which driver is live and why it stops/starts responding across a popout), state.changed (idle↔loading), turn.start/turn.end (turn boundary; turn.end carries durationMs), turn.retry (a recoverable in-turn retry — detail.reason plus attempt/maxAttempts; for malformed calls also finishMessage; for empty responses also the provider finishReason + thoughtsTokens + parts breakdown), turn.error (a turn failed or hit a guardrail — detail.reason is one of exception/malformed-function-call/empty-response/unknown-tool-limit/max-iterations/response-truncated/refusal/budget-exhausted/provider-refused/context-exhausted, plus reason-specific diagnostics: attempts (for empty-response also finishReason + thoughtsTokens + a parts breakdown, distinguishing a thinking-only STOP from a truly empty turn), finishMessage, for response-truncated the model + maxTokens + outputTokens + tools, unknownTools (split into staleTools — real earlier this activation but retired by the current state or hidden behind an open exclusive fold — and hallucinatedTools — never advertised) + availableTools, iterations + limit, for budget-exhausted the budgetUsd + spentUsd figures reported by the proxy plus the resolved vendor, for provider-refused the kind (spend|auth) plus the upstream status and error type — the vendor refusing the ACCOUNT rather than us refusing to spend, so the remedy is a top-up or a key rotation rather than a raised cap, and the kind is the only place that distinction survives because the user-facing copy is deliberately cause-free, for context-exhausted the estimated contextTokens against the guard that stopped the turn plus the iterations reached — the driver ending a runaway tool loop at the edge of the context window rather than issuing a request the provider would reject, so the transcript survives intact and can still be compacted, or name + message for exceptions), tool.failed (a tool threw), tool.unresolved (the model called a tool that could not be dispatched — detail.kind is folded/fold-hidden/stale/unknown, plus tool + agent and, for the counted kinds, the consecutive streak; the recurring lead-up to an unknown-tool-limit turn.error), subagent.started/completed/failed (the lifecycle of a `requestSubAgent` delegation — detail.agent names the sub-agent; these bracket the sub-agent turns that appear as kind:turn entries with an N-M `turnIndex`; subagent.failed also carries detail.reason, one of max_iterations/malformed_tool_call/empty_response/unknown_tool_limit/timeout/response_truncated/refusal/budget_exhausted/provider_refused/context_exhausted; budget_exhausted, provider_refused and context_exhausted are all terminal for the PARENT turn too — the parent stops rather than calling the model again into the same wall), agent.handoff (routing; from=null is the initial activation), agent.pinned/unpinned (forced routing), routing.decided (ONE PER ROUTING DECISION — see its own paragraph below), provider.selected (the resolved provider for the upcoming turns — detail.provider is the registry SLOT/tier name, detail.model the concrete model behind it and detail.vendor its vendor; emitted only when the slot CHANGES, so read the per-turn `model` for the model of any given call rather than assuming the nearest event still applies), interaction.requested/resolved (blocking user widgets — explain quiet gaps; note that when a sub-agent opens a widget, detail.agent — and the agentName on the interaction message — is the HOST agent that owns the widget, NOT the sub-agent that asked, because widgets render and resolve on the host driver), context.updated/threshold-crossed (token + cost), context.condensed (a stale tool payload was collapsed out of the model-bound history by a `condenseWhen` declaration on the tool — detail.tool + toolCallId, target args|response, trigger (superseded:<key> or age:<n>), stubLen, and an estimated tokensSaved; stored history and this log keep the FULL payload, so the model-visible slice at any point is the full history minus the condensations recorded up to then), panel.toggled, attachment.added, file.read-failed, suggestions.failed.',
223
226
  'Sub-agent meta events: a sub-agent\'s own turn.retry/turn.error/tool.failed/tool.unresolved events are merged into this same timeline, tagged with `detail.subAgent` — a `"<parent> › <sub-agent>"` breadcrumb that composes when nested (e.g. `"UI Builder › Planner › Grounding"`) — and interleaved by their original timestamps within the subagent.started→completed/failed bracket. These are the per-attempt/per-failure signals that do NOT appear among the sub-agent\'s (hoisted) messages: a malformed/empty attempt that gets retried produces no message, and the stale-vs-hallucinated split and streak counts live only on the event. A sub-agent\'s high-volume, message-derivable events (turn.start/turn.end, provider.selected, context.updated) are intentionally NOT merged — read its hoisted messages for model/tokens/cost and turn-by-turn activity, and the bracketing subagent.* events for the run\'s span.',
224
227
  "`meta` holds context captured at export time: agentSummary (full agent configs), context (active model, contextTokens/contextLimit/contextUsagePercent for the last call, and the session totals — sessionCostUsd, sessionTokensConsumed, and the four-bucket sessionUsage lifted to the top of this log), activeDebugSnapshot (the active agent's `getDebugSnapshot()` taken fresh at export — reflects state NOW, which may have advanced beyond the last turn's agentSnapshot), debug (optional host-supplied debug state), host, and the export timestamp.",
225
228
  'Note the two different scopes in `meta.context`: `contextTokens` is the prompt size of the LAST call (against `contextLimit`, the model context window), while `sessionUsage`/`sessionTokensConsumed` are cumulative BILLED throughput. Every turn resends the conversation, so the cumulative figure counts each turn’s prompt again in the next turn’s and is expected to dwarf the context size — that is not double-counting.',
226
- 'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it.',
229
+ "kind:'event' type 'routing.decided' — how ONE turn was routed, emitted by the orchestrating driver before the agent runs (and again after every handoff). `decision` is why that agent was chosen: 'classified' (the classifier ran and picked one), 'no-match' (it ran and answered -1 — nothing listed covers the request), 'ballot-exhausted' (every eligible specialist had already handed off this turn, so the ballot was empty and NO call was made — distinct from 'no-match', where one ran and declined), 'pinned' (a user pin or an active stateful flow owned the turn, so no call was made), 'single-candidate'/'no-specialists' (configuration left nothing to decide), 'provider-unavailable' (the registry default exposes no chat()), 'classifier-failed' (every attempt threw — `error` carries the last one). `to` names the agent and `fallback` says whether that agent is the fallback. `candidates` is what was actually ON THE BALLOT, in ballot order, so `index` — the classifier's own answer — indexes it directly; `excluded` names the specialists left off because they already handed off this turn, so the full eligible set is `candidates` ∪ `excluded`. `candidates` is absent on the four decisions that put nothing to a vote — 'pinned', 'single-candidate', 'no-specialists', 'ballot-exhausted'. It IS present on 'provider-unavailable', which is not an exception worth removing: the ballot was built there, it simply never got sent. `attempts` covers the retry ladder. `durationMs` is on EVERY routing.decided, and is the field to read when a decision looks wrong: the four decisions that can reach the fallback are told apart by the clock as much as by their name, because a fallback routing that resolved in tens of milliseconds made no network call at all whatever its `decision` says.",
230
+ "kind:'event' type 'routing.decided'.`usage` — what that classifier call cost, in the same four-bucket + costUsd shape as `sessionUsage`, plus `model`. Absent on the decisions that make no call (see 'pinned' and the configuration ones). THIS SPEND IS NOT IN `sessionUsage`: a classifier call never enters the transcript and `sessionUsage` is derived from the transcript, so classifier cost is missing from the session total by construction, not by accident. Sum these events to close that gap — it is the one component of a session's real spend that the totals cannot see.",
231
+ 'To debug a failure: find the last turn.error or tool.failed, then read upward for the user message, the turn(s), and the agent/provider/state events that led into it. For a turn that went to the wrong agent, read its routing.decided first — `decision` says whether anything was actually asked.',
227
232
  ];
228
233
  /**
229
234
  * Removes all entries. Exposed for test isolation only — not part of the
@@ -42,16 +42,40 @@ export const COMPACTION_SUMMARY_TOKEN_ESTIMATE = 1200;
42
42
  * `assistant(toolCalls)` and its results must not be mistaken for a safe split.
43
43
  *
44
44
  * Mirrors what the request-build chokepoint and the transports actually send:
45
- * `system-event` / `synthetic-user` are display-only rows, and the
45
+ * `system-event` / `synthetic-user` are display-only rows, the
46
46
  * reasoning/narration splits (GENC-1410, plus the legacy `thinking` flag they
47
- * superseded) are skipped when building the request. Counting any of them would
48
- * inflate every estimate against a prompt that never carries them.
47
+ * superseded) are skipped when building the request, and `excludeFromProvider`
48
+ * marks prose the driver wrote rather than the model (GENC-1573). Counting any
49
+ * of them would inflate every estimate against a prompt that never carries
50
+ * them.
51
+ *
52
+ * Keep this list aligned with the skip block in `toAnthropicMessages` /
53
+ * `toGeminiContents` — it exists to predict them.
54
+ *
55
+ * SCOPED TO WHAT CANONICAL HISTORY CARRIES, though, which is less than the wire
56
+ * decides. This runs against stored history, whereas `transformHistoryForAgent`
57
+ * is applied later and only at the request-build site — so neither of the things
58
+ * that transform does is visible here: the `excludeFromProvider` it stamps on
59
+ * another agent's `toolResult`, nor the foreign `tool_use` blocks it flattens to
60
+ * prose. In the session quoted in its docblock that is 389 `tool_use` + 313
61
+ * `tool_result` payloads still counted in full against a prompt that no longer
62
+ * carries them. So for `excludeFromProvider` this predicate is exact only for the
63
+ * driver-authored notices that carry the flag in stored history.
64
+ *
65
+ * The divergence predates the flag — masking already emptied `args` after
66
+ * estimation — and is left as is deliberately: the estimator over-counts, which
67
+ * errs towards compacting early rather than towards an over-limit request. Noted
68
+ * because the next person to trust this as an exact mirror will be debugging a
69
+ * multi-agent session that compacts sooner than they expect. Reviewer catch on
70
+ * #2496.
49
71
  */
50
72
  export function reachesProvider(m) {
51
73
  if (m.role === 'system-event' || m.role === 'synthetic-user')
52
74
  return false;
53
75
  if (m.category === 'reasoning' || m.category === 'narration')
54
76
  return false;
77
+ if (m.excludeFromProvider)
78
+ return false;
55
79
  return !m.thinking;
56
80
  }
57
81
  /** Images on a message — both the user-attached kind and the kind a tool handed back. */