@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.
- package/dist/ai-assistant.api.json +135 -1
- package/dist/ai-assistant.d.ts +152 -0
- package/dist/chat-driver.cjs +281 -81
- package/dist/chat-driver.cjs.map +2 -2
- package/dist/chat-driver.mjs +277 -81
- package/dist/chat-driver.mjs.map +2 -2
- package/dist/custom-elements.json +175 -8
- package/dist/dts/components/chat-driver/chat-driver.d.ts +39 -0
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/index.d.ts +2 -1
- package/dist/dts/components/orchestrating-driver/index.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +101 -0
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/config/fallback-agents.d.ts +7 -0
- package/dist/dts/config/fallback-agents.d.ts.map +1 -1
- package/dist/dts/state/debug-event-log.d.ts +1 -1
- package/dist/dts/state/debug-event-log.d.ts.map +1 -1
- package/dist/dts/utils/context-tokens.d.ts +25 -3
- package/dist/dts/utils/context-tokens.d.ts.map +1 -1
- package/dist/dts/utils/history-transform.d.ts +23 -3
- package/dist/dts/utils/history-transform.d.ts.map +1 -1
- package/dist/esm/components/chat-driver/chat-driver.js +72 -56
- package/dist/esm/components/orchestrating-driver/index.js +1 -1
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +266 -36
- package/dist/esm/config/fallback-agents.js +12 -1
- package/dist/esm/state/debug-event-log.js +7 -2
- package/dist/esm/utils/context-tokens.js +27 -3
- package/dist/esm/utils/history-transform.js +54 -5
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
624
|
-
let
|
|
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
|
-
|
|
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 <
|
|
643
|
-
|
|
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
{
|
|
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.
|
|
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
|
-
'
|
|
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,
|
|
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
|
|
48
|
-
*
|
|
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. */
|