@genesislcap/ai-assistant 15.19.6 → 15.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ai-assistant.api.json +605 -72
- package/dist/ai-assistant.d.ts +404 -25
- package/dist/chat-driver.cjs +341 -28
- package/dist/chat-driver.cjs.map +4 -4
- package/dist/chat-driver.mjs +341 -28
- package/dist/chat-driver.mjs.map +4 -4
- package/dist/custom-elements.json +630 -20
- package/dist/dts/components/ai-driver/ai-driver.d.ts +33 -7
- package/dist/dts/components/ai-driver/ai-driver.d.ts.map +1 -1
- package/dist/dts/components/chat-driver/chat-driver.d.ts +63 -2
- package/dist/dts/components/chat-driver/chat-driver.d.ts.map +1 -1
- package/dist/dts/components/orchestrating-driver/orchestrating-driver.d.ts +9 -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/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 +179 -34
- package/dist/esm/components/orchestrating-driver/orchestrating-driver.js +12 -4
- package/dist/esm/main/main.js +391 -21
- 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/utils/condense-history.js +1 -5
- 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
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
|
}
|
package/sandbox/sandbox.css
CHANGED
|
@@ -120,7 +120,11 @@ body {
|
|
|
120
120
|
.viewport {
|
|
121
121
|
overflow: hidden;
|
|
122
122
|
resize: both;
|
|
123
|
-
|
|
123
|
+
|
|
124
|
+
/* 400px is the narrowest supported composer — the blocked-state copy is sized
|
|
125
|
+
against it — so the drag handle stops there rather than letting the stage
|
|
126
|
+
reach widths the component is not built for. */
|
|
127
|
+
min-width: 400px;
|
|
124
128
|
min-height: 240px;
|
|
125
129
|
border: 1px solid var(--sb-line);
|
|
126
130
|
border-radius: 6px;
|
|
@@ -210,6 +214,55 @@ rapid-design-system-provider {
|
|
|
210
214
|
padding-bottom: 8px;
|
|
211
215
|
}
|
|
212
216
|
|
|
217
|
+
/* The one control a group is really about — lifted out of the list so it reads as
|
|
218
|
+
the entry point rather than the first of eight equal rows. Deliberately quiet:
|
|
219
|
+
a tinted panel and an accent edge, not a colour that competes with the stage. */
|
|
220
|
+
.row-featured {
|
|
221
|
+
/* Content stays on the same left edge as every other row: the shared .row pads
|
|
222
|
+
14px, and the 2px accent border here eats 2 of them, so the padding makes up
|
|
223
|
+
the remaining 12. Getting this wrong is immediately visible as a highlighted
|
|
224
|
+
block whose label is indented differently from its neighbours. */
|
|
225
|
+
margin: 0 0 6px;
|
|
226
|
+
padding: 8px 14px 8px 12px;
|
|
227
|
+
border-left: 2px solid var(--sb-accent);
|
|
228
|
+
background: color-mix(in srgb, var(--sb-accent) 8%, transparent);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.row-featured .label {
|
|
232
|
+
color: var(--sb-text);
|
|
233
|
+
font-weight: 600;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* Stacked, not inline. The shared row gives a select a fixed 140px, which clips
|
|
237
|
+
these option labels to "Blocked — compacting will…" — losing the clause that
|
|
238
|
+
distinguishes the two blocked states, which is the whole reason they are
|
|
239
|
+
separate entries. Full width costs nothing here and keeps them readable. */
|
|
240
|
+
.row-featured {
|
|
241
|
+
flex-direction: column;
|
|
242
|
+
align-items: stretch;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.row-featured select {
|
|
246
|
+
flex: 1 1 auto;
|
|
247
|
+
width: 100%;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/* A derived figure read back off the component, not a control. Monospace and
|
|
251
|
+
wrapping, because these are numbers meant to be compared by eye and some of
|
|
252
|
+
them (the compact hint) are a whole sentence. */
|
|
253
|
+
.row-readout {
|
|
254
|
+
display: flex;
|
|
255
|
+
flex-direction: column;
|
|
256
|
+
gap: 2px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.readout-value {
|
|
260
|
+
color: var(--sb-text);
|
|
261
|
+
font-family: ui-monospace, sfmono-regular, menlo, monospace;
|
|
262
|
+
font-size: 11px;
|
|
263
|
+
overflow-wrap: anywhere;
|
|
264
|
+
}
|
|
265
|
+
|
|
213
266
|
.action {
|
|
214
267
|
width: 100%;
|
|
215
268
|
padding: 5px 10px;
|