@animalabs/connectome-host 0.3.5 → 0.3.8
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/.env.example +6 -0
- package/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/README.md +5 -0
- package/bun.lock +14 -6
- package/docs/AGENT-ONBOARDING.md +6 -1
- package/package.json +6 -4
- package/scripts/import-codex-rollout.ts +288 -0
- package/src/call-ledger.ts +371 -0
- package/src/call-pricing.ts +119 -0
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +130 -105
- package/src/logging-adapter.ts +105 -12
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/fleet-module.ts +17 -7
- package/src/modules/mcpl-admin-module.ts +6 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/time-module.ts +15 -9
- package/src/modules/web-ui-module.ts +88 -10
- package/src/modules/web-ui-observers.ts +35 -9
- package/src/recipe.ts +133 -6
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +15 -9
- package/src/web/protocol.ts +89 -0
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/call-ledger.test.ts +91 -0
- package/test/call-pricing.test.ts +69 -0
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/frontdesk-strategy.test.ts +10 -0
- package/test/import-codex-rollout.test.ts +36 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/logging-adapter.test.ts +58 -1
- package/test/recipe-cache-ttl.test.ts +7 -3
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-provider.test.ts +36 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/recipe-timezone.test.ts +20 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/time-module.test.ts +13 -0
- package/test/web-ui-observers.test.ts +70 -2
- package/web/package-lock.json +425 -302
- package/web/src/App.tsx +9 -0
- package/web/src/ObserverGate.tsx +21 -11
- package/web/src/Usage.tsx +116 -2
- package/web/src/wire.ts +5 -2
- package/web/bun.lock +0 -357
|
@@ -41,6 +41,7 @@ import { createHash, timingSafeEqual } from 'node:crypto';
|
|
|
41
41
|
import type { Recipe } from '../recipe.js';
|
|
42
42
|
import type { SessionManager } from '../session-manager.js';
|
|
43
43
|
import type { BranchState } from '../commands.js';
|
|
44
|
+
import type { CallLedger } from '../call-ledger.js';
|
|
44
45
|
import { handleCommand } from '../commands.js';
|
|
45
46
|
import { AgentTreeReducer, type AgentTreeSnapshot } from '../state/agent-tree-reducer.js';
|
|
46
47
|
import { FleetTreeAggregator } from '../state/fleet-tree-aggregator.js';
|
|
@@ -54,6 +55,7 @@ import {
|
|
|
54
55
|
type WelcomeMessageEntry,
|
|
55
56
|
type RequestHistoryMessage,
|
|
56
57
|
type TokenUsage,
|
|
58
|
+
type CallLedgerSnapshot,
|
|
57
59
|
type McplListMessage,
|
|
58
60
|
type LessonsListMessage,
|
|
59
61
|
} from '../web/protocol.js';
|
|
@@ -128,6 +130,8 @@ export interface WebUiModuleConfig {
|
|
|
128
130
|
* scope mask. With no grants the webui behaves exactly as before.
|
|
129
131
|
*/
|
|
130
132
|
observersPath?: string;
|
|
133
|
+
/** Content-free recent provider-call ledger for spend/cache diagnostics. */
|
|
134
|
+
callLedger?: CallLedger;
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
/** Data stashed on the Bun WS upgrade. */
|
|
@@ -389,6 +393,9 @@ interface SharedServerState {
|
|
|
389
393
|
* every usage:updated event so the welcome and live UsageMessage frames
|
|
390
394
|
* carry consistent values. */
|
|
391
395
|
latestPerAgentCost: import('../web/protocol.js').PerAgentCost[];
|
|
396
|
+
/** Recent per-call cache diagnostics, updated directly by the adapter. */
|
|
397
|
+
latestCallLedger?: CallLedgerSnapshot;
|
|
398
|
+
callLedgerDetacher: (() => void) | null;
|
|
392
399
|
/** Currently-bound app, refreshed on every setApp() call. WS handlers read
|
|
393
400
|
* from here so the singleton always points at the live framework regardless
|
|
394
401
|
* of which WebUiModule instance is "active". */
|
|
@@ -464,6 +471,8 @@ export class WebUiModule implements Module {
|
|
|
464
471
|
parentUsage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
465
472
|
childUsage: new Map(),
|
|
466
473
|
latestPerAgentCost: [],
|
|
474
|
+
latestCallLedger: this.config.callLedger?.snapshot(),
|
|
475
|
+
callLedgerDetacher: null,
|
|
467
476
|
pendingFleetRequests: new Map(),
|
|
468
477
|
childRecipeCache: new Map(),
|
|
469
478
|
app: null,
|
|
@@ -495,6 +504,19 @@ export class WebUiModule implements Module {
|
|
|
495
504
|
}
|
|
496
505
|
sharedServer = state;
|
|
497
506
|
|
|
507
|
+
// Provider calls include auxiliary compression requests that never emit a
|
|
508
|
+
// framework usage trace, so subscribe at the adapter ledger itself. This
|
|
509
|
+
// keeps the panel live for both conversational and memory-maintenance
|
|
510
|
+
// calls without polling the JSONL log.
|
|
511
|
+
state.callLedgerDetacher = this.config.callLedger?.onUpdate((ledger) => {
|
|
512
|
+
state.latestCallLedger = ledger;
|
|
513
|
+
const msg: WebUiServerMessage = { type: 'call-ledger', ledger };
|
|
514
|
+
for (const client of state.clients.values()) {
|
|
515
|
+
if (!client.welcomed) continue;
|
|
516
|
+
if (client.scopes === null || client.scopes.has('health')) this.send(client, msg);
|
|
517
|
+
}
|
|
518
|
+
}) ?? null;
|
|
519
|
+
|
|
498
520
|
console.log(`[webui] listening on http://${host}:${boundPort}`);
|
|
499
521
|
}
|
|
500
522
|
|
|
@@ -961,8 +983,14 @@ export class WebUiModule implements Module {
|
|
|
961
983
|
// `new WebSocket(...)` the way they do on fetch, so without an
|
|
962
984
|
// explicit check, any tab the operator opens could connect here.
|
|
963
985
|
if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
|
|
964
|
-
|
|
965
|
-
//
|
|
986
|
+
// Full authority = basic auth OR a full session cookie (minted by
|
|
987
|
+
// /auth/basic). The cookie path exists because browsers reliably send
|
|
988
|
+
// cookies on WS upgrades but Chrome does NOT send cached basic-auth
|
|
989
|
+
// credentials — without it, password sign-in bounced back to the
|
|
990
|
+
// observer gate in a loop.
|
|
991
|
+
const wsSession = sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
|
|
992
|
+
const basicOk = this.checkAuth(req) || (wsSession?.full ?? false);
|
|
993
|
+
// Without full auth the upgrade is allowed only when observer grants
|
|
966
994
|
// exist — and then NOTHING is sent until a signed observer-hello
|
|
967
995
|
// verifies (in-band auth; see onWsOpen/onWsMessage).
|
|
968
996
|
if (!basicOk && !observersActive) return this.unauthorized();
|
|
@@ -980,19 +1008,29 @@ export class WebUiModule implements Module {
|
|
|
980
1008
|
// when observers are active the static app shell is public — it carries
|
|
981
1009
|
// no data, and key-only devices must be able to load the SPA to
|
|
982
1010
|
// authenticate at all.
|
|
983
|
-
const
|
|
984
|
-
const
|
|
985
|
-
|
|
986
|
-
: sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
|
|
1011
|
+
const session = sharedServer!.observerSessions.lookup(sessionTokenFromRequest(req));
|
|
1012
|
+
const basicOk = this.checkAuth(req) || (session?.full ?? false);
|
|
1013
|
+
const sessionScopes = basicOk ? null : session?.scopes ?? null;
|
|
987
1014
|
const httpAllowed = (scope: ObserverScope): boolean =>
|
|
988
1015
|
basicOk || (sessionScopes?.has(scope) ?? false);
|
|
989
1016
|
// /auth/basic: deliberate basic-auth challenge point. fetch() never
|
|
990
1017
|
// triggers the browser's native credential prompt, but a top-level
|
|
991
1018
|
// navigation here does — the SPA's "sign in with password" fallback for
|
|
992
|
-
// devices without an observer grant.
|
|
993
|
-
// the
|
|
1019
|
+
// devices without an observer grant. On success it mints a FULL session
|
|
1020
|
+
// cookie: the subsequent WS upgrade authenticates via that cookie
|
|
1021
|
+
// (browsers send cookies on upgrades; Chrome does not send cached basic
|
|
1022
|
+
// credentials, which used to loop users back to the gate).
|
|
994
1023
|
if (url.pathname === '/auth/basic') {
|
|
995
|
-
if (basicOk)
|
|
1024
|
+
if (basicOk) {
|
|
1025
|
+
const token = sharedServer!.observerSessions.mint(new Set(), { full: true });
|
|
1026
|
+
return new Response(null, {
|
|
1027
|
+
status: 302,
|
|
1028
|
+
headers: {
|
|
1029
|
+
location: '/',
|
|
1030
|
+
'set-cookie': `fkm_obs=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${12 * 3600}`,
|
|
1031
|
+
},
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
996
1034
|
return this.unauthorized();
|
|
997
1035
|
}
|
|
998
1036
|
const isStatic = !url.pathname.startsWith('/debug/')
|
|
@@ -1055,7 +1093,44 @@ export class WebUiModule implements Module {
|
|
|
1055
1093
|
if (typeof fw.healthSnapshot !== 'function') {
|
|
1056
1094
|
return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
|
|
1057
1095
|
}
|
|
1058
|
-
|
|
1096
|
+
const snapshot = fw.healthSnapshot();
|
|
1097
|
+
// Compression quarantine is a guaranteed-eventual-outage state (raw
|
|
1098
|
+
// spans accumulate until the picker cannot fit the window). Surface it
|
|
1099
|
+
// here so the fleet hub and connectome-doctor can alarm on it — it
|
|
1100
|
+
// must never be observable only in agent.log.
|
|
1101
|
+
try {
|
|
1102
|
+
const quarantine: Record<string, unknown> = {};
|
|
1103
|
+
for (const agent of app.framework.getAllAgents()) {
|
|
1104
|
+
const strategy = (agent.getContextManager() as unknown as {
|
|
1105
|
+
getStrategy?: () => { getCompressionQuarantineStatus?: () => unknown };
|
|
1106
|
+
}).getStrategy?.();
|
|
1107
|
+
const status = strategy?.getCompressionQuarantineStatus?.();
|
|
1108
|
+
if (status) quarantine[(agent as unknown as { name: string }).name] = status;
|
|
1109
|
+
}
|
|
1110
|
+
(snapshot as Record<string, unknown>).compressionQuarantine = quarantine;
|
|
1111
|
+
} catch {
|
|
1112
|
+
// Health reads never throw.
|
|
1113
|
+
}
|
|
1114
|
+
// Per-agent runtime settings (context budget, tail, transition pace +
|
|
1115
|
+
// convergence state) — the same numbers `agent_settings get` returns,
|
|
1116
|
+
// exposed externally so the fleet hub / connectome-doctor can watch
|
|
1117
|
+
// budget convergence without an agent turn.
|
|
1118
|
+
try {
|
|
1119
|
+
const fw2 = app.framework as unknown as {
|
|
1120
|
+
getAgentRuntimeSettings?: (name: string) => unknown;
|
|
1121
|
+
};
|
|
1122
|
+
if (typeof fw2.getAgentRuntimeSettings === 'function') {
|
|
1123
|
+
const settings: Record<string, unknown> = {};
|
|
1124
|
+
for (const agent of app.framework.getAllAgents()) {
|
|
1125
|
+
const name = (agent as unknown as { name: string }).name;
|
|
1126
|
+
settings[name] = fw2.getAgentRuntimeSettings(name);
|
|
1127
|
+
}
|
|
1128
|
+
(snapshot as Record<string, unknown>).runtimeSettings = settings;
|
|
1129
|
+
}
|
|
1130
|
+
} catch {
|
|
1131
|
+
// Health reads never throw.
|
|
1132
|
+
}
|
|
1133
|
+
return Response.json(snapshot);
|
|
1059
1134
|
}
|
|
1060
1135
|
|
|
1061
1136
|
// Workspace file passthrough: /files/<mount>/<path...>
|
|
@@ -2486,6 +2561,9 @@ export class WebUiModule implements Module {
|
|
|
2486
2561
|
...(sharedServer!.latestPerAgentCost.length > 0
|
|
2487
2562
|
? { perAgentCost: sharedServer!.latestPerAgentCost }
|
|
2488
2563
|
: {}),
|
|
2564
|
+
...(sharedServer!.latestCallLedger
|
|
2565
|
+
? { callLedger: sharedServer!.latestCallLedger }
|
|
2566
|
+
: {}),
|
|
2489
2567
|
};
|
|
2490
2568
|
}
|
|
2491
2569
|
|
|
@@ -210,23 +210,36 @@ export class ObserverRegistry {
|
|
|
210
210
|
|
|
211
211
|
const SESSION_TTL_MS = 12 * 60 * 60_000;
|
|
212
212
|
|
|
213
|
+
export interface ObserverSession {
|
|
214
|
+
scopes: Set<ObserverScope>;
|
|
215
|
+
/**
|
|
216
|
+
* Full sessions carry OPERATOR authority (equivalent to basic auth): the
|
|
217
|
+
* WS upgrade treats the connection as a full client, not a read-only
|
|
218
|
+
* observer. Minted only by /auth/basic after a successful password
|
|
219
|
+
* challenge. Exists because browsers reliably attach cookies to WebSocket
|
|
220
|
+
* upgrades but (Chrome, notably) do NOT attach cached basic-auth
|
|
221
|
+
* credentials — without this, password sign-in loops back to the gate.
|
|
222
|
+
*/
|
|
223
|
+
full: boolean;
|
|
224
|
+
}
|
|
225
|
+
|
|
213
226
|
export class ObserverSessions {
|
|
214
|
-
private sessions = new Map<string,
|
|
227
|
+
private sessions = new Map<string, ObserverSession & { expiresAt: number }>();
|
|
215
228
|
|
|
216
|
-
mint(scopes: Set<ObserverScope
|
|
217
|
-
// Opportunistic sweep — the map stays tiny (one entry per
|
|
229
|
+
mint(scopes: Set<ObserverScope>, opts?: { full?: boolean }): string {
|
|
230
|
+
// Opportunistic sweep — the map stays tiny (one entry per auth event).
|
|
218
231
|
const now = Date.now();
|
|
219
232
|
for (const [t, s] of this.sessions) if (s.expiresAt < now) this.sessions.delete(t);
|
|
220
233
|
const token = randomBytes(32).toString('hex');
|
|
221
|
-
this.sessions.set(token, { scopes, expiresAt: now + SESSION_TTL_MS });
|
|
234
|
+
this.sessions.set(token, { scopes, full: opts?.full ?? false, expiresAt: now + SESSION_TTL_MS });
|
|
222
235
|
return token;
|
|
223
236
|
}
|
|
224
237
|
|
|
225
|
-
lookup(token: string | null | undefined):
|
|
238
|
+
lookup(token: string | null | undefined): ObserverSession | null {
|
|
226
239
|
if (!token) return null;
|
|
227
240
|
const s = this.sessions.get(token);
|
|
228
241
|
if (!s || s.expiresAt < Date.now()) return null;
|
|
229
|
-
return s.scopes;
|
|
242
|
+
return { scopes: s.scopes, full: s.full };
|
|
230
243
|
}
|
|
231
244
|
}
|
|
232
245
|
|
|
@@ -283,12 +296,25 @@ export function filterEntryForScopes(
|
|
|
283
296
|
/** Project a welcome through an observer's scope mask. Without 'messages'
|
|
284
297
|
* the entire conversation payload (entries + agent trees, which carry
|
|
285
298
|
* streaming buffers) is emptied — a health/ops observer like the fleet hub
|
|
286
|
-
* gets structure and usage, never content.
|
|
299
|
+
* gets structure and usage, never content. Without 'health' the telemetry
|
|
300
|
+
* fields (usage / perAgentCost / callLedger) are masked too: their live
|
|
301
|
+
* frames (`usage`, `call-ledger`) are gated on 'health', and the welcome
|
|
302
|
+
* must refuse exactly what the stream refuses — otherwise a messages-only
|
|
303
|
+
* observer reads model IDs, per-call costs, and raw provider errors on
|
|
304
|
+
* connect that it is denied a second later. */
|
|
287
305
|
export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>): WelcomeMessage {
|
|
288
306
|
const emptyTree = { asOfTs: Date.now(), nodes: [], callIdIndex: {} };
|
|
307
|
+
const scoped: WelcomeMessage = { ...welcome };
|
|
308
|
+
if (!scopes.has('health')) {
|
|
309
|
+
delete scoped.callLedger;
|
|
310
|
+
delete scoped.perAgentCost;
|
|
311
|
+
// `usage` is required by the wire type, so it is zeroed rather than
|
|
312
|
+
// dropped (matches the emptied-not-deleted style of the fields below).
|
|
313
|
+
scoped.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
314
|
+
}
|
|
289
315
|
if (!scopes.has('messages')) {
|
|
290
316
|
return {
|
|
291
|
-
...
|
|
317
|
+
...scoped,
|
|
292
318
|
messages: [],
|
|
293
319
|
history: { startIndex: welcome.history.totalCount, totalCount: welcome.history.totalCount },
|
|
294
320
|
localTree: emptyTree,
|
|
@@ -296,7 +322,7 @@ export function scopeWelcome(welcome: WelcomeMessage, scopes: Set<ObserverScope>
|
|
|
296
322
|
};
|
|
297
323
|
}
|
|
298
324
|
return {
|
|
299
|
-
...
|
|
325
|
+
...scoped,
|
|
300
326
|
messages: welcome.messages
|
|
301
327
|
.map((e) => filterEntryForScopes(e, scopes))
|
|
302
328
|
.filter((e): e is WelcomeMessageEntry => e !== null),
|
package/src/recipe.ts
CHANGED
|
@@ -35,6 +35,13 @@ export interface RecipeStrategy {
|
|
|
35
35
|
// for what the recipe loader accepts under `agent.strategy`.
|
|
36
36
|
enforceBudget?: boolean;
|
|
37
37
|
maxSpeculativeL1s?: number;
|
|
38
|
+
/** Number of coverage-equivalent recall curves to try after the canonical
|
|
39
|
+
* autobiographical compression request is explicitly refused. Zero disables
|
|
40
|
+
* fallback while retaining the canonical attempt. */
|
|
41
|
+
compressionRefusalCurveFallbacks?: number;
|
|
42
|
+
/** Complete provider-request admission ceiling for compression fallbacks,
|
|
43
|
+
* including the output reserve. */
|
|
44
|
+
compressionContextBudgetTokens?: number;
|
|
38
45
|
positionedRecallPairs?: boolean;
|
|
39
46
|
recallHeaderTemplate?: string;
|
|
40
47
|
targetChunkTokens?: number;
|
|
@@ -75,6 +82,10 @@ export interface RecipeStrategy {
|
|
|
75
82
|
export interface RecipeAgent {
|
|
76
83
|
name?: string;
|
|
77
84
|
model?: string;
|
|
85
|
+
/** IANA zone used when rendering wall-clock times to the agent. */
|
|
86
|
+
timezone?: string;
|
|
87
|
+
/** Provider transport. Omitted preserves the historical Anthropic default. */
|
|
88
|
+
provider?: 'anthropic' | 'openai-responses';
|
|
78
89
|
systemPrompt: string;
|
|
79
90
|
maxTokens?: number;
|
|
80
91
|
/**
|
|
@@ -86,10 +97,14 @@ export interface RecipeAgent {
|
|
|
86
97
|
/** Per-agent context compile budget (input tokens). When unset, the
|
|
87
98
|
* ContextManager default (100k) applies. Raise for large-context models. */
|
|
88
99
|
contextBudgetTokens?: number;
|
|
89
|
-
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider.
|
|
90
|
-
*
|
|
91
|
-
* the full context to cache after every gap. Unset: provider default (5m). */
|
|
100
|
+
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
|
|
101
|
+
* '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
|
|
92
102
|
cacheTtl?: '5m' | '1h';
|
|
103
|
+
/**
|
|
104
|
+
* Same-round routing policy for ordinary text emitted beside think().
|
|
105
|
+
* Omitted preserves the compatibility carry-forward in Agent Framework.
|
|
106
|
+
*/
|
|
107
|
+
sameRoundThinkTextPolicy?: 'public' | 'private';
|
|
93
108
|
strategy?: RecipeStrategy;
|
|
94
109
|
/**
|
|
95
110
|
* Native extended thinking. When `enabled: true`, the agent's API requests
|
|
@@ -100,6 +115,14 @@ export interface RecipeAgent {
|
|
|
100
115
|
enabled: boolean;
|
|
101
116
|
budgetTokens?: number;
|
|
102
117
|
};
|
|
118
|
+
/** Stateless OpenAI Responses settings. Only used with
|
|
119
|
+
* `provider: "openai-responses"`. */
|
|
120
|
+
responses?: {
|
|
121
|
+
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
122
|
+
reasoningContext?: 'current_turn' | 'all_turns';
|
|
123
|
+
serviceTier?: string;
|
|
124
|
+
compactThreshold?: number;
|
|
125
|
+
};
|
|
103
126
|
/**
|
|
104
127
|
* Content-refusal handling. When `autoRewind` is on, a `stop_reason: refusal`
|
|
105
128
|
* turn triggers an automatic rewind of the triggering turn + retry (keeping
|
|
@@ -109,6 +132,11 @@ export interface RecipeAgent {
|
|
|
109
132
|
autoRewind?: boolean;
|
|
110
133
|
maxRewinds?: number;
|
|
111
134
|
announceHumanTurns?: boolean;
|
|
135
|
+
primarySummaryFallback?: {
|
|
136
|
+
enabled?: boolean;
|
|
137
|
+
maxNewSummaries?: number;
|
|
138
|
+
requestBudgetTokens?: number;
|
|
139
|
+
};
|
|
112
140
|
};
|
|
113
141
|
}
|
|
114
142
|
|
|
@@ -144,9 +172,8 @@ export interface RecipeMcpServer {
|
|
|
144
172
|
/** Ceiling for the exponential reconnect backoff. Default: 5 minutes. */
|
|
145
173
|
reconnectMaxIntervalMs?: number;
|
|
146
174
|
/**
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* a string[] is an allow-list of channel ids.
|
|
175
|
+
* @deprecated One-time migration input for legacy recipes. Runtime channel
|
|
176
|
+
* desired state is Chronicle-backed and changed with channel_open/close.
|
|
150
177
|
*/
|
|
151
178
|
channelSubscription?: 'auto' | 'manual' | string[];
|
|
152
179
|
/**
|
|
@@ -500,6 +527,7 @@ export const DEFAULT_RECIPE: Recipe = {
|
|
|
500
527
|
description: 'General-purpose assistant with tool access',
|
|
501
528
|
agent: {
|
|
502
529
|
name: 'agent',
|
|
530
|
+
cacheTtl: '1h',
|
|
503
531
|
systemPrompt: [
|
|
504
532
|
'You are a helpful assistant. You have access to tools provided by connected MCP servers.',
|
|
505
533
|
'Use them to help the user with their tasks.',
|
|
@@ -678,6 +706,42 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
678
706
|
throw new Error('Recipe agent must have a "systemPrompt" string');
|
|
679
707
|
}
|
|
680
708
|
|
|
709
|
+
if (agent.provider !== undefined && agent.provider !== 'anthropic' && agent.provider !== 'openai-responses') {
|
|
710
|
+
throw new Error(`Recipe agent.provider must be 'anthropic' or 'openai-responses', got ${JSON.stringify(agent.provider)}.`);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (agent.timezone !== undefined) {
|
|
714
|
+
if (typeof agent.timezone !== 'string' || !agent.timezone.trim()) {
|
|
715
|
+
throw new Error('Recipe agent.timezone must be a non-empty IANA time zone string.');
|
|
716
|
+
}
|
|
717
|
+
try {
|
|
718
|
+
new Intl.DateTimeFormat('en-US', { timeZone: agent.timezone }).format(new Date(0));
|
|
719
|
+
} catch {
|
|
720
|
+
throw new Error(`Recipe agent.timezone is not a valid IANA time zone: ${JSON.stringify(agent.timezone)}.`);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
if (agent.responses !== undefined) {
|
|
725
|
+
if (!agent.responses || typeof agent.responses !== 'object' || Array.isArray(agent.responses)) {
|
|
726
|
+
throw new Error('Recipe agent.responses must be an object.');
|
|
727
|
+
}
|
|
728
|
+
const responses = agent.responses as Record<string, unknown>;
|
|
729
|
+
const efforts = ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
730
|
+
if (responses.reasoningEffort !== undefined && !efforts.includes(String(responses.reasoningEffort))) {
|
|
731
|
+
throw new Error(`Invalid agent.responses.reasoningEffort ${JSON.stringify(responses.reasoningEffort)}.`);
|
|
732
|
+
}
|
|
733
|
+
if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
|
|
734
|
+
throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
|
|
735
|
+
}
|
|
736
|
+
if (responses.serviceTier !== undefined && (typeof responses.serviceTier !== 'string' || !responses.serviceTier.trim())) {
|
|
737
|
+
throw new Error('Recipe agent.responses.serviceTier must be a non-empty string.');
|
|
738
|
+
}
|
|
739
|
+
if (responses.compactThreshold !== undefined &&
|
|
740
|
+
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
741
|
+
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
681
745
|
if (agent.maxStreamTokens !== undefined && (typeof agent.maxStreamTokens !== 'number' || agent.maxStreamTokens <= 0)) {
|
|
682
746
|
throw new Error('Recipe agent.maxStreamTokens must be a positive number.');
|
|
683
747
|
}
|
|
@@ -687,6 +751,17 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
687
751
|
if (agent.cacheTtl !== undefined && agent.cacheTtl !== '5m' && agent.cacheTtl !== '1h') {
|
|
688
752
|
throw new Error(`Recipe agent.cacheTtl must be '5m' or '1h', got ${JSON.stringify(agent.cacheTtl)}.`);
|
|
689
753
|
}
|
|
754
|
+
agent.cacheTtl ??= '1h';
|
|
755
|
+
|
|
756
|
+
if (
|
|
757
|
+
agent.sameRoundThinkTextPolicy !== undefined &&
|
|
758
|
+
agent.sameRoundThinkTextPolicy !== 'public' &&
|
|
759
|
+
agent.sameRoundThinkTextPolicy !== 'private'
|
|
760
|
+
) {
|
|
761
|
+
throw new Error(
|
|
762
|
+
`Recipe agent.sameRoundThinkTextPolicy must be 'public' or 'private', got ${JSON.stringify(agent.sameRoundThinkTextPolicy)}.`,
|
|
763
|
+
);
|
|
764
|
+
}
|
|
690
765
|
|
|
691
766
|
// Validate agent.thinking if present. Catches typos and constraint
|
|
692
767
|
// violations (notably max_tokens > budget_tokens) at recipe-load time
|
|
@@ -725,6 +800,58 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
725
800
|
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
|
|
726
801
|
);
|
|
727
802
|
}
|
|
803
|
+
if (
|
|
804
|
+
strategy.compressionRefusalCurveFallbacks !== undefined
|
|
805
|
+
&& (
|
|
806
|
+
typeof strategy.compressionRefusalCurveFallbacks !== 'number'
|
|
807
|
+
|| !Number.isSafeInteger(strategy.compressionRefusalCurveFallbacks)
|
|
808
|
+
|| strategy.compressionRefusalCurveFallbacks < 0
|
|
809
|
+
)
|
|
810
|
+
) {
|
|
811
|
+
throw new Error('Recipe agent.strategy.compressionRefusalCurveFallbacks must be a non-negative safe integer.');
|
|
812
|
+
}
|
|
813
|
+
if (
|
|
814
|
+
strategy.compressionContextBudgetTokens !== undefined
|
|
815
|
+
&& (
|
|
816
|
+
typeof strategy.compressionContextBudgetTokens !== 'number'
|
|
817
|
+
|| !Number.isSafeInteger(strategy.compressionContextBudgetTokens)
|
|
818
|
+
|| strategy.compressionContextBudgetTokens <= 0
|
|
819
|
+
)
|
|
820
|
+
) {
|
|
821
|
+
throw new Error('Recipe agent.strategy.compressionContextBudgetTokens must be a positive safe integer.');
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const refusalHandling = agent.refusalHandling as Record<string, unknown> | undefined;
|
|
826
|
+
const primarySummaryFallback = refusalHandling?.primarySummaryFallback;
|
|
827
|
+
if (primarySummaryFallback !== undefined) {
|
|
828
|
+
if (!primarySummaryFallback || typeof primarySummaryFallback !== 'object' || Array.isArray(primarySummaryFallback)) {
|
|
829
|
+
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback must be an object.');
|
|
830
|
+
}
|
|
831
|
+
const fallback = primarySummaryFallback as Record<string, unknown>;
|
|
832
|
+
if (fallback.enabled !== undefined && typeof fallback.enabled !== 'boolean') {
|
|
833
|
+
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.enabled must be a boolean.');
|
|
834
|
+
}
|
|
835
|
+
if (
|
|
836
|
+
fallback.maxNewSummaries !== undefined &&
|
|
837
|
+
(
|
|
838
|
+
typeof fallback.maxNewSummaries !== 'number' ||
|
|
839
|
+
!Number.isSafeInteger(fallback.maxNewSummaries) ||
|
|
840
|
+
fallback.maxNewSummaries < 0
|
|
841
|
+
)
|
|
842
|
+
) {
|
|
843
|
+
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.maxNewSummaries must be a non-negative safe integer.');
|
|
844
|
+
}
|
|
845
|
+
if (
|
|
846
|
+
fallback.requestBudgetTokens !== undefined &&
|
|
847
|
+
(
|
|
848
|
+
typeof fallback.requestBudgetTokens !== 'number' ||
|
|
849
|
+
!Number.isSafeInteger(fallback.requestBudgetTokens) ||
|
|
850
|
+
fallback.requestBudgetTokens <= 0
|
|
851
|
+
)
|
|
852
|
+
) {
|
|
853
|
+
throw new Error('Recipe agent.refusalHandling.primarySummaryFallback.requestBudgetTokens must be a positive safe integer.');
|
|
854
|
+
}
|
|
728
855
|
}
|
|
729
856
|
|
|
730
857
|
// Validate mcpServers entries if present
|
|
@@ -202,10 +202,13 @@ const EVENT_HANDLERS: Record<string, EventHandler> = {
|
|
|
202
202
|
node.lastEventAt = ts;
|
|
203
203
|
},
|
|
204
204
|
|
|
205
|
-
// inference:exhausted =
|
|
205
|
+
// inference:exhausted = retries exhausted / genuine stream cancel / reboot-
|
|
206
206
|
// induced. Postmortem 2026-05-28 P1 #3: not strictly a fault — flip both
|
|
207
207
|
// fields to the dedicated 'cancelled' terminal state so the renderer can
|
|
208
|
-
// distinguish "stopped on purpose" from "errored out".
|
|
208
|
+
// distinguish "stopped on purpose" from "errored out". (AF PR #55 / ≥ 0.6.5
|
|
209
|
+
// stopped emitting this for endTurn and context-budget restarts — those are
|
|
210
|
+
// no longer terminal at all — but user cancels and reboots still land here,
|
|
211
|
+
// and older AF still sends the endTurn-trailing one.)
|
|
209
212
|
'inference:exhausted': (r, e, ts) => {
|
|
210
213
|
if (!e.agentName) return;
|
|
211
214
|
const node = r._ensureNode(e.agentName);
|
|
@@ -236,9 +239,20 @@ const EVENT_HANDLERS: Record<string, EventHandler> = {
|
|
|
236
239
|
r._ensureNode(e.agentName).lastEventAt = ts;
|
|
237
240
|
},
|
|
238
241
|
|
|
242
|
+
// endTurn tool result: the agent deliberately ended its turn — a successful
|
|
243
|
+
// terminal, same family as inference:completed. This event must be terminal
|
|
244
|
+
// in its own right: agent-framework PR #55 (≥ 0.6.5) settles endTurn from
|
|
245
|
+
// the state machine and no longer follows it with the spurious
|
|
246
|
+
// inference:exhausted that used to flip these nodes to 'cancelled'. On
|
|
247
|
+
// older AF the trailing exhausted still arrives and (harmlessly)
|
|
248
|
+
// re-terminates the node as cancelled one event later.
|
|
239
249
|
'inference:turn_ended': (r, e, ts) => {
|
|
240
250
|
if (!e.agentName) return;
|
|
241
|
-
r._ensureNode(e.agentName)
|
|
251
|
+
const node = r._ensureNode(e.agentName);
|
|
252
|
+
node.phase = 'done';
|
|
253
|
+
node.status = 'completed';
|
|
254
|
+
node.completedAt = ts;
|
|
255
|
+
node.lastEventAt = ts;
|
|
242
256
|
},
|
|
243
257
|
|
|
244
258
|
'tool:started': (r, e, ts) => {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
SummaryEntry,
|
|
10
10
|
} from '@animalabs/context-manager';
|
|
11
11
|
import type { ContentBlock } from '@animalabs/membrane';
|
|
12
|
+
import { formatZonedTime, resolveTimeZone } from '@animalabs/agent-framework';
|
|
12
13
|
|
|
13
14
|
// Structural mirror of AutobiographicalStrategy's internal Chunk.
|
|
14
15
|
// Kept inline because @animalabs/context-manager does not currently export it.
|
|
@@ -24,7 +25,7 @@ interface Chunk {
|
|
|
24
25
|
phaseType?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig
|
|
28
|
+
export type FrontdeskStrategyOptions = Partial<AutobiographicalConfig> & { timeZone?: string };
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Chatbot-flavoured context strategy for agents that receive messages via
|
|
@@ -43,6 +44,13 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
43
44
|
override readonly name: string = 'frontdesk';
|
|
44
45
|
|
|
45
46
|
private salientSourceIds: Set<string> = new Set();
|
|
47
|
+
private readonly timeZone: string;
|
|
48
|
+
|
|
49
|
+
constructor(options: FrontdeskStrategyOptions = {}) {
|
|
50
|
+
const { timeZone, ...strategyOptions } = options;
|
|
51
|
+
super(strategyOptions);
|
|
52
|
+
this.timeZone = resolveTimeZone(timeZone);
|
|
53
|
+
}
|
|
46
54
|
|
|
47
55
|
override select(
|
|
48
56
|
store: MessageStoreView,
|
|
@@ -115,15 +123,15 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
115
123
|
if (ts) parts.push(ts);
|
|
116
124
|
|
|
117
125
|
if (meta.messageId !== undefined && meta.messageId !== null && meta.messageId !== '') {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
126
|
+
// Render the FULL id — truncating (an old token-saving trim) corrupts
|
|
127
|
+
// Discord snowflakes, so every reply_message/fetch_around/add_reaction
|
|
128
|
+
// call the agent copies from its own context 404s with Unknown message.
|
|
129
|
+
parts.push(`msg ${String(meta.messageId)}`);
|
|
121
130
|
}
|
|
122
131
|
|
|
123
132
|
const threadId = meta.threadId !== undefined && meta.threadId !== null ? String(meta.threadId) : '';
|
|
124
133
|
if (threadId && threadId !== topic) {
|
|
125
|
-
|
|
126
|
-
parts.push(`thread ${short}`);
|
|
134
|
+
parts.push(`thread ${threadId}`);
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
if (parts.length === 0) return null;
|
|
@@ -146,9 +154,7 @@ export class FrontdeskStrategy extends AutobiographicalStrategy {
|
|
|
146
154
|
}
|
|
147
155
|
if (!d && msgTs instanceof Date && !isNaN(msgTs.getTime())) d = msgTs;
|
|
148
156
|
if (!d) return null;
|
|
149
|
-
|
|
150
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
151
|
-
return `${hh}:${mm}`;
|
|
157
|
+
return formatZonedTime(d, this.timeZone);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
160
|
// ==========================================================================
|
package/src/web/protocol.ts
CHANGED
|
@@ -93,6 +93,9 @@ export interface WelcomeMessage {
|
|
|
93
93
|
/** Per-agent cost breakdown for the parent process, present when the
|
|
94
94
|
* framework's usage tracker has data. Empty during cold start. */
|
|
95
95
|
perAgentCost?: PerAgentCost[];
|
|
96
|
+
/** Recent provider calls with cache verdicts. Present when the host's
|
|
97
|
+
* provider adapter exposes the call ledger. */
|
|
98
|
+
callLedger?: CallLedgerSnapshot;
|
|
96
99
|
}
|
|
97
100
|
|
|
98
101
|
/**
|
|
@@ -164,6 +167,91 @@ export interface UsageMessage {
|
|
|
164
167
|
perAgentCost?: PerAgentCost[];
|
|
165
168
|
}
|
|
166
169
|
|
|
170
|
+
/** Live replacement snapshot emitted after each provider call. */
|
|
171
|
+
export interface CallLedgerMessage {
|
|
172
|
+
type: 'call-ledger';
|
|
173
|
+
ledger: CallLedgerSnapshot;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export type CallLedgerVerdict =
|
|
177
|
+
| 'HIT'
|
|
178
|
+
| 'hit+extend'
|
|
179
|
+
| 'uncached'
|
|
180
|
+
| 'rewrite:expired'
|
|
181
|
+
| 'rewrite:prefix-mutated'
|
|
182
|
+
| 'rewrite:prefix-truncated'
|
|
183
|
+
| 'rewrite:unexplained'
|
|
184
|
+
| 'first-write'
|
|
185
|
+
| 'ERROR'
|
|
186
|
+
| 'empty'
|
|
187
|
+
| 'unknown';
|
|
188
|
+
|
|
189
|
+
export interface CallLedgerRow {
|
|
190
|
+
id: string;
|
|
191
|
+
timestamp: string;
|
|
192
|
+
kind: 'complete' | 'stream';
|
|
193
|
+
/** Honest call-class estimate; `~` means the trigger plumbing did not
|
|
194
|
+
* provide a definitive origin. */
|
|
195
|
+
originEstimate: 'turn~' | 'aux~';
|
|
196
|
+
model: string;
|
|
197
|
+
messages: number;
|
|
198
|
+
durationMs: number;
|
|
199
|
+
tokens: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
|
200
|
+
cost?: CallCostBreakdown;
|
|
201
|
+
cache: {
|
|
202
|
+
/** Undefined for older compact logs that predate marker summaries. */
|
|
203
|
+
breakpoints?: number;
|
|
204
|
+
ttls: string[];
|
|
205
|
+
effectiveTtl: '5m' | '1h';
|
|
206
|
+
};
|
|
207
|
+
verdict: CallLedgerVerdict;
|
|
208
|
+
cause: string;
|
|
209
|
+
stopReason?: string;
|
|
210
|
+
error?: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface CallCostBreakdown {
|
|
214
|
+
input: number;
|
|
215
|
+
cacheWrite5m: number;
|
|
216
|
+
cacheWrite1h: number;
|
|
217
|
+
cacheRead: number;
|
|
218
|
+
output: number;
|
|
219
|
+
total: number;
|
|
220
|
+
currency: 'USD';
|
|
221
|
+
/** `billing` means every charged usage bucket and pricing modifier was
|
|
222
|
+
* known. Unknown/mixed buckets are left unpriced rather than estimated. */
|
|
223
|
+
grade: 'billing';
|
|
224
|
+
pricingVersion: string;
|
|
225
|
+
rates: {
|
|
226
|
+
inputPerMillion: number;
|
|
227
|
+
outputPerMillion: number;
|
|
228
|
+
cacheWrite5mPerMillion: number;
|
|
229
|
+
cacheWrite1hPerMillion: number;
|
|
230
|
+
cacheReadPerMillion: number;
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface CallLedgerSnapshot {
|
|
235
|
+
/** Oldest to newest; clients may reverse for display. */
|
|
236
|
+
rows: CallLedgerRow[];
|
|
237
|
+
summary: {
|
|
238
|
+
calls: number;
|
|
239
|
+
input: number;
|
|
240
|
+
output: number;
|
|
241
|
+
cacheRead: number;
|
|
242
|
+
cacheWrite: number;
|
|
243
|
+
cacheHitRatio: number;
|
|
244
|
+
cost?: {
|
|
245
|
+
total: number;
|
|
246
|
+
currency: 'USD';
|
|
247
|
+
pricedCalls: number;
|
|
248
|
+
unpricedCalls: number;
|
|
249
|
+
pricingVersion: string;
|
|
250
|
+
};
|
|
251
|
+
byVerdict: Partial<Record<CallLedgerVerdict, number>>;
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
167
255
|
export interface TokenUsage {
|
|
168
256
|
input: number;
|
|
169
257
|
output: number;
|
|
@@ -352,6 +440,7 @@ export type WebUiServerMessage =
|
|
|
352
440
|
| ChildEventMessage
|
|
353
441
|
| CommandResultMessage
|
|
354
442
|
| UsageMessage
|
|
443
|
+
| CallLedgerMessage
|
|
355
444
|
| BranchChangedMessage
|
|
356
445
|
| SessionChangedMessage
|
|
357
446
|
| PeekMessage
|