@genesislcap/ai-assistant 15.19.6 → 15.20.0-alpha.3
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 +707 -69
- package/dist/ai-assistant.d.ts +443 -25
- package/dist/chat-driver.cjs +440 -34
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +440 -34
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +709 -47
- package/dist/dts/components/ai-driver/ai-driver.d.ts +40 -7
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +81 -2
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +23 -3
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts.map +1 -1
- package/dist/dts/config/config.d.ts +44 -0
- package/dist/dts/config/config.d.ts.map +1 -1
- package/dist/dts/main/main.d.ts +187 -5
- package/dist/dts/main/main.d.ts.map +1 -1
- package/dist/dts/main/main.styles.d.ts.map +1 -1
- package/dist/dts/main/main.template.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics-cursors.d.ts +7 -2
- package/dist/dts/state/persistence/diagnostics-cursors.d.ts.map +1 -1
- package/dist/dts/state/persistence/diagnostics.d.ts +30 -0
- package/dist/dts/state/persistence/diagnostics.d.ts.map +1 -1
- package/dist/dts/state/persistence/session-persister.d.ts +18 -0
- package/dist/dts/state/persistence/session-persister.d.ts.map +1 -1
- package/dist/dts/utils/condense-history.d.ts +35 -0
- package/dist/dts/utils/condense-history.d.ts.map +1 -1
- package/dist/dts/utils/context-tokens.d.ts +156 -0
- package/dist/dts/utils/context-tokens.d.ts.map +1 -0
- package/dist/dts/utils/history-transform.d.ts +76 -14
- package/dist/dts/utils/history-transform.d.ts.map +1 -1
- package/dist/dts/utils/resolve-context-budget.d.ts +98 -0
- package/dist/dts/utils/resolve-context-budget.d.ts.map +1 -0
- package/dist/esm/components/chat-driver/chat-driver.js +200 -35
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +28 -4
- package/dist/esm/main/main.js +400 -24
- package/dist/esm/main/main.styles.js +128 -0
- package/dist/esm/main/main.template.js +64 -29
- package/dist/esm/state/debug-event-log.js +1 -1
- package/dist/esm/state/persistence/diagnostics-cursors.js +1 -1
- package/dist/esm/state/persistence/diagnostics.js +49 -0
- package/dist/esm/state/persistence/session-persister.js +38 -18
- package/dist/esm/utils/condense-history.js +125 -26
- package/dist/esm/utils/context-tokens.js +339 -0
- package/dist/esm/utils/history-transform.js +101 -19
- package/dist/esm/utils/resolve-context-budget.js +84 -0
- package/package.json +16 -16
- package/sandbox/README.md +93 -4
- package/sandbox/controls.ts +77 -10
- package/sandbox/fixtures.ts +163 -6
- package/sandbox/sandbox.css +54 -1
- package/sandbox/sandbox.ts +384 -7
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { COMPACTION_SUMMARY_TOKEN_ESTIMATE } from './context-tokens';
|
|
2
|
+
// Re-exported, not redeclared: this module and `planCompaction` must apply the
|
|
3
|
+
// SAME worth-it floor, or a host passing explicit options and a caller relying
|
|
4
|
+
// on the module default would silently disagree about whether a compaction is
|
|
5
|
+
// worth running. `history-transform` owns it because that is where the plan is
|
|
6
|
+
// made; this module only threads it through.
|
|
7
|
+
import { DEFAULT_MIN_RECLAIM_TOKENS } from './history-transform';
|
|
8
|
+
export { DEFAULT_MIN_RECLAIM_TOKENS };
|
|
9
|
+
/**
|
|
10
|
+
* Context headroom thresholds (GENC-1567) — resolving `chatConfig.context`,
|
|
11
|
+
* the active model's window, and any per-agent reserve into the four numbers
|
|
12
|
+
* the gate and the compaction planner actually read.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Tokens kept free for a single turn by default.
|
|
16
|
+
*
|
|
17
|
+
* A turn is not atomic in cost: a send allowed just under a threshold can run a
|
|
18
|
+
* long tool loop and add a great deal on its own, so what has to be bounded is
|
|
19
|
+
* the growth of ONE turn — a token count, not a fraction of the window. On a
|
|
20
|
+
* 200k model this leaves ~12% free; on a 1M model the same absolute room, rather
|
|
21
|
+
* than the 200k a percentage rule would strand.
|
|
22
|
+
*
|
|
23
|
+
* Chosen as a starting position, not a measured figure. Per-turn `inputTokens`
|
|
24
|
+
* is recorded for every call, so this should be revisited against real traffic.
|
|
25
|
+
*/
|
|
26
|
+
export const DEFAULT_RESERVE_TOKENS = 25000;
|
|
27
|
+
/** Warning appears this many reserves ahead of the block. */
|
|
28
|
+
export const DEFAULT_WARN_MULTIPLIER = 2;
|
|
29
|
+
/**
|
|
30
|
+
* How close to the window a running turn is allowed to get before the driver
|
|
31
|
+
* stops it (GENC-1567). Roughly one more tool result plus a reply — enough that
|
|
32
|
+
* the next request would very likely be refused.
|
|
33
|
+
*/
|
|
34
|
+
export const CONTEXT_ABORT_MARGIN_TOKENS = 4096;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the headroom thresholds for the active model and agent.
|
|
37
|
+
*
|
|
38
|
+
* Everything downstream — the warning, the block, the compaction planner and the
|
|
39
|
+
* banner copy — reads this one result, so the thresholds a user is judged
|
|
40
|
+
* against and the budget compaction aims at can never disagree.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveContextBudget(input) {
|
|
43
|
+
var _a, _b, _c;
|
|
44
|
+
const { chatConfig, contextLimit, agentReserveTokens, systemOverhead = 0 } = input;
|
|
45
|
+
const cfg = chatConfig.context;
|
|
46
|
+
const reserveTokens = Math.max(0, (_a = agentReserveTokens !== null && agentReserveTokens !== void 0 ? agentReserveTokens : cfg === null || cfg === void 0 ? void 0 : cfg.reserveTokens) !== null && _a !== void 0 ? _a : DEFAULT_RESERVE_TOKENS);
|
|
47
|
+
const warnMultiplier = Math.max(1, (_b = cfg === null || cfg === void 0 ? void 0 : cfg.warnMultiplier) !== null && _b !== void 0 ? _b : DEFAULT_WARN_MULTIPLIER);
|
|
48
|
+
const minReclaimTokens = Math.max(0, (_c = cfg === null || cfg === void 0 ? void 0 : cfg.minReclaimTokens) !== null && _c !== void 0 ? _c : DEFAULT_MIN_RECLAIM_TOKENS);
|
|
49
|
+
const limit = contextLimit !== null && contextLimit !== void 0 ? contextLimit : cfg === null || cfg === void 0 ? void 0 : cfg.fallbackContextLimit;
|
|
50
|
+
// No limit → no thresholds. The gate stays inert rather than guessing a window
|
|
51
|
+
// and blocking the composer against the guess.
|
|
52
|
+
if (limit == null || limit <= 0) {
|
|
53
|
+
return {
|
|
54
|
+
enabled: false,
|
|
55
|
+
limit: undefined,
|
|
56
|
+
reserveTokens,
|
|
57
|
+
tailTokenBudget: Math.max(0, reserveTokens),
|
|
58
|
+
minReclaimTokens,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const enabled = (cfg === null || cfg === void 0 ? void 0 : cfg.enabled) !== false;
|
|
62
|
+
const blockAt = Math.max(0, limit - reserveTokens);
|
|
63
|
+
const warnAt = Math.max(0, limit - warnMultiplier * reserveTokens);
|
|
64
|
+
// Aim compaction at the warning line, and leave room for the summary itself.
|
|
65
|
+
// Floored at one reserve so a pathological config (a reserve wider than the
|
|
66
|
+
// window) still asks for a tail rather than an impossible zero.
|
|
67
|
+
const tailTokenBudget = Math.max(reserveTokens, warnAt - systemOverhead - COMPACTION_SUMMARY_TOKEN_ESTIMATE);
|
|
68
|
+
return {
|
|
69
|
+
enabled,
|
|
70
|
+
limit,
|
|
71
|
+
reserveTokens,
|
|
72
|
+
warnAt,
|
|
73
|
+
blockAt,
|
|
74
|
+
tailTokenBudget,
|
|
75
|
+
minReclaimTokens,
|
|
76
|
+
// Withheld when the host opted out. `enabled: false` is documented as "no
|
|
77
|
+
// warning and no block", and a host that would rather take the provider's
|
|
78
|
+
// answer than ever have its composer disabled means the mid-loop guard too —
|
|
79
|
+
// otherwise the visible gate disappears as asked while a long tool loop still
|
|
80
|
+
// dies with `context-exhausted`, which is the same refusal wearing a
|
|
81
|
+
// different hat.
|
|
82
|
+
abortAt: enabled ? Math.max(blockAt, limit - CONTEXT_ABORT_MARGIN_TOKENS) : undefined,
|
|
83
|
+
};
|
|
84
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genesislcap/ai-assistant",
|
|
3
3
|
"description": "Genesis AI Assistant micro-frontend",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.20.0-alpha.3",
|
|
5
5
|
"license": "SEE LICENSE IN license.txt",
|
|
6
6
|
"main": "dist/esm/index.js",
|
|
7
7
|
"types": "dist/ai-assistant.d.ts",
|
|
@@ -74,26 +74,26 @@
|
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
|
-
"@genesislcap/foundation-testing": "15.
|
|
78
|
-
"@genesislcap/genx": "15.
|
|
79
|
-
"@genesislcap/rollup-builder": "15.
|
|
80
|
-
"@genesislcap/ts-builder": "15.
|
|
81
|
-
"@genesislcap/uvu-playwright-builder": "15.
|
|
82
|
-
"@genesislcap/vite-builder": "15.
|
|
83
|
-
"@genesislcap/webpack-builder": "15.
|
|
77
|
+
"@genesislcap/foundation-testing": "15.20.0-alpha.3",
|
|
78
|
+
"@genesislcap/genx": "15.20.0-alpha.3",
|
|
79
|
+
"@genesislcap/rollup-builder": "15.20.0-alpha.3",
|
|
80
|
+
"@genesislcap/ts-builder": "15.20.0-alpha.3",
|
|
81
|
+
"@genesislcap/uvu-playwright-builder": "15.20.0-alpha.3",
|
|
82
|
+
"@genesislcap/vite-builder": "15.20.0-alpha.3",
|
|
83
|
+
"@genesislcap/webpack-builder": "15.20.0-alpha.3",
|
|
84
84
|
"@types/dompurify": "^3.0.5",
|
|
85
85
|
"@types/marked": "^5.0.2",
|
|
86
86
|
"esbuild": "0.25.12"
|
|
87
87
|
},
|
|
88
88
|
"dependencies": {
|
|
89
|
-
"@genesislcap/foundation-ai": "15.
|
|
90
|
-
"@genesislcap/foundation-logger": "15.
|
|
91
|
-
"@genesislcap/foundation-notifications": "15.
|
|
92
|
-
"@genesislcap/foundation-redux": "15.
|
|
93
|
-
"@genesislcap/foundation-ui": "15.
|
|
94
|
-
"@genesislcap/foundation-utils": "15.
|
|
95
|
-
"@genesislcap/rapid-design-system": "15.
|
|
96
|
-
"@genesislcap/web-core": "15.
|
|
89
|
+
"@genesislcap/foundation-ai": "15.20.0-alpha.3",
|
|
90
|
+
"@genesislcap/foundation-logger": "15.20.0-alpha.3",
|
|
91
|
+
"@genesislcap/foundation-notifications": "15.20.0-alpha.3",
|
|
92
|
+
"@genesislcap/foundation-redux": "15.20.0-alpha.3",
|
|
93
|
+
"@genesislcap/foundation-ui": "15.20.0-alpha.3",
|
|
94
|
+
"@genesislcap/foundation-utils": "15.20.0-alpha.3",
|
|
95
|
+
"@genesislcap/rapid-design-system": "15.20.0-alpha.3",
|
|
96
|
+
"@genesislcap/web-core": "15.20.0-alpha.3",
|
|
97
97
|
"dompurify": "^3.3.1",
|
|
98
98
|
"marked": "^17.0.3"
|
|
99
99
|
},
|
package/sandbox/README.md
CHANGED
|
@@ -28,10 +28,11 @@ The panel covers, roughly in the order you'll want them:
|
|
|
28
28
|
| **Composer** | `acceptedFiles` (the paperclip), attachment + rejected-file chips, the resize handle, `hideComposer`, `hideStopButton`, the placeholder |
|
|
29
29
|
| **Agent picker & pinning** | `picker.mode`, how many agents exist, whether they opt in to manual selection, pin colours; a button to open the picker panel |
|
|
30
30
|
| **Session menu (⋮)** | Whether a persistence provider is configured (the menu is gated on one) and `showSessionMenu`; a button to open the menu |
|
|
31
|
+
| **Context window** | **Jump to state** (the four gate states in one click), the simulated window and fill, `chatConfig.context`, a flow-in-progress toggle, live readouts of the gate / thresholds / compaction plan, and Compact |
|
|
31
32
|
| **Simulated state** | Forces `idle` / busy / cancelling / blocked / compacting / restoring onto the element |
|
|
32
|
-
| **Transcript** | Seeds an empty, short, tool-and-markdown-heavy,
|
|
33
|
+
| **Transcript** | Seeds an empty, short, tool-and-markdown-heavy, 28-turn, ~20k-token, or ~22k-token tool-loop conversation |
|
|
33
34
|
| **Chrome** | Header title, popout mode, suggestions, the tool-call / reasoning / narration toggles, animations |
|
|
34
|
-
| **Stage** | Frame size, light/dark, remount, full reset |
|
|
35
|
+
| **Stage** | Frame size (floored at 400px — see below), light/dark, remount, full reset |
|
|
35
36
|
|
|
36
37
|
Panel state persists in `localStorage`, so a reload picks up where you left off. **Reset sandbox**
|
|
37
38
|
clears it.
|
|
@@ -44,8 +45,93 @@ composer all behave as they do against a live model. It also answers the orchest
|
|
|
44
45
|
classifier call, round-robining across the configured agents so agent switching — and the
|
|
45
46
|
"Switched to X" divider — actually happens.
|
|
46
47
|
|
|
47
|
-
It is not a model. It ignores the system prompt
|
|
48
|
-
|
|
48
|
+
It is not a model. It ignores the system prompt and never calls a tool. It *does* answer the
|
|
49
|
+
compaction summariser, so **Compact** works end to end — the reply is canned, but the plan, the
|
|
50
|
+
splice, the divider and the shrunken context are all real. Suggestions still get nothing.
|
|
51
|
+
|
|
52
|
+
## Checking the context gate (GENC-1567)
|
|
53
|
+
|
|
54
|
+
The gate warns as the window fills and blocks sending before it overflows. Two things make it
|
|
55
|
+
awkward to exercise honestly, and the panel works around both:
|
|
56
|
+
|
|
57
|
+
- **Nothing reports usage.** The stub returns no `inputTokens`, so `contextTokens` would never be
|
|
58
|
+
set and the gate would sit inert forever. **Fill from** decides how it is faked. `Transcript
|
|
59
|
+
(measured)` — the default — runs the component's own estimator over the seeded conversation, so
|
|
60
|
+
the gate and the compaction planner are looking at the same thing. `Manual %` forces a
|
|
61
|
+
percentage, and `Unset` leaves it undefined, which is the real state on a provider that reports
|
|
62
|
+
no usage and where the gate must stay quiet rather than guess.
|
|
63
|
+
|
|
64
|
+
A manual fill can **disagree with the transcript**, and that is worth understanding rather than
|
|
65
|
+
avoiding: claim 85% of the window while the transcript holds 250 tokens and the gate says "full"
|
|
66
|
+
while the planner correctly reports nothing worth compacting — so the blocked copy offers a new
|
|
67
|
+
chat instead of Compact. The component is right about an impossible situation. The **Transcript**
|
|
68
|
+
readout flags the mismatch so it is visible rather than mystifying.
|
|
69
|
+
- **A real window is too big to fill by hand.** At 200k the block sits at 175k, and a transcript
|
|
70
|
+
that large makes the page miserable to work in. **Window (tokens)** shrinks it. It is written to
|
|
71
|
+
the stub provider as well as the element, because the element re-resolves its limit from the
|
|
72
|
+
active provider and would otherwise silently revert a value poked straight onto it.
|
|
73
|
+
|
|
74
|
+
The three readouts are the actual check. Every figure in them is read back **off the element** —
|
|
75
|
+
`contextGate`, `contextBudget`, `compactionPlan`, `compactHint` — rather than recomputed by the
|
|
76
|
+
panel, which would only ever confirm the panel agrees with itself.
|
|
77
|
+
|
|
78
|
+
### Jump to state
|
|
79
|
+
|
|
80
|
+
The fastest way in. **Jump to state** sets the whole group below to land on one of four:
|
|
81
|
+
|
|
82
|
+
| | What you get |
|
|
83
|
+
| --- | --- |
|
|
84
|
+
| **Normal** | Plenty of room. No label, no outline. |
|
|
85
|
+
| **Warning** | Amber pill reading "Approaching context limit", amber composer outline, sending still works. |
|
|
86
|
+
| **Blocked — compacting will clear it** | Red pill reading "At context limit", red outline, composer disabled, and the placeholder offers **Compact**. |
|
|
87
|
+
| **Blocked — compacting will NOT clear it** | The same red treatment, but the placeholder offers a **new chat** instead. |
|
|
88
|
+
|
|
89
|
+
The last two are the pair worth understanding, because the difference is not visible in the panel:
|
|
90
|
+
it is whether a compaction would reclaim enough to be worth offering. The compactable one seeds a
|
|
91
|
+
long tool loop, which has cuttable boundaries all through it. The stuck one seeds a single tool
|
|
92
|
+
result larger than any tail budget — a cut cannot split a message, so no boundary anywhere shrinks
|
|
93
|
+
it, and the component correctly stops recommending Compact. One oversized file read does this in a
|
|
94
|
+
real conversation.
|
|
95
|
+
|
|
96
|
+
Each preset drives the ordinary controls rather than forcing the element, so what you land on is
|
|
97
|
+
the component deciding for itself. Move anything by hand afterwards and the dropdown drops to
|
|
98
|
+
**Custom** — it is a shortcut, not a readout, and the **Gate** line is what tells you where you
|
|
99
|
+
actually are.
|
|
100
|
+
|
|
101
|
+
### The rest of the panel
|
|
102
|
+
|
|
103
|
+
A run worth doing:
|
|
104
|
+
|
|
105
|
+
1. Seed **Tool loop**. One user turn, then 24 tool-call/result pairs and no user message anywhere
|
|
106
|
+
inside — the shape the old compaction rule could not touch at all, because it could only cut at
|
|
107
|
+
a user boundary and there was none to cut at. The plan readout should show a real reclaim with
|
|
108
|
+
the cut landing inside the loop (around message 23 of 50, at the defaults).
|
|
109
|
+
2. On the default **Transcript (measured)** fill, that seed alone lands you at the block — no
|
|
110
|
+
slider needed. The ⋮ pill grows an amber "Approaching context limit" label at the warning and a
|
|
111
|
+
red "At context limit" at the block, the composer picks up a matching outline from the warning
|
|
112
|
+
onwards, and at the block it disables with the full explanation in its placeholder. Seed
|
|
113
|
+
**Huge** for the warning state and **Tools, markdown, agent switch** to drop back to clear.
|
|
114
|
+
3. Turn **`showSessionMenu`** off (or the persistence provider), still past the warning. The gate
|
|
115
|
+
forces ⋮ back on screen — and the menu shows **Compact only**, no Clear.
|
|
116
|
+
4. Turn **Flow in progress** on at a blocking fill. The block downgrades to a warning and sending
|
|
117
|
+
works again: a flow always gets to finish.
|
|
118
|
+
5. Hit **Compact**. Watch the transcript collapse to a divider plus the tail, and the readouts drop
|
|
119
|
+
back under the warning line.
|
|
120
|
+
|
|
121
|
+
**Thresholds** also shows `abort`, the point at which a *running* turn is stopped mid-loop. It sits
|
|
122
|
+
far above `block` on purpose: the reserve exists so an accepted turn can spend it, and a guard at
|
|
123
|
+
the block line would kill every turn that used the headroom it was given.
|
|
124
|
+
|
|
125
|
+
## 400px is the narrowest supported width
|
|
126
|
+
|
|
127
|
+
The stage will not go below it — slider, drag handle and a restored panel state are all clamped —
|
|
128
|
+
because the assistant is not built narrower than that. The blocked-state placeholder is the case
|
|
129
|
+
that pins it: it carries two sentences, and the height floor that stops them being clipped is sized
|
|
130
|
+
from measurement at 400px. Narrower, the message wraps further and clips again.
|
|
131
|
+
|
|
132
|
+
If you need to know the real requirement at some width, measure it rather than eyeballing a
|
|
133
|
+
screenshot — `scrollHeight > clientHeight` on the inner `textarea`. A line that is 8px short looks
|
|
134
|
+
complete.
|
|
49
135
|
|
|
50
136
|
## Things worth knowing
|
|
51
137
|
|
|
@@ -59,6 +145,9 @@ summarisation) returns nothing — so **Compact** runs but produces no summary.
|
|
|
59
145
|
seed simply re-applies. **Reset sandbox** clears both.
|
|
60
146
|
- **"Simulated state" is a blunt instrument.** It writes `state` / `blocked` / `compacting` /
|
|
61
147
|
`restoring` straight onto the element. A real send will move `state` again underneath it.
|
|
148
|
+
- **Compact is correctly a no-op on a small transcript.** Compaction is sized in tokens now, so ten
|
|
149
|
+
short turns are not worth a summariser call and the button stays greyed out. The plan readout says
|
|
150
|
+
why. Seed **Tool loop** or **Huge** to give it something to do.
|
|
62
151
|
- **The panel is deliberately plain HTML.** It has to stay usable while the design-system components
|
|
63
152
|
it would otherwise be built from are the ones being edited, and it sits outside the design system
|
|
64
153
|
provider so a theme change on the stage can't restyle it.
|
package/sandbox/controls.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type ControlDef<S> =
|
|
|
15
15
|
label: string;
|
|
16
16
|
options: ReadonlyArray<{ value: string; label: string }>;
|
|
17
17
|
hint?: string;
|
|
18
|
+
/** Lifts the row out of the list — for the one control a group is really about. */
|
|
19
|
+
featured?: boolean;
|
|
18
20
|
}
|
|
19
21
|
| { kind: 'text'; key: keyof S & string; label: string; placeholder?: string; hint?: string }
|
|
20
22
|
| {
|
|
@@ -26,7 +28,14 @@ export type ControlDef<S> =
|
|
|
26
28
|
step?: number;
|
|
27
29
|
hint?: string;
|
|
28
30
|
}
|
|
29
|
-
| { kind: 'action'; label: string; run: () => void; hint?: string }
|
|
31
|
+
| { kind: 'action'; label: string; run: () => void; hint?: string }
|
|
32
|
+
/**
|
|
33
|
+
* A read-only line whose text is recomputed on demand rather than bound to
|
|
34
|
+
* state — for showing what the component DERIVED, which is the only way to
|
|
35
|
+
* check a derivation by hand. `renderControls` returns a refresh function; call
|
|
36
|
+
* it after applying state.
|
|
37
|
+
*/
|
|
38
|
+
| { kind: 'readout'; label: string; render: () => string; hint?: string };
|
|
30
39
|
|
|
31
40
|
export interface ControlGroup<S> {
|
|
32
41
|
title: string;
|
|
@@ -61,16 +70,31 @@ const withHint = (row: HTMLElement, hint?: string): HTMLElement => {
|
|
|
61
70
|
|
|
62
71
|
/**
|
|
63
72
|
* Renders `groups` into `host`, reading from and writing to `state`. `onChange`
|
|
64
|
-
* fires after every edit with the mutated state
|
|
65
|
-
* the assistant.
|
|
73
|
+
* fires after every edit with the mutated state and the key that changed — the
|
|
74
|
+
* caller re-applies it to the assistant.
|
|
75
|
+
*
|
|
76
|
+
* Returns a `refresh()` that re-reads every control's value back out of `state`
|
|
77
|
+
* and recomputes the readouts. That is what lets one control REWRITE others (a
|
|
78
|
+
* preset, say) without rebuilding the panel: a rebuild drops focus mid-drag and
|
|
79
|
+
* collapses any group the user had open.
|
|
66
80
|
*/
|
|
67
81
|
export function renderControls<S extends Record<string, any>>(
|
|
68
82
|
host: HTMLElement,
|
|
69
83
|
groups: ReadonlyArray<ControlGroup<S>>,
|
|
70
84
|
state: S,
|
|
71
|
-
onChange: (state: S) => void,
|
|
72
|
-
): void {
|
|
85
|
+
onChange: (state: S, key?: keyof S & string) => void,
|
|
86
|
+
): () => void {
|
|
73
87
|
host.textContent = '';
|
|
88
|
+
const refreshers: Array<() => void> = [];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Push `state` back into a control, unless the user is currently interacting
|
|
92
|
+
* with it — re-writing the value of a range being dragged fights the drag.
|
|
93
|
+
*/
|
|
94
|
+
const syncing = (node: HTMLElement, write: () => void) => () => {
|
|
95
|
+
if (document.activeElement === node) return;
|
|
96
|
+
write();
|
|
97
|
+
};
|
|
74
98
|
|
|
75
99
|
for (const group of groups) {
|
|
76
100
|
const details = el('details', 'group');
|
|
@@ -91,7 +115,25 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
91
115
|
continue;
|
|
92
116
|
}
|
|
93
117
|
|
|
94
|
-
|
|
118
|
+
if (control.kind === 'readout') {
|
|
119
|
+
const row = el('div', 'row row-readout');
|
|
120
|
+
const name = el('span', 'label');
|
|
121
|
+
name.textContent = control.label;
|
|
122
|
+
const value = el('output', 'readout-value');
|
|
123
|
+
const refresh = () => {
|
|
124
|
+
value.textContent = control.render();
|
|
125
|
+
};
|
|
126
|
+
refresh();
|
|
127
|
+
refreshers.push(refresh);
|
|
128
|
+
row.append(name, value);
|
|
129
|
+
details.appendChild(withHint(row, control.hint));
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const row = el(
|
|
134
|
+
'label',
|
|
135
|
+
`row row-${control.kind}${'featured' in control && control.featured ? ' row-featured' : ''}`,
|
|
136
|
+
);
|
|
95
137
|
const name = el('span', 'label');
|
|
96
138
|
name.textContent = control.label;
|
|
97
139
|
|
|
@@ -101,8 +143,13 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
101
143
|
input.checked = Boolean(state[control.key]);
|
|
102
144
|
input.addEventListener('change', () => {
|
|
103
145
|
(state as any)[control.key] = input.checked;
|
|
104
|
-
onChange(state);
|
|
146
|
+
onChange(state, control.key);
|
|
105
147
|
});
|
|
148
|
+
refreshers.push(
|
|
149
|
+
syncing(input, () => {
|
|
150
|
+
input.checked = Boolean(state[control.key]);
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
106
153
|
row.append(input, name);
|
|
107
154
|
} else if (control.kind === 'select') {
|
|
108
155
|
const select = el('select');
|
|
@@ -115,8 +162,13 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
115
162
|
select.value = String(state[control.key]);
|
|
116
163
|
select.addEventListener('change', () => {
|
|
117
164
|
(state as any)[control.key] = select.value;
|
|
118
|
-
onChange(state);
|
|
165
|
+
onChange(state, control.key);
|
|
119
166
|
});
|
|
167
|
+
refreshers.push(
|
|
168
|
+
syncing(select, () => {
|
|
169
|
+
select.value = String(state[control.key]);
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
120
172
|
row.append(name, select);
|
|
121
173
|
} else if (control.kind === 'text') {
|
|
122
174
|
const input = el('input');
|
|
@@ -125,8 +177,13 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
125
177
|
input.value = String(state[control.key] ?? '');
|
|
126
178
|
input.addEventListener('input', () => {
|
|
127
179
|
(state as any)[control.key] = input.value;
|
|
128
|
-
onChange(state);
|
|
180
|
+
onChange(state, control.key);
|
|
129
181
|
});
|
|
182
|
+
refreshers.push(
|
|
183
|
+
syncing(input, () => {
|
|
184
|
+
input.value = String(state[control.key] ?? '');
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
130
187
|
row.append(name, input);
|
|
131
188
|
} else {
|
|
132
189
|
const input = el('input');
|
|
@@ -140,8 +197,14 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
140
197
|
input.addEventListener('input', () => {
|
|
141
198
|
readout.textContent = input.value;
|
|
142
199
|
(state as any)[control.key] = Number(input.value);
|
|
143
|
-
onChange(state);
|
|
200
|
+
onChange(state, control.key);
|
|
144
201
|
});
|
|
202
|
+
refreshers.push(
|
|
203
|
+
syncing(input, () => {
|
|
204
|
+
input.value = String(state[control.key] ?? control.min);
|
|
205
|
+
readout.textContent = input.value;
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
145
208
|
name.appendChild(readout);
|
|
146
209
|
row.append(name, input);
|
|
147
210
|
}
|
|
@@ -151,4 +214,8 @@ export function renderControls<S extends Record<string, any>>(
|
|
|
151
214
|
|
|
152
215
|
host.appendChild(details);
|
|
153
216
|
}
|
|
217
|
+
|
|
218
|
+
return () => {
|
|
219
|
+
for (const refresh of refreshers) refresh();
|
|
220
|
+
};
|
|
154
221
|
}
|
package/sandbox/fixtures.ts
CHANGED
|
@@ -206,11 +206,128 @@ const LONG: ChatMessage[] = stamped(
|
|
|
206
206
|
]).flat(),
|
|
207
207
|
);
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* The same chars-per-token approximation the estimator falls back to, so a
|
|
211
|
+
* fixture asking for N tokens lands near N on the component's own reckoning.
|
|
212
|
+
*/
|
|
213
|
+
const APPROX_CHARS_PER_TOKEN = 4;
|
|
214
|
+
|
|
215
|
+
/** Default window the stub reports, matching a mid-size production model. */
|
|
216
|
+
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A paragraph of filler sized in tokens. Prose rather than repeated characters
|
|
220
|
+
* so the transcript stays readable while you scroll it.
|
|
221
|
+
*/
|
|
222
|
+
const bulk = (tokens: number): string => {
|
|
223
|
+
const sentence =
|
|
224
|
+
'The position was reconciled against the custodian file and the residual break was ' +
|
|
225
|
+
'allocated to the rates book before the cutoff, with the remainder carried forward. ';
|
|
226
|
+
return sentence.repeat(
|
|
227
|
+
Math.max(1, Math.ceil((tokens * APPROX_CHARS_PER_TOKEN) / sentence.length)),
|
|
228
|
+
);
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* A transcript large enough to actually be worth compacting (GENC-1567).
|
|
233
|
+
*
|
|
234
|
+
* `long` is 28 turns but only a few hundred tokens, and since compaction became
|
|
235
|
+
* token-driven that is correctly NOT worth a summariser call — so it can no
|
|
236
|
+
* longer demonstrate Compact. This one carries real weight per turn.
|
|
237
|
+
*/
|
|
238
|
+
const HUGE_TURNS = 16;
|
|
239
|
+
const HUGE_TOKENS_PER_TURN = 1_200;
|
|
240
|
+
|
|
241
|
+
const HUGE: ChatMessage[] = stamped(
|
|
242
|
+
Array.from({ length: HUGE_TURNS }, (_, i) => [
|
|
243
|
+
user(`Walk me through the breaks on book ${i + 1} in detail.`),
|
|
244
|
+
assistant(`Book ${i + 1}. ${bulk(HUGE_TOKENS_PER_TURN)}`, 'Positions'),
|
|
245
|
+
]).flat(),
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* One user turn followed by a long agentic tool loop — the shape GENC-1567 was
|
|
250
|
+
* written for, and the one to reach for when checking the cut.
|
|
251
|
+
*
|
|
252
|
+
* There is no `user` message anywhere inside the loop, which is exactly why the
|
|
253
|
+
* old cut rule could not touch it: cutting only at a user boundary meant the
|
|
254
|
+
* whole loop was one indivisible block, so compaction reclaimed nothing however
|
|
255
|
+
* large it grew. Seed this, watch the plan report a real reclaim, and Compact —
|
|
256
|
+
* the summary should land with the loop behind it rather than in front.
|
|
257
|
+
*/
|
|
258
|
+
const LOOP_ITERATIONS = 24;
|
|
259
|
+
const LOOP_RESULT_TOKENS = 900;
|
|
260
|
+
|
|
261
|
+
const LOOP: ChatMessage[] = stamped([
|
|
262
|
+
user('Reconcile every book for yesterday and tell me what broke.'),
|
|
263
|
+
...Array.from({ length: LOOP_ITERATIONS }, (_, i) => [
|
|
264
|
+
{
|
|
265
|
+
role: 'assistant' as const,
|
|
266
|
+
content: '',
|
|
267
|
+
agentName: 'Reconciliation',
|
|
268
|
+
toolCalls: [
|
|
269
|
+
{
|
|
270
|
+
id: `loop_${i}`,
|
|
271
|
+
name: 'reconcile_book',
|
|
272
|
+
args: { book: `BOOK-${i + 1}`, asOf: '2026-08-25' },
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
role: 'tool' as const,
|
|
278
|
+
content: '',
|
|
279
|
+
agentName: 'Reconciliation',
|
|
280
|
+
toolResult: {
|
|
281
|
+
toolCallId: `loop_${i}`,
|
|
282
|
+
content: `BOOK-${i + 1} reconciliation detail. ${bulk(LOOP_RESULT_TOKENS)}`,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
]).flat(),
|
|
286
|
+
assistant('All books reconciled. Three had breaks worth reporting.', 'Reconciliation'),
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Over the limit and genuinely **uncompactable** — the case where the gate has to
|
|
291
|
+
* admit compaction is not the answer (GENC-1567).
|
|
292
|
+
*
|
|
293
|
+
* One user turn and a single tool result far larger than any tail budget. A cut
|
|
294
|
+
* cannot split a message, so there is no boundary anywhere that shrinks this:
|
|
295
|
+
* `findCompactionCut` walks back from the end, cannot fit even the one tool
|
|
296
|
+
* result, and returns `null`. The blocked copy then offers a new chat instead of
|
|
297
|
+
* Compact, which is the honest advice.
|
|
298
|
+
*
|
|
299
|
+
* Not a contrived shape either — one oversized file read or query result does
|
|
300
|
+
* exactly this in a real conversation.
|
|
301
|
+
*/
|
|
302
|
+
const WALL_RESULT_TOKENS = 24_000;
|
|
303
|
+
|
|
304
|
+
const WALL: ChatMessage[] = stamped([
|
|
305
|
+
user('Read the whole reconciliation archive and summarise it.'),
|
|
306
|
+
{
|
|
307
|
+
role: 'assistant',
|
|
308
|
+
content: '',
|
|
309
|
+
agentName: 'Reconciliation',
|
|
310
|
+
toolCalls: [{ id: 'wall', name: 'read_archive', args: { path: '/archive/2026-08.csv' } }],
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
role: 'tool',
|
|
314
|
+
content: '',
|
|
315
|
+
agentName: 'Reconciliation',
|
|
316
|
+
toolResult: {
|
|
317
|
+
toolCallId: 'wall',
|
|
318
|
+
content: `Reconciliation archive. ${bulk(WALL_RESULT_TOKENS)}`,
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
]);
|
|
322
|
+
|
|
209
323
|
export const TRANSCRIPTS = {
|
|
210
324
|
empty: [] as ChatMessage[],
|
|
211
325
|
short: SHORT,
|
|
212
326
|
rich: RICH,
|
|
213
327
|
long: LONG,
|
|
328
|
+
huge: HUGE,
|
|
329
|
+
loop: LOOP,
|
|
330
|
+
wall: WALL,
|
|
214
331
|
} as const;
|
|
215
332
|
|
|
216
333
|
export type TranscriptName = keyof typeof TRANSCRIPTS;
|
|
@@ -266,7 +383,22 @@ const countAgentsInPrompt = (systemPrompt?: string): number =>
|
|
|
266
383
|
export class SandboxAIProvider implements AIProvider {
|
|
267
384
|
/** Round-trip delay: long enough to see the busy states, short enough not to annoy. */
|
|
268
385
|
private static readonly LATENCY_MS = 1200;
|
|
269
|
-
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The window `getStatus` reports, and so the one every context threshold is
|
|
389
|
+
* derived from (GENC-1567).
|
|
390
|
+
*
|
|
391
|
+
* **Mutable, and the sandbox writes it before each apply.** Setting
|
|
392
|
+
* `assistant.contextLimit` alone would not hold: the element re-resolves the
|
|
393
|
+
* limit from the provider whenever the active provider changes, and would
|
|
394
|
+
* overwrite a value poked onto it directly. Driving the provider instead means
|
|
395
|
+
* the immediate value and any later re-resolve agree.
|
|
396
|
+
*
|
|
397
|
+
* Shrinking it is the practical way to exercise the gate: at a real 200k
|
|
398
|
+
* window the block sits at 175k, and seeding a transcript that large would
|
|
399
|
+
* make the page miserable to work in.
|
|
400
|
+
*/
|
|
401
|
+
contextLimit = DEFAULT_CONTEXT_LIMIT;
|
|
270
402
|
|
|
271
403
|
private turn = 0;
|
|
272
404
|
private route = 0;
|
|
@@ -275,7 +407,7 @@ export class SandboxAIProvider implements AIProvider {
|
|
|
275
407
|
return {
|
|
276
408
|
provider: 'none',
|
|
277
409
|
model: 'sandbox-stub',
|
|
278
|
-
contextLimit:
|
|
410
|
+
contextLimit: this.contextLimit,
|
|
279
411
|
};
|
|
280
412
|
}
|
|
281
413
|
|
|
@@ -307,9 +439,34 @@ export class SandboxAIProvider implements AIProvider {
|
|
|
307
439
|
return { role: 'assistant', content: reply };
|
|
308
440
|
}
|
|
309
441
|
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
442
|
+
/**
|
|
443
|
+
* Side-calls land here — suggestion generation and, more usefully, compaction's
|
|
444
|
+
* one-shot summariser.
|
|
445
|
+
*
|
|
446
|
+
* It answers the summariser for real (GENC-1567) rather than returning nothing
|
|
447
|
+
* as it used to. That empty string made **Compact** a dead control in the
|
|
448
|
+
* sandbox: the driver treats an empty summary as a failure, leaves history
|
|
449
|
+
* untouched and appends its apology, so the one path worth exercising by hand
|
|
450
|
+
* could not be. The reply is canned, but everything around it — the plan, the
|
|
451
|
+
* splice, the divider, the shrunken context and the gate clearing — is real.
|
|
452
|
+
*
|
|
453
|
+
* Suggestions are told apart by their own prompt convention and still get
|
|
454
|
+
* nothing, since there is nothing useful to invent for those.
|
|
455
|
+
*/
|
|
456
|
+
async prompt(userMessage: string, options?: { systemPrompt?: string }): Promise<string> {
|
|
457
|
+
const isSummary = /compress a conversation transcript/i.test(options?.systemPrompt ?? '');
|
|
458
|
+
if (!isSummary) return '';
|
|
459
|
+
await sleep(SandboxAIProvider.LATENCY_MS);
|
|
460
|
+
// Report the size actually handed over, so a hand-check of the compaction
|
|
461
|
+
// plan has something concrete to compare against.
|
|
462
|
+
const chars = userMessage.length;
|
|
463
|
+
return (
|
|
464
|
+
'Earlier in this conversation the user worked through positions, breaks and ' +
|
|
465
|
+
'reporting with the assistant, and a daily rates break report was scheduled. ' +
|
|
466
|
+
`(Sandbox summary standing in for ~${Math.round(
|
|
467
|
+
chars / APPROX_CHARS_PER_TOKEN,
|
|
468
|
+
).toLocaleString()} tokens ` +
|
|
469
|
+
'of compacted transcript.)'
|
|
470
|
+
);
|
|
314
471
|
}
|
|
315
472
|
}
|