@animalabs/connectome-host 0.6.1 → 0.7.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/CHANGELOG.md +34 -0
- package/package.json +1 -1
- package/src/modules/web-ui-module.ts +22 -0
- package/web/src/App.tsx +1 -0
- package/web/src/Health.tsx +185 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,40 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.1 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **Health call stats are now per-call, not cumulative.** The previous version
|
|
10
|
+
rolled everything into two totals (main / compression), which hid exactly what
|
|
11
|
+
you want to see — how an individual turn behaved. Now one row per call, newest
|
|
12
|
+
first: time, origin, messages, fresh input, cached tokens, cached share, cache
|
|
13
|
+
write, output, breakpoints, duration and verdict, with refusals and errors
|
|
14
|
+
highlighted. Cumulative totals for the session remain in the Usage panel.
|
|
15
|
+
|
|
16
|
+
## Unreleased
|
|
17
|
+
|
|
18
|
+
## 0.7.0 — 2026-07-26
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Health panel: recent LLM call stats, split main vs compression.** Aggregates
|
|
23
|
+
the call ledger the client already receives — fresh input, cache read/write,
|
|
24
|
+
**cached share** (cacheRead ÷ input+cacheRead: what fraction of the prompt was
|
|
25
|
+
reused rather than re-read), prefix-reuse rate, output, average cache
|
|
26
|
+
breakpoints, cost, and errors/refusals — separately for `turn~` (main) and
|
|
27
|
+
`aux~` (compression/summarizer). Includes the last 8 fresh-input values per
|
|
28
|
+
group, so a budget descent can be seen trending down.
|
|
29
|
+
- The `~` is honest: origin is inferred from stream-vs-complete (turns stream,
|
|
30
|
+
compression uses `complete()`), not a definitive tag. Stated in the panel.
|
|
31
|
+
- **Health panel: context composition of the last compile** — head / raw middle /
|
|
32
|
+
summaries by level / tail, with shares and bars. Sourced from `/healthz`, which
|
|
33
|
+
now carries the strategy's in-process render stats: unlike
|
|
34
|
+
`/debug/context/makeup` this costs nothing and makes no `count_tokens` network
|
|
35
|
+
call, so it is safe on the 15s health poll.
|
|
36
|
+
|
|
37
|
+
## Unreleased
|
|
38
|
+
|
|
5
39
|
## 0.6.1 — 2026-07-26
|
|
6
40
|
|
|
7
41
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1118,6 +1118,28 @@ export class WebUiModule implements Module {
|
|
|
1118
1118
|
} catch {
|
|
1119
1119
|
// Health reads never throw.
|
|
1120
1120
|
}
|
|
1121
|
+
// Rendered context COMPOSITION per agent — head / raw middle / summaries
|
|
1122
|
+
// by level / tail, as actually emitted by the last compile.
|
|
1123
|
+
//
|
|
1124
|
+
// Sourced from the strategy's own render stats, which are already
|
|
1125
|
+
// computed in-process: unlike /debug/context/makeup this costs nothing
|
|
1126
|
+
// and makes no count_tokens network call, so it is safe on the 15s
|
|
1127
|
+
// /healthz poll. Answers "how much of what was actually sent" without
|
|
1128
|
+
// recompiling.
|
|
1129
|
+
try {
|
|
1130
|
+
const composition: Record<string, unknown> = {};
|
|
1131
|
+
for (const agent of app.framework.getAllAgents()) {
|
|
1132
|
+
const name = (agent as unknown as { name: string }).name;
|
|
1133
|
+
const cm = agent.getContextManager() as unknown as {
|
|
1134
|
+
getRenderStats?: () => unknown;
|
|
1135
|
+
};
|
|
1136
|
+
const rs = cm.getRenderStats?.();
|
|
1137
|
+
if (rs) composition[name] = rs;
|
|
1138
|
+
}
|
|
1139
|
+
(snapshot as Record<string, unknown>).contextComposition = composition;
|
|
1140
|
+
} catch {
|
|
1141
|
+
// Health reads never throw.
|
|
1142
|
+
}
|
|
1121
1143
|
// Per-agent runtime settings (context budget, tail, transition pace +
|
|
1122
1144
|
// convergence state) — the same numbers `agent_settings get` returns,
|
|
1123
1145
|
// exposed externally so the fleet hub / connectome-doctor can watch
|
package/web/src/App.tsx
CHANGED
package/web/src/Health.tsx
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { For, Show } from 'solid-js';
|
|
20
|
+
import type { CallLedgerRow } from '@conhost/web/protocol';
|
|
20
21
|
|
|
21
22
|
/** One active operator alert, keyed `${agent}:${kind}`. `count` increments on
|
|
22
23
|
* every re-fire of the same key so a repeating klaxon reads as one row. */
|
|
@@ -33,8 +34,22 @@ export interface OpsAlert {
|
|
|
33
34
|
/** Shape of GET /healthz — framework healthSnapshot() plus the host's
|
|
34
35
|
* compressionQuarantine / runtimeSettings extensions. All fields optional
|
|
35
36
|
* and defensively read: health rendering must survive version skew. */
|
|
37
|
+
/** Rendered composition of the last compile — what was actually SENT. */
|
|
38
|
+
export interface ContextComposition {
|
|
39
|
+
head?: { messages: number; tokens: number };
|
|
40
|
+
tail?: { messages: number; tokens: number };
|
|
41
|
+
middleRaw?: { messages: number; tokens: number };
|
|
42
|
+
summaries?: Record<string, { count: number; tokens: number }>;
|
|
43
|
+
total?: { messages: number; tokens: number };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One provider call. Aliased to the wire type so the two cannot drift; only
|
|
47
|
+
* the fields this panel aggregates are read. */
|
|
48
|
+
export type LedgerRow = CallLedgerRow;
|
|
49
|
+
|
|
36
50
|
export interface HealthSnapshot {
|
|
37
51
|
at?: string;
|
|
52
|
+
contextComposition?: Record<string, ContextComposition>;
|
|
38
53
|
uptimeSec?: number;
|
|
39
54
|
gate?: Record<string, unknown> | null;
|
|
40
55
|
pendingRequests?: number;
|
|
@@ -126,8 +141,160 @@ export function OpsAlertStrip(props: {
|
|
|
126
141
|
);
|
|
127
142
|
}
|
|
128
143
|
|
|
144
|
+
|
|
145
|
+
const n0 = (v: number) => v.toLocaleString();
|
|
146
|
+
|
|
147
|
+
function CallStats(props: { rows: LedgerRow[] }) {
|
|
148
|
+
/** Newest first — the interesting call is the one that just happened. */
|
|
149
|
+
const recent = () => [...props.rows].reverse().slice(0, 24);
|
|
150
|
+
|
|
151
|
+
const clock = (ts: string) => {
|
|
152
|
+
const d = new Date(ts);
|
|
153
|
+
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19);
|
|
154
|
+
};
|
|
155
|
+
/** Fraction of the prompt that was reused rather than re-read. */
|
|
156
|
+
const share = (r: LedgerRow) => {
|
|
157
|
+
const sent = r.tokens.input + r.tokens.cacheRead;
|
|
158
|
+
return sent > 0 ? Math.round((100 * r.tokens.cacheRead) / sent) : null;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div>
|
|
163
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
|
|
164
|
+
llm calls — newest first ({props.rows.length} held)
|
|
165
|
+
</div>
|
|
166
|
+
<div class="overflow-x-auto">
|
|
167
|
+
<table class="w-full font-mono text-[10px] whitespace-nowrap">
|
|
168
|
+
<thead>
|
|
169
|
+
<tr class="text-neutral-600 border-b border-neutral-800">
|
|
170
|
+
<th class="text-left pr-2 font-normal">time</th>
|
|
171
|
+
<th class="text-left pr-2 font-normal">origin</th>
|
|
172
|
+
<th class="text-right pr-2 font-normal">msgs</th>
|
|
173
|
+
<th class="text-right pr-2 font-normal">fresh</th>
|
|
174
|
+
<th class="text-right pr-2 font-normal">cached</th>
|
|
175
|
+
<th class="text-right pr-2 font-normal">%c</th>
|
|
176
|
+
<th class="text-right pr-2 font-normal">write</th>
|
|
177
|
+
<th class="text-right pr-2 font-normal">out</th>
|
|
178
|
+
<th class="text-right pr-2 font-normal">bp</th>
|
|
179
|
+
<th class="text-right pr-2 font-normal">ms</th>
|
|
180
|
+
<th class="text-left font-normal">verdict</th>
|
|
181
|
+
</tr>
|
|
182
|
+
</thead>
|
|
183
|
+
<tbody>
|
|
184
|
+
<For each={recent()}>
|
|
185
|
+
{(r) => {
|
|
186
|
+
const main = r.originEstimate === 'turn~';
|
|
187
|
+
return (
|
|
188
|
+
<tr class={`border-b border-neutral-900 ${
|
|
189
|
+
r.error || r.stopReason === 'refusal' ? 'bg-red-950/30' : ''
|
|
190
|
+
}`}>
|
|
191
|
+
<td class="pr-2 text-neutral-500">{clock(r.timestamp)}</td>
|
|
192
|
+
<td class={`pr-2 ${main ? 'text-cyan-400' : 'text-orange-400'}`}>
|
|
193
|
+
{main ? 'turn' : 'compr'}
|
|
194
|
+
</td>
|
|
195
|
+
<td class="pr-2 text-right text-neutral-400">{r.messages}</td>
|
|
196
|
+
<td class="pr-2 text-right text-neutral-100">{n0(r.tokens.input)}</td>
|
|
197
|
+
<td class="pr-2 text-right text-sky-300">{n0(r.tokens.cacheRead)}</td>
|
|
198
|
+
<td class="pr-2 text-right text-neutral-400">
|
|
199
|
+
{share(r) === null ? '—' : `${share(r)}%`}
|
|
200
|
+
</td>
|
|
201
|
+
<td class="pr-2 text-right text-violet-300">
|
|
202
|
+
{r.tokens.cacheWrite ? n0(r.tokens.cacheWrite) : '·'}
|
|
203
|
+
</td>
|
|
204
|
+
<td class="pr-2 text-right text-neutral-400">{n0(r.tokens.output)}</td>
|
|
205
|
+
<td class="pr-2 text-right text-neutral-600">{r.cache.breakpoints ?? '·'}</td>
|
|
206
|
+
<td class="pr-2 text-right text-neutral-600">{n0(r.durationMs)}</td>
|
|
207
|
+
<td class={verdictTone(r.verdict)} title={r.cause}>
|
|
208
|
+
{r.verdict}
|
|
209
|
+
<Show when={r.stopReason === 'refusal'}>
|
|
210
|
+
<span class="text-red-300"> refusal</span>
|
|
211
|
+
</Show>
|
|
212
|
+
<Show when={r.error}>
|
|
213
|
+
<span class="text-red-300"> err</span>
|
|
214
|
+
</Show>
|
|
215
|
+
</td>
|
|
216
|
+
</tr>
|
|
217
|
+
);
|
|
218
|
+
}}
|
|
219
|
+
</For>
|
|
220
|
+
</tbody>
|
|
221
|
+
</table>
|
|
222
|
+
</div>
|
|
223
|
+
<div class="text-[9px] text-neutral-600 mt-1 leading-relaxed">
|
|
224
|
+
<span class="text-cyan-400">turn</span> = main inference,
|
|
225
|
+
<span class="text-orange-400"> compr</span> = compression/summarizer — inferred from
|
|
226
|
+
stream-vs-complete, not a definitive tag (hence <span class="font-mono">~</span> upstream).
|
|
227
|
+
One turn may appear as a turn row plus several compr rows.
|
|
228
|
+
<span class="font-mono"> fresh</span> = tokens billed as new input;
|
|
229
|
+
<span class="font-mono"> cached</span> = read from cache;
|
|
230
|
+
<span class="font-mono"> %c</span> = cached ÷ (fresh+cached);
|
|
231
|
+
<span class="font-mono"> bp</span> = cache breakpoints.
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Verdict colouring: reuse is good, rewrites cost a full re-read. */
|
|
238
|
+
function verdictTone(v: string): string {
|
|
239
|
+
if (v === 'HIT' || v === 'hit+extend') return 'text-emerald-300';
|
|
240
|
+
if (v === 'first-write') return 'text-violet-300';
|
|
241
|
+
if (v === 'uncached') return 'text-neutral-400';
|
|
242
|
+
if (v === 'ERROR' || v === 'empty') return 'text-red-300';
|
|
243
|
+
return 'text-amber-300';
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function CompositionBlock(props: { c: ContextComposition }) {
|
|
247
|
+
const rows = () => {
|
|
248
|
+
const c = props.c;
|
|
249
|
+
const out: Array<[string, number]> = [
|
|
250
|
+
['head (verbatim)', c.head?.tokens ?? 0],
|
|
251
|
+
['middle raw', c.middleRaw?.tokens ?? 0],
|
|
252
|
+
];
|
|
253
|
+
for (const [lvl, v] of Object.entries(c.summaries ?? {})) {
|
|
254
|
+
out.push([`summaries ${lvl.toUpperCase()}`, v?.tokens ?? 0]);
|
|
255
|
+
}
|
|
256
|
+
out.push(['tail (verbatim)', c.tail?.tokens ?? 0]);
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
const total = () => props.c.total?.tokens ?? rows().reduce((s, [, v]) => s + v, 0);
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<div>
|
|
263
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
|
|
264
|
+
context composition (last compile)
|
|
265
|
+
</div>
|
|
266
|
+
<table class="w-full font-mono text-[10px]">
|
|
267
|
+
<tbody>
|
|
268
|
+
<For each={rows()}>
|
|
269
|
+
{([k, v]) => (
|
|
270
|
+
<tr>
|
|
271
|
+
<td class="text-neutral-500 pr-2">{k}</td>
|
|
272
|
+
<td class="text-neutral-200 text-right tabular-nums">{n0(v)}</td>
|
|
273
|
+
<td class="text-neutral-600 text-right pl-2 w-10">
|
|
274
|
+
{total() > 0 ? `${Math.round((100 * v) / total())}%` : ''}
|
|
275
|
+
</td>
|
|
276
|
+
<td class="pl-2 w-1/3">
|
|
277
|
+
<span class="inline-block h-1.5 bg-cyan-800 rounded"
|
|
278
|
+
style={{ width: `${total() > 0 ? Math.round((100 * v) / total()) : 0}%` }} />
|
|
279
|
+
</td>
|
|
280
|
+
</tr>
|
|
281
|
+
)}
|
|
282
|
+
</For>
|
|
283
|
+
<tr class="border-t border-neutral-800">
|
|
284
|
+
<td class="text-neutral-400 pr-2">total rendered</td>
|
|
285
|
+
<td class="text-neutral-100 text-right tabular-nums">{n0(total())}</td>
|
|
286
|
+
<td colSpan={2} />
|
|
287
|
+
</tr>
|
|
288
|
+
</tbody>
|
|
289
|
+
</table>
|
|
290
|
+
</div>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
129
294
|
export function HealthPanel(props: {
|
|
130
295
|
health: HealthSnapshot | null;
|
|
296
|
+
/** Recent provider calls. Already on the client via the call-ledger frame. */
|
|
297
|
+
ledger?: LedgerRow[];
|
|
131
298
|
/** Non-null when the last /healthz fetch failed; '403' means scope-denied. */
|
|
132
299
|
error: string | null;
|
|
133
300
|
onRefresh(): void;
|
|
@@ -135,6 +302,7 @@ export function HealthPanel(props: {
|
|
|
135
302
|
const agents = () => props.health?.agents ?? [];
|
|
136
303
|
const quarantine = (name: string) => props.health?.compressionQuarantine?.[name];
|
|
137
304
|
const settings = (name: string) => props.health?.runtimeSettings?.[name];
|
|
305
|
+
const composition = (name: string) => props.health?.contextComposition?.[name];
|
|
138
306
|
|
|
139
307
|
const statusTone = (status?: string): string => {
|
|
140
308
|
switch (status) {
|
|
@@ -262,6 +430,23 @@ export function HealthPanel(props: {
|
|
|
262
430
|
</div>
|
|
263
431
|
)}
|
|
264
432
|
</Show>
|
|
433
|
+
|
|
434
|
+
{/* What was actually SENT, and how it split. Composition is
|
|
435
|
+
in-process render stats (free); call stats come from the ledger
|
|
436
|
+
the client already holds. */}
|
|
437
|
+
<Show when={composition(a.name)}>
|
|
438
|
+
{(c) => (
|
|
439
|
+
<div class="border-t border-neutral-900 pt-1.5">
|
|
440
|
+
<CompositionBlock c={c()} />
|
|
441
|
+
</div>
|
|
442
|
+
)}
|
|
443
|
+
</Show>
|
|
444
|
+
|
|
445
|
+
<Show when={(props.ledger?.length ?? 0) > 0}>
|
|
446
|
+
<div class="border-t border-neutral-900 pt-1.5">
|
|
447
|
+
<CallStats rows={props.ledger!} />
|
|
448
|
+
</div>
|
|
449
|
+
</Show>
|
|
265
450
|
</section>
|
|
266
451
|
)}</For>
|
|
267
452
|
</Show>
|