@animalabs/connectome-host 0.3.7 → 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/.github/workflows/ci.yml +52 -0
- package/.github/workflows/publish.yml +2 -2
- package/bun.lock +9 -5
- package/package.json +5 -3
- package/src/framework-agent-config.ts +51 -0
- package/src/framework-strategy.ts +70 -0
- package/src/index.ts +68 -94
- package/src/logging-adapter.ts +33 -3
- package/src/mcpl-config.ts +1 -0
- package/src/modules/channel-mode-module.ts +14 -16
- package/src/modules/mcpl-admin-module.ts +0 -15
- package/src/modules/settings-module.ts +83 -59
- package/src/modules/subscription-gc-module.ts +17 -20
- package/src/modules/web-ui-module.ts +63 -10
- package/src/modules/web-ui-observers.ts +19 -6
- package/src/recipe.ts +85 -3
- package/src/state/agent-tree-reducer.ts +17 -3
- package/src/strategies/frontdesk-strategy.ts +5 -5
- package/test/agent-tree-reducer.test.ts +35 -3
- package/test/autobio-progress-snapshot.test.ts +27 -12
- package/test/framework-fkm-composition.test.ts +160 -0
- package/test/logging-adapter-refusal.test.ts +57 -0
- package/test/recipe-compression-fallback.test.ts +36 -0
- package/test/recipe-primary-summary-fallback.test.ts +40 -0
- package/test/recipe-think-policy.test.ts +39 -0
- package/test/subscription-gc-module.test.ts +7 -5
- package/test/web-ui-observers.test.ts +27 -2
- package/web/package-lock.json +425 -302
- package/web/bun.lock +0 -357
|
@@ -114,16 +114,6 @@ export class McplAdminModule implements Module {
|
|
|
114
114
|
token: { type: 'string', description: 'Bearer token for WebSocket auth.' },
|
|
115
115
|
toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
|
|
116
116
|
reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (default false). Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
|
|
117
|
-
channelSubscription: {
|
|
118
|
-
type: 'string',
|
|
119
|
-
enum: ['auto', 'manual'],
|
|
120
|
-
description: "Channel auto-open policy: 'auto' (default) opens every channel the server registers; 'manual' opens none. For an allow-list, use channelAllowlist instead.",
|
|
121
|
-
},
|
|
122
|
-
channelAllowlist: {
|
|
123
|
-
type: 'array',
|
|
124
|
-
items: { type: 'string' },
|
|
125
|
-
description: 'Allow-list of channel ids to auto-open (overrides channelSubscription).',
|
|
126
|
-
},
|
|
127
117
|
enabledFeatureSets: { type: 'array', items: { type: 'string' } },
|
|
128
118
|
disabledFeatureSets: { type: 'array', items: { type: 'string' } },
|
|
129
119
|
enabledTools: { type: 'array', items: { type: 'string' }, description: 'Tool allow-list (bare names, * wildcard).' },
|
|
@@ -263,11 +253,6 @@ export class McplAdminModule implements Module {
|
|
|
263
253
|
if (typeof input.token === 'string') entry.token = input.token;
|
|
264
254
|
if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
|
|
265
255
|
if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
|
|
266
|
-
if (Array.isArray(input.channelAllowlist)) {
|
|
267
|
-
entry.channelSubscription = input.channelAllowlist.map(String);
|
|
268
|
-
} else if (input.channelSubscription === 'auto' || input.channelSubscription === 'manual') {
|
|
269
|
-
entry.channelSubscription = input.channelSubscription;
|
|
270
|
-
}
|
|
271
256
|
if (Array.isArray(input.enabledFeatureSets)) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
|
|
272
257
|
if (Array.isArray(input.disabledFeatureSets)) entry.disabledFeatureSets = input.disabledFeatureSets.map(String);
|
|
273
258
|
if (Array.isArray(input.enabledTools)) entry.enabledTools = input.enabledTools.map(String);
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* itself. State persists to chronicle via ModuleContext (`setState`/`getState`)
|
|
4
4
|
* so changes survive restarts.
|
|
5
5
|
*
|
|
6
|
-
* First domain: **reasoning** (Anthropic extended thinking)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* First domain: **reasoning** (Anthropic extended thinking), surfaced to the
|
|
7
|
+
* agent as `agent_settings` fields (reasoning_enabled /
|
|
8
|
+
* reasoning_budget_tokens) via the framework's settings-extension hook —
|
|
9
|
+
* NOT as standalone tools (the former reasoning_status/enable/disable trio
|
|
10
|
+
* was tool bloat for one boolean + number).
|
|
10
11
|
*
|
|
11
12
|
* The host's adapter wrapper (LoggingAnthropicAdapter) reads `getReasoning()`
|
|
12
13
|
* on each call and injects `thinking: {type:'enabled', budget_tokens: N}` into
|
|
@@ -69,75 +70,98 @@ export class SettingsModule implements Module {
|
|
|
69
70
|
return { ...this.state.reasoning };
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** No standalone tools — reasoning controls live inside the framework's
|
|
74
|
+
* `agent_settings` tool via getAgentSettingsExtension() below. The three
|
|
75
|
+
* former reasoning_* tools were pure tool bloat for one boolean + number. */
|
|
72
76
|
getTools(): ToolDefinition[] {
|
|
73
|
-
return [
|
|
74
|
-
{
|
|
75
|
-
name: 'reasoning_status',
|
|
76
|
-
description:
|
|
77
|
-
'Show whether extended thinking (reasoning) is currently enabled and the token budget.',
|
|
78
|
-
inputSchema: { type: 'object', properties: {} },
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
name: 'reasoning_enable',
|
|
82
|
-
description:
|
|
83
|
-
'Enable extended thinking (reasoning) on your subsequent inference calls. ' +
|
|
84
|
-
'Optional budgetTokens in tokens (default: current value, initially 8192; min 1024).',
|
|
85
|
-
inputSchema: {
|
|
86
|
-
type: 'object',
|
|
87
|
-
properties: {
|
|
88
|
-
budgetTokens: {
|
|
89
|
-
type: 'number',
|
|
90
|
-
description: 'Token budget for thinking blocks (min 1024).',
|
|
91
|
-
},
|
|
92
|
-
},
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
name: 'reasoning_disable',
|
|
97
|
-
description:
|
|
98
|
-
'Disable extended thinking (reasoning). Subsequent inference calls will not request thinking blocks.',
|
|
99
|
-
inputSchema: { type: 'object', properties: {} },
|
|
100
|
-
},
|
|
101
|
-
];
|
|
77
|
+
return [];
|
|
102
78
|
}
|
|
103
79
|
|
|
104
80
|
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
81
|
+
return {
|
|
82
|
+
success: false,
|
|
83
|
+
error:
|
|
84
|
+
`Unknown tool: ${call.name}. Reasoning controls moved into agent_settings ` +
|
|
85
|
+
`(fields reasoning_enabled / reasoning_budget_tokens).`,
|
|
86
|
+
isError: true,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Declare reasoning as an agent_settings extension: the framework merges
|
|
92
|
+
* these fields into the agent_settings tool and routes get/update/reset for
|
|
93
|
+
* them back here. Framework versions predating the hook simply never call
|
|
94
|
+
* this — in that case reasoning is temporarily not agent-tunable (the
|
|
95
|
+
* adapter still honors persisted state).
|
|
96
|
+
*/
|
|
97
|
+
getAgentSettingsExtension(): {
|
|
98
|
+
properties: Record<string, unknown>;
|
|
99
|
+
keys: string[];
|
|
100
|
+
get(agentName: string): Record<string, unknown>;
|
|
101
|
+
update(agentName: string, patch: Record<string, unknown>): Record<string, unknown>;
|
|
102
|
+
reset(agentName: string, keys?: string[]): Record<string, unknown>;
|
|
103
|
+
} {
|
|
104
|
+
return {
|
|
105
|
+
properties: {
|
|
106
|
+
reasoning_enabled: {
|
|
107
|
+
type: 'boolean',
|
|
108
|
+
description:
|
|
109
|
+
'Extended thinking (reasoning) on subsequent inference calls.',
|
|
110
|
+
},
|
|
111
|
+
reasoning_budget_tokens: {
|
|
112
|
+
type: 'number',
|
|
113
|
+
description: 'Token budget for thinking blocks (min 1024).',
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
keys: ['reasoning_enabled', 'reasoning_budget_tokens'],
|
|
117
|
+
get: () => this.reasoningSettingsView(),
|
|
118
|
+
update: (_agentName, patch) => {
|
|
119
|
+
const next = { ...this.state.reasoning };
|
|
120
|
+
if (patch.reasoning_enabled !== undefined) {
|
|
121
|
+
if (typeof patch.reasoning_enabled !== 'boolean') {
|
|
122
|
+
throw new Error('reasoning_enabled must be a boolean');
|
|
123
|
+
}
|
|
124
|
+
next.enabled = patch.reasoning_enabled;
|
|
125
|
+
}
|
|
126
|
+
if (patch.reasoning_budget_tokens !== undefined) {
|
|
127
|
+
const budget = Number(patch.reasoning_budget_tokens);
|
|
128
|
+
if (!Number.isFinite(budget)) {
|
|
129
|
+
throw new Error('reasoning_budget_tokens must be a number');
|
|
130
|
+
}
|
|
131
|
+
next.budgetTokens = Math.max(1024, Math.round(budget));
|
|
132
|
+
}
|
|
133
|
+
this.state.reasoning = next;
|
|
115
134
|
this.ctx?.setState(this.state);
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
return this.reasoningSettingsView();
|
|
136
|
+
},
|
|
137
|
+
reset: (_agentName, keys) => {
|
|
138
|
+
const all = !keys || keys.length === 0;
|
|
139
|
+
if (all || keys?.includes('reasoning_enabled')) {
|
|
140
|
+
this.state.reasoning.enabled = DEFAULTS.reasoning.enabled;
|
|
141
|
+
}
|
|
142
|
+
if (all || keys?.includes('reasoning_budget_tokens')) {
|
|
143
|
+
this.state.reasoning.budgetTokens = DEFAULTS.reasoning.budgetTokens;
|
|
144
|
+
}
|
|
120
145
|
this.ctx?.setState(this.state);
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
146
|
+
return this.reasoningSettingsView();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The extension's wire view of reasoning state (flat agent_settings keys). */
|
|
152
|
+
private reasoningSettingsView(): Record<string, unknown> {
|
|
153
|
+
return {
|
|
154
|
+
reasoning_enabled: this.state.reasoning.enabled,
|
|
155
|
+
reasoning_budget_tokens: this.state.reasoning.budgetTokens,
|
|
156
|
+
};
|
|
125
157
|
}
|
|
126
158
|
|
|
127
159
|
async onProcess(_event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
128
160
|
return {};
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
private reasoningStatusText(): string {
|
|
132
|
-
const r = this.state.reasoning;
|
|
133
|
-
return `reasoning=${r.enabled ? 'ENABLED' : 'disabled'}, budgetTokens=${r.budgetTokens}`;
|
|
134
|
-
}
|
|
135
163
|
}
|
|
136
164
|
|
|
137
165
|
function clone<T>(x: T): T {
|
|
138
166
|
return JSON.parse(JSON.stringify(x)) as T;
|
|
139
167
|
}
|
|
140
|
-
|
|
141
|
-
function ok(message: string): ToolResult {
|
|
142
|
-
return { success: true, data: { message }, isError: false };
|
|
143
|
-
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* SubscriptionGcModule — auto-
|
|
2
|
+
* SubscriptionGcModule — auto-close noisy ordinary channel traffic.
|
|
3
3
|
*
|
|
4
4
|
* Problem: a subscribed channel delivers ambient (non-mention, non-DM) messages
|
|
5
5
|
* into the agent's context whether or not the agent ever acts on them. A busy
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* the agent saw every subscribed channel in that context (compressed if
|
|
13
13
|
* large; nothing is dropped), so the slate is clean.
|
|
14
14
|
* - The instant a channel's since-last-activation count crosses its limit, it
|
|
15
|
-
* is auto-
|
|
15
|
+
* is auto-closed via the host's generic `channel_close` tool. This
|
|
16
16
|
* bounds any one channel's context pollution to ~limit characters.
|
|
17
17
|
*
|
|
18
18
|
* Everything is durable across restarts: counters + per-channel overrides
|
|
@@ -36,15 +36,15 @@ import type {
|
|
|
36
36
|
} from '@animalabs/agent-framework';
|
|
37
37
|
|
|
38
38
|
export interface SubscriptionGcConfig {
|
|
39
|
-
/** Default ambient-character budget per channel before auto-
|
|
39
|
+
/** Default ambient-character budget per channel before auto-close. */
|
|
40
40
|
defaultLimitChars?: number;
|
|
41
41
|
/** MCPL server id that owns subscriptions (default 'discord'). */
|
|
42
42
|
serverId?: string;
|
|
43
|
-
/**
|
|
43
|
+
/** @deprecated Lifecycle tools are host-generic; retained for config compatibility. */
|
|
44
44
|
toolPrefix?: string;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/** `'off'` pins a channel (never auto-
|
|
47
|
+
/** `'off'` pins a channel (never auto-close); a number overrides the
|
|
48
48
|
* default limit; absence falls back to the default. */
|
|
49
49
|
type LimitOverride = number | 'off';
|
|
50
50
|
|
|
@@ -66,7 +66,6 @@ export class SubscriptionGcModule implements Module {
|
|
|
66
66
|
|
|
67
67
|
private readonly defaultLimitChars: number;
|
|
68
68
|
private readonly serverId: string;
|
|
69
|
-
private readonly toolPrefix: string;
|
|
70
69
|
|
|
71
70
|
private state: GcState = { overrides: {}, counters: {} };
|
|
72
71
|
|
|
@@ -76,7 +75,6 @@ export class SubscriptionGcModule implements Module {
|
|
|
76
75
|
? Math.round(config.defaultLimitChars)
|
|
77
76
|
: 20000;
|
|
78
77
|
this.serverId = config.serverId ?? 'discord';
|
|
79
|
-
this.toolPrefix = config.toolPrefix ?? `mcpl--${this.serverId}`;
|
|
80
78
|
}
|
|
81
79
|
|
|
82
80
|
async start(ctx: ModuleContext): Promise<void> {
|
|
@@ -121,9 +119,9 @@ export class SubscriptionGcModule implements Module {
|
|
|
121
119
|
description:
|
|
122
120
|
'Set how many characters of ambient (non-mention, non-DM) traffic a ' +
|
|
123
121
|
'subscribed channel may emit between your activations before it is ' +
|
|
124
|
-
'auto-
|
|
122
|
+
'auto-closed. `limit` is a number of characters, "default" to ' +
|
|
125
123
|
`use the global default (${this.defaultLimitChars}), or "off" to ` +
|
|
126
|
-
'never auto-
|
|
124
|
+
'never auto-close this channel.',
|
|
127
125
|
inputSchema: {
|
|
128
126
|
type: 'object',
|
|
129
127
|
properties: {
|
|
@@ -139,7 +137,7 @@ export class SubscriptionGcModule implements Module {
|
|
|
139
137
|
{
|
|
140
138
|
name: 'list_channel_idle_limits',
|
|
141
139
|
description:
|
|
142
|
-
'Show the auto-
|
|
140
|
+
'Show the auto-close configuration: the global default limit, ' +
|
|
143
141
|
'per-channel overrides, and the current since-last-activation ambient ' +
|
|
144
142
|
'character counts per channel.',
|
|
145
143
|
inputSchema: { type: 'object', properties: {} },
|
|
@@ -169,7 +167,7 @@ export class SubscriptionGcModule implements Module {
|
|
|
169
167
|
desc = `default (${this.defaultLimitChars})`;
|
|
170
168
|
} else if (limit === 'off') {
|
|
171
169
|
this.state.overrides[channelId] = 'off';
|
|
172
|
-
desc = 'off (never auto-
|
|
170
|
+
desc = 'off (never auto-close)';
|
|
173
171
|
} else if (Number.isFinite(numeric) && numeric > 0) {
|
|
174
172
|
this.state.overrides[channelId] = Math.round(numeric);
|
|
175
173
|
desc = `${Math.round(numeric)} characters`;
|
|
@@ -207,14 +205,14 @@ export class SubscriptionGcModule implements Module {
|
|
|
207
205
|
const next = (this.state.counters[channelId] ?? 0) + chars;
|
|
208
206
|
|
|
209
207
|
if (limit !== Infinity && next > limit) {
|
|
210
|
-
// Cross the threshold →
|
|
208
|
+
// Cross the threshold → close and clear the counter.
|
|
211
209
|
delete this.state.counters[channelId];
|
|
212
210
|
this.persistNow();
|
|
213
211
|
const result = await this.ctx
|
|
214
212
|
?.callTool({
|
|
215
213
|
id: `gc-unsub-${this.callSeq++}`,
|
|
216
|
-
name:
|
|
217
|
-
input: { channelId },
|
|
214
|
+
name: 'channel_close',
|
|
215
|
+
input: { channelId, serverId: this.serverId },
|
|
218
216
|
})
|
|
219
217
|
.catch((err: unknown) => ({
|
|
220
218
|
success: false,
|
|
@@ -231,18 +229,17 @@ export class SubscriptionGcModule implements Module {
|
|
|
231
229
|
{
|
|
232
230
|
type: 'text',
|
|
233
231
|
text:
|
|
234
|
-
`[subscription-gc] Auto-
|
|
232
|
+
`[subscription-gc] Auto-closed channel ${channelId}: it emitted ` +
|
|
235
233
|
`over ${limit} characters of ambient traffic since your last activation without ` +
|
|
236
|
-
`engagement.
|
|
237
|
-
|
|
238
|
-
`set_channel_idle_limit, or check what you're missing with ${this.toolPrefix}--channel_missed.`,
|
|
234
|
+
`engagement. Direct addresses there still reach you. Reopen with channel_open, ` +
|
|
235
|
+
`or raise/disable its limit with set_channel_idle_limit.`,
|
|
239
236
|
},
|
|
240
237
|
],
|
|
241
238
|
},
|
|
242
239
|
],
|
|
243
240
|
};
|
|
244
241
|
}
|
|
245
|
-
//
|
|
242
|
+
// Close failed — keep the channel counted so we retry on the next
|
|
246
243
|
// ambient message rather than silently giving up.
|
|
247
244
|
this.state.counters[channelId] = next;
|
|
248
245
|
this.persistNow();
|
|
@@ -270,7 +267,7 @@ export class SubscriptionGcModule implements Module {
|
|
|
270
267
|
if (event.serverId !== this.serverId) return null;
|
|
271
268
|
const origin = (event.origin ?? {}) as Record<string, unknown>;
|
|
272
269
|
if (origin.isMention || origin.isDM) return null;
|
|
273
|
-
const channelId = origin.channelId;
|
|
270
|
+
const channelId = origin.mcplChannelId ?? origin.channelId;
|
|
274
271
|
if (typeof channelId !== 'string' || channelId.length === 0) return null;
|
|
275
272
|
return { channelId, chars: textLen(event.content) };
|
|
276
273
|
}
|
|
@@ -983,8 +983,14 @@ export class WebUiModule implements Module {
|
|
|
983
983
|
// `new WebSocket(...)` the way they do on fetch, so without an
|
|
984
984
|
// explicit check, any tab the operator opens could connect here.
|
|
985
985
|
if (!this.checkOrigin(req)) return new Response('Forbidden', { status: 403 });
|
|
986
|
-
|
|
987
|
-
//
|
|
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
|
|
988
994
|
// exist — and then NOTHING is sent until a signed observer-hello
|
|
989
995
|
// verifies (in-band auth; see onWsOpen/onWsMessage).
|
|
990
996
|
if (!basicOk && !observersActive) return this.unauthorized();
|
|
@@ -1002,19 +1008,29 @@ export class WebUiModule implements Module {
|
|
|
1002
1008
|
// when observers are active the static app shell is public — it carries
|
|
1003
1009
|
// no data, and key-only devices must be able to load the SPA to
|
|
1004
1010
|
// authenticate at all.
|
|
1005
|
-
const
|
|
1006
|
-
const
|
|
1007
|
-
|
|
1008
|
-
: 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;
|
|
1009
1014
|
const httpAllowed = (scope: ObserverScope): boolean =>
|
|
1010
1015
|
basicOk || (sessionScopes?.has(scope) ?? false);
|
|
1011
1016
|
// /auth/basic: deliberate basic-auth challenge point. fetch() never
|
|
1012
1017
|
// triggers the browser's native credential prompt, but a top-level
|
|
1013
1018
|
// navigation here does — the SPA's "sign in with password" fallback for
|
|
1014
|
-
// devices without an observer grant.
|
|
1015
|
-
// 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).
|
|
1016
1023
|
if (url.pathname === '/auth/basic') {
|
|
1017
|
-
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
|
+
}
|
|
1018
1034
|
return this.unauthorized();
|
|
1019
1035
|
}
|
|
1020
1036
|
const isStatic = !url.pathname.startsWith('/debug/')
|
|
@@ -1077,7 +1093,44 @@ export class WebUiModule implements Module {
|
|
|
1077
1093
|
if (typeof fw.healthSnapshot !== 'function') {
|
|
1078
1094
|
return Response.json({ error: 'framework lacks healthSnapshot()' }, { status: 501 });
|
|
1079
1095
|
}
|
|
1080
|
-
|
|
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);
|
|
1081
1134
|
}
|
|
1082
1135
|
|
|
1083
1136
|
// Workspace file passthrough: /files/<mount>/<path...>
|
|
@@ -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
|
|
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;
|
|
@@ -93,6 +100,11 @@ export interface RecipeAgent {
|
|
|
93
100
|
/** Prompt-cache TTL ('5m' | '1h') forwarded to the provider. Defaults to
|
|
94
101
|
* '1h'; set '5m' explicitly for high-frequency, sub-5-minute workloads. */
|
|
95
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';
|
|
96
108
|
strategy?: RecipeStrategy;
|
|
97
109
|
/**
|
|
98
110
|
* Native extended thinking. When `enabled: true`, the agent's API requests
|
|
@@ -108,6 +120,7 @@ export interface RecipeAgent {
|
|
|
108
120
|
responses?: {
|
|
109
121
|
reasoningEffort?: 'none' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
110
122
|
reasoningContext?: 'current_turn' | 'all_turns';
|
|
123
|
+
serviceTier?: string;
|
|
111
124
|
compactThreshold?: number;
|
|
112
125
|
};
|
|
113
126
|
/**
|
|
@@ -119,6 +132,11 @@ export interface RecipeAgent {
|
|
|
119
132
|
autoRewind?: boolean;
|
|
120
133
|
maxRewinds?: number;
|
|
121
134
|
announceHumanTurns?: boolean;
|
|
135
|
+
primarySummaryFallback?: {
|
|
136
|
+
enabled?: boolean;
|
|
137
|
+
maxNewSummaries?: number;
|
|
138
|
+
requestBudgetTokens?: number;
|
|
139
|
+
};
|
|
122
140
|
};
|
|
123
141
|
}
|
|
124
142
|
|
|
@@ -154,9 +172,8 @@ export interface RecipeMcpServer {
|
|
|
154
172
|
/** Ceiling for the exponential reconnect backoff. Default: 5 minutes. */
|
|
155
173
|
reconnectMaxIntervalMs?: number;
|
|
156
174
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* 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.
|
|
160
177
|
*/
|
|
161
178
|
channelSubscription?: 'auto' | 'manual' | string[];
|
|
162
179
|
/**
|
|
@@ -716,6 +733,9 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
716
733
|
if (responses.reasoningContext !== undefined && responses.reasoningContext !== 'current_turn' && responses.reasoningContext !== 'all_turns') {
|
|
717
734
|
throw new Error(`Invalid agent.responses.reasoningContext ${JSON.stringify(responses.reasoningContext)}.`);
|
|
718
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
|
+
}
|
|
719
739
|
if (responses.compactThreshold !== undefined &&
|
|
720
740
|
(typeof responses.compactThreshold !== 'number' || responses.compactThreshold <= 0)) {
|
|
721
741
|
throw new Error('Recipe agent.responses.compactThreshold must be a positive number.');
|
|
@@ -733,6 +753,16 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
733
753
|
}
|
|
734
754
|
agent.cacheTtl ??= '1h';
|
|
735
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
|
+
}
|
|
765
|
+
|
|
736
766
|
// Validate agent.thinking if present. Catches typos and constraint
|
|
737
767
|
// violations (notably max_tokens > budget_tokens) at recipe-load time
|
|
738
768
|
// rather than as a 400 from Anthropic at first inference.
|
|
@@ -770,6 +800,58 @@ export function validateRecipe(raw: unknown): Recipe {
|
|
|
770
800
|
`Invalid strategy type "${strategy.type}". Must be "autobiographical", "passthrough", or "frontdesk".`,
|
|
771
801
|
);
|
|
772
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
|
+
}
|
|
773
855
|
}
|
|
774
856
|
|
|
775
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) => {
|