@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.
@@ -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). Tools:
7
- * - reasoning_status → show current state
8
- * - reasoning_enable {budgetTokens?} → turn on with optional budget
9
- * - reasoning_disable → turn off
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
- const input = (call.input ?? {}) as Record<string, unknown>;
106
- switch (call.name) {
107
- case 'reasoning_status':
108
- return ok(this.reasoningStatusText());
109
- case 'reasoning_enable': {
110
- const budget =
111
- typeof input.budgetTokens === 'number'
112
- ? Math.max(1024, Math.round(input.budgetTokens))
113
- : this.state.reasoning.budgetTokens;
114
- this.state.reasoning = { enabled: true, budgetTokens: budget };
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 ok('Reasoning enabled. ' + this.reasoningStatusText());
117
- }
118
- case 'reasoning_disable':
119
- this.state.reasoning = { ...this.state.reasoning, enabled: false };
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 ok('Reasoning disabled. ' + this.reasoningStatusText());
122
- default:
123
- return { success: false, error: `Unknown tool: ${call.name}`, isError: true };
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-unsubscribe noisy ambient Discord channels.
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-unsubscribed via the surface's `unsubscribe_channel` tool. This
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-unsubscribe. */
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
- /** Tool-name prefix for that server (default `mcpl--<serverId>`). */
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-unsubscribe); a number overrides the
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-unsubscribed. `limit` is a number of characters, "default" to ' +
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-unsubscribe this channel.',
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-unsubscribe configuration: the global default limit, ' +
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-unsubscribe)';
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 → unsubscribe and clear the counter.
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: `${this.toolPrefix}--unsubscribe_channel`,
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-unsubscribed from channel ${channelId}: it emitted ` +
232
+ `[subscription-gc] Auto-closed channel ${channelId}: it emitted ` +
235
233
  `over ${limit} characters of ambient traffic since your last activation without ` +
236
- `engagement. Mentions and DMs there still reach you. Resubscribe with ` +
237
- `${this.toolPrefix}--subscribe_channel, raise/disable its limit with ` +
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
- // Unsubscribe failed — keep the channel counted so we retry on the next
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
- const basicOk = this.checkAuth(req);
987
- // Without basic auth the upgrade is allowed only when observer grants
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 basicOk = this.checkAuth(req);
1006
- const sessionScopes = basicOk
1007
- ? null
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. Once credentials are cached for
1015
- // the realm, the WS upgrade carries them and the client is 'full'.
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) return new Response(null, { status: 302, headers: { location: '/' } });
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
- return Response.json(fw.healthSnapshot());
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, { scopes: Set<ObserverScope>; expiresAt: number }>();
227
+ private sessions = new Map<string, ObserverSession & { expiresAt: number }>();
215
228
 
216
- mint(scopes: Set<ObserverScope>): string {
217
- // Opportunistic sweep — the map stays tiny (one entry per WS auth).
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): Set<ObserverScope> | null {
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
- * Channel auto-open policy. 'auto' (default) opens everything the server
158
- * registers; 'manual' opens nothing (agent calls channel_open as needed);
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 = budget exhaustion / stream-side cancel / reboot-
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).lastEventAt = ts;
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) => {