@animalabs/connectome-host 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +47 -0
- package/README.md +11 -11
- package/docs/AGENT-ONBOARDING.md +20 -1
- package/package.json +2 -2
- package/scripts/warmup-session.ts +17 -3
- package/src/codex-subscription-adapter.ts +13 -1
- package/src/framework-agent-config.ts +59 -4
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +86 -29
- package/src/logging-adapter.ts +13 -2
- package/src/mcpl-config.ts +8 -0
- package/src/modules/identity-module.ts +274 -0
- package/src/modules/mcpl-admin-module.ts +45 -1
- package/src/modules/observers-module.ts +12 -0
- package/src/modules/retrieval-module.ts +5 -1
- package/src/modules/settings-module.ts +28 -2
- package/src/modules/subscription-gc-module.ts +54 -1
- package/src/recipe.ts +85 -11
- package/test/bedrock-prompt-caching.test.ts +170 -0
- package/test/framework-strategy-defaults.test.ts +88 -0
- package/test/identity-and-surfaces.test.ts +157 -0
- package/test/subscription-gc-module.test.ts +152 -0
- package/web/bun.lock +345 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent identity — the agent's own archipelago-home principal (connectome
|
|
3
|
+
* docs/home-node.md §4).
|
|
4
|
+
*
|
|
5
|
+
* Two audiences, deliberately separated:
|
|
6
|
+
*
|
|
7
|
+
* HOST-FACING (this module's public methods): the deployment holds an
|
|
8
|
+
* ed25519 keypair in the data dir; `accessFor(audience)` exchanges a
|
|
9
|
+
* key-proof at the home node for a short-lived aid1 token, and
|
|
10
|
+
* `httpAuthFor(audience)` wraps it for HTTP. This is plumbing other host
|
|
11
|
+
* pieces call — the MCPL transport's per-dial credential provider, future
|
|
12
|
+
* HTTP helpers. Credentials live and die HERE.
|
|
13
|
+
*
|
|
14
|
+
* AGENT-FACING (utilities, via the `utils` meta-tool): deliberately small
|
|
15
|
+
* and deliberately boring — `status` ("who am I registered as, where is
|
|
16
|
+
* that recognized") and `accept_invite` ("register with an invitation code
|
|
17
|
+
* from your operator"). No tokens, keys, proofs, or signing in any
|
|
18
|
+
* agent-visible name, description, or result: the agent asks for access by
|
|
19
|
+
* name (`mcpl_deploy … access: "eidoverse"`); the host does the rest. This
|
|
20
|
+
* is both hygiene (credentials never enter model context, so they never
|
|
21
|
+
* enter chronicles, compression, or channels) and framing (an agent
|
|
22
|
+
* narrating credential mechanics reads as exfiltration to safety
|
|
23
|
+
* classifiers — so it simply never has them to narrate).
|
|
24
|
+
*
|
|
25
|
+
* Utilities-only module: enrollment is one-time; it costs no tool slots.
|
|
26
|
+
* Wire statements per the home-node spec (archipelago-home
|
|
27
|
+
* src/statements.ts is the source of truth — re-copy, don't fork).
|
|
28
|
+
*/
|
|
29
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
|
|
30
|
+
import { dirname } from 'node:path';
|
|
31
|
+
import {
|
|
32
|
+
createPrivateKey,
|
|
33
|
+
createPublicKey,
|
|
34
|
+
generateKeyPairSync,
|
|
35
|
+
sign as cryptoSign,
|
|
36
|
+
type KeyObject,
|
|
37
|
+
} from 'node:crypto';
|
|
38
|
+
import type {
|
|
39
|
+
Module,
|
|
40
|
+
ModuleContext,
|
|
41
|
+
ToolCall,
|
|
42
|
+
ToolResult,
|
|
43
|
+
ToolDefinition,
|
|
44
|
+
} from '@animalabs/agent-framework';
|
|
45
|
+
|
|
46
|
+
export interface IdentityModuleConfig {
|
|
47
|
+
/** ed25519 PKCS#8 PEM, generated on first use, 0600. dataDir-anchored:
|
|
48
|
+
* identity is per-deployment, not per-session. */
|
|
49
|
+
keyPath: string;
|
|
50
|
+
/** Home node domain (the trust anchor), e.g. `id.animalabs.ai`. */
|
|
51
|
+
home: string;
|
|
52
|
+
/** Audience assumed when none is named. */
|
|
53
|
+
defaultAudience?: string;
|
|
54
|
+
/** Injectable for tests. */
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Persisted beside the key after a successful registration. */
|
|
59
|
+
interface IdentityRecord {
|
|
60
|
+
sub: string;
|
|
61
|
+
name: string;
|
|
62
|
+
home: string;
|
|
63
|
+
enrolledAt: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ok(data: unknown): ToolResult {
|
|
67
|
+
return { success: true, data };
|
|
68
|
+
}
|
|
69
|
+
function fail(text: string): ToolResult {
|
|
70
|
+
return { success: false, error: text, isError: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class IdentityModule implements Module {
|
|
74
|
+
readonly name = 'identity';
|
|
75
|
+
private readonly recordPath: string;
|
|
76
|
+
|
|
77
|
+
constructor(private readonly config: IdentityModuleConfig) {
|
|
78
|
+
this.recordPath = config.keyPath.replace(/\.pem$/, '') + '.json';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async start(_ctx: ModuleContext): Promise<void> {}
|
|
82
|
+
async stop(): Promise<void> {}
|
|
83
|
+
|
|
84
|
+
getTools(): ToolDefinition[] {
|
|
85
|
+
return []; // utilities-only, by design — see module header
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getUtilities(): ToolDefinition[] {
|
|
89
|
+
return [
|
|
90
|
+
{
|
|
91
|
+
name: 'status',
|
|
92
|
+
description:
|
|
93
|
+
'Your registered identity: the name and id services know you by, and which ' +
|
|
94
|
+
'identity service vouches for it. Access to networked places (worlds etc.) is ' +
|
|
95
|
+
'managed by the host from this — you never handle credentials yourself.',
|
|
96
|
+
inputSchema: { type: 'object', properties: {} },
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: 'accept_invite',
|
|
100
|
+
description:
|
|
101
|
+
'Register with the identity service using an invitation code from your operator. ' +
|
|
102
|
+
'One-time: it establishes the name services will know you by. After this, the ' +
|
|
103
|
+
'host handles access automatically (e.g. mcpl_deploy with an `access` name).',
|
|
104
|
+
inputSchema: {
|
|
105
|
+
type: 'object',
|
|
106
|
+
properties: {
|
|
107
|
+
invite: { type: 'string', description: 'Invitation code from your operator.' },
|
|
108
|
+
name: { type: 'string', description: 'The display name you want (must be unused).' },
|
|
109
|
+
},
|
|
110
|
+
required: ['invite', 'name'],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async handleToolCall(call: ToolCall): Promise<ToolResult> {
|
|
117
|
+
try {
|
|
118
|
+
switch (call.name) {
|
|
119
|
+
case 'status':
|
|
120
|
+
return this.status();
|
|
121
|
+
case 'accept_invite':
|
|
122
|
+
return await this.acceptInvite(call.input as { invite?: unknown; name?: unknown });
|
|
123
|
+
default:
|
|
124
|
+
return fail(`Unknown identity utility: ${call.name}`);
|
|
125
|
+
}
|
|
126
|
+
} catch (err) {
|
|
127
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async onProcess(): Promise<Record<string, never>> {
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Host-facing API — credential plumbing. Nothing below ever reaches model
|
|
137
|
+
// context; callers (MCPL dial provider, HTTP helpers) consume the values
|
|
138
|
+
// outside the agent's view.
|
|
139
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/** True once this deployment holds a registered principal. */
|
|
142
|
+
isEnrolled(): boolean {
|
|
143
|
+
return this.record() !== null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The registered principal id (`agent:<name>@<domain>`), if any. */
|
|
147
|
+
sub(): string | null {
|
|
148
|
+
return this.record()?.sub ?? null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Exchange a key-proof for a fresh aid1 token for `audience`. Called per
|
|
153
|
+
* MCPL dial (connect + every reconnect) and by HTTP helpers — which is
|
|
154
|
+
* what lets audience tokens be short-lived. Throws with an actionable
|
|
155
|
+
* message when unregistered or refused.
|
|
156
|
+
*/
|
|
157
|
+
async accessFor(audience?: string): Promise<string> {
|
|
158
|
+
const aud = audience ?? this.config.defaultAudience;
|
|
159
|
+
if (!aud) throw new Error('identity: no audience named and none configured');
|
|
160
|
+
if (!this.record()) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`identity: not registered with ${this.config.home} — the agent needs to accept an operator invite first (utils run identity--accept_invite)`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
const key = this.loadOrCreateKey();
|
|
166
|
+
const timestamp = new Date().toISOString();
|
|
167
|
+
const statement = `archipelago-token|v1|${this.config.home}|${aud}|${timestamp}`;
|
|
168
|
+
const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
|
|
169
|
+
const { status, json } = await this.post('/token', { id: key.id, audience: aud, timestamp, proof });
|
|
170
|
+
if (status !== 200 || typeof json.token !== 'string') {
|
|
171
|
+
throw new Error(`identity: ${this.config.home} refused access to "${aud}" (${status}): ${String(json.error ?? 'unknown')}`);
|
|
172
|
+
}
|
|
173
|
+
return json.token;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Authorization header for HTTP calls to an audience's API. */
|
|
177
|
+
async httpAuthFor(audience?: string): Promise<Record<string, string>> {
|
|
178
|
+
return { authorization: `Bearer ${await this.accessFor(audience)}` };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── key material ──
|
|
182
|
+
|
|
183
|
+
private loadOrCreateKey(): { privateKey: KeyObject; id: string } {
|
|
184
|
+
let privateKey: KeyObject;
|
|
185
|
+
if (existsSync(this.config.keyPath)) {
|
|
186
|
+
privateKey = createPrivateKey(readFileSync(this.config.keyPath, 'utf8'));
|
|
187
|
+
} else {
|
|
188
|
+
privateKey = generateKeyPairSync('ed25519').privateKey;
|
|
189
|
+
mkdirSync(dirname(this.config.keyPath), { recursive: true });
|
|
190
|
+
writeFileSync(this.config.keyPath, privateKey.export({ format: 'pem', type: 'pkcs8' }), { mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
const spki = createPublicKey(privateKey).export({ format: 'der', type: 'spki' }) as Buffer;
|
|
193
|
+
return { privateKey, id: `ed25519:${spki.subarray(spki.length - 32).toString('base64url')}` };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private record(): IdentityRecord | null {
|
|
197
|
+
try {
|
|
198
|
+
return JSON.parse(readFileSync(this.recordPath, 'utf8')) as IdentityRecord;
|
|
199
|
+
} catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private saveRecord(rec: IdentityRecord): void {
|
|
205
|
+
writeFileSync(this.recordPath + '.tmp', JSON.stringify(rec, null, 2) + '\n');
|
|
206
|
+
renameSync(this.recordPath + '.tmp', this.recordPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async post(path: string, body: unknown): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
210
|
+
const f = this.config.fetchImpl ?? fetch;
|
|
211
|
+
const res = await f(`https://${this.config.home}${path}`, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: { 'content-type': 'application/json' },
|
|
214
|
+
body: JSON.stringify(body),
|
|
215
|
+
});
|
|
216
|
+
return { status: res.status, json: (await res.json().catch(() => ({}))) as Record<string, unknown> };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── agent-facing utilities ──
|
|
220
|
+
|
|
221
|
+
private status(): ToolResult {
|
|
222
|
+
// Key material is deliberately created lazily here too, so `status` is
|
|
223
|
+
// always safe to call — but none of it surfaces in the result.
|
|
224
|
+
this.loadOrCreateKey();
|
|
225
|
+
const rec = this.record();
|
|
226
|
+
return ok(
|
|
227
|
+
rec
|
|
228
|
+
? {
|
|
229
|
+
registeredAs: rec.name,
|
|
230
|
+
id: rec.sub,
|
|
231
|
+
recognizedBy: rec.home,
|
|
232
|
+
since: rec.enrolledAt,
|
|
233
|
+
note: 'Access to services is handled by the host automatically (e.g. mcpl_deploy with an `access` name).',
|
|
234
|
+
}
|
|
235
|
+
: {
|
|
236
|
+
registeredAs: null,
|
|
237
|
+
note: `Not registered with ${this.config.home} yet — ask your operator for an invitation code, then use accept_invite.`,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async acceptInvite(input: { invite?: unknown; name?: unknown }): Promise<ToolResult> {
|
|
243
|
+
if (typeof input.invite !== 'string' || typeof input.name !== 'string') {
|
|
244
|
+
return fail('accept_invite needs { invite, name }');
|
|
245
|
+
}
|
|
246
|
+
const existing = this.record();
|
|
247
|
+
if (existing) {
|
|
248
|
+
return fail(`Already registered as "${existing.name}" (${existing.sub}) — registration is one-time.`);
|
|
249
|
+
}
|
|
250
|
+
const key = this.loadOrCreateKey();
|
|
251
|
+
const timestamp = new Date().toISOString();
|
|
252
|
+
const statement = `archipelago-enroll|v1|${this.config.home}|${input.invite}|${timestamp}`;
|
|
253
|
+
const proof = cryptoSign(null, Buffer.from(statement, 'utf8'), key.privateKey).toString('base64url');
|
|
254
|
+
const { status, json } = await this.post('/enroll', {
|
|
255
|
+
invite: input.invite,
|
|
256
|
+
id: key.id,
|
|
257
|
+
name: input.name,
|
|
258
|
+
timestamp,
|
|
259
|
+
proof,
|
|
260
|
+
});
|
|
261
|
+
if (status !== 200 || typeof json.sub !== 'string') {
|
|
262
|
+
return fail(`Registration refused (${status}): ${String(json.error ?? 'unknown')}`);
|
|
263
|
+
}
|
|
264
|
+
this.saveRecord({ sub: json.sub, name: input.name, home: this.config.home, enrolledAt: timestamp });
|
|
265
|
+
// Note what is NOT returned: the first token the home node minted. The
|
|
266
|
+
// host fetches its own, fresh, per use — the agent never holds one.
|
|
267
|
+
return ok({
|
|
268
|
+
registeredAs: input.name,
|
|
269
|
+
id: json.sub,
|
|
270
|
+
recognizedBy: this.config.home,
|
|
271
|
+
note: 'Done — the host now handles access for you automatically.',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -51,6 +51,11 @@ export interface McplAdminModuleConfig {
|
|
|
51
51
|
overlayPath?: string;
|
|
52
52
|
/** Path to the human-owned server config file (read-only here). */
|
|
53
53
|
configPath?: string;
|
|
54
|
+
/** Where these operations surface to the model: 'tools' (default — four
|
|
55
|
+
* first-class slots, exactly the historical behavior) or 'utilities'
|
|
56
|
+
* (behind the framework's single `utils` meta-tool: mcpl management is a
|
|
57
|
+
* rare operation and needn't tax every inference with four schemas). */
|
|
58
|
+
surface?: 'tools' | 'utilities';
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
function ok(text: string): ToolResult {
|
|
@@ -69,10 +74,13 @@ export class McplAdminModule implements Module {
|
|
|
69
74
|
private configPath: string;
|
|
70
75
|
private timeZone: string;
|
|
71
76
|
|
|
77
|
+
private surface: 'tools' | 'utilities';
|
|
78
|
+
|
|
72
79
|
constructor(config?: McplAdminModuleConfig) {
|
|
73
80
|
this.overlayPath = config?.overlayPath ?? DEFAULT_AGENT_OVERLAY_PATH;
|
|
74
81
|
this.configPath = config?.configPath ?? DEFAULT_CONFIG_PATH;
|
|
75
82
|
this.timeZone = resolveTimeZone(config?.timeZone);
|
|
83
|
+
this.surface = config?.surface ?? 'tools';
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
/** Post-creation wiring (called from index.ts, mirrors ActivityModule.setFramework). */
|
|
@@ -80,6 +88,15 @@ export class McplAdminModule implements Module {
|
|
|
80
88
|
this.framework = framework;
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
/** Optional identity plumbing (index.ts wires it when the recipe enables
|
|
92
|
+
* the identity module): lets deployed servers name an `access` grant that
|
|
93
|
+
* the host turns into a per-dial credential provider. The agent names the
|
|
94
|
+
* access; credentials never surface. */
|
|
95
|
+
private identity: { accessFor(audience?: string): Promise<string> } | null = null;
|
|
96
|
+
setIdentity(identity: { accessFor(audience?: string): Promise<string> } | null): void {
|
|
97
|
+
this.identity = identity;
|
|
98
|
+
}
|
|
99
|
+
|
|
83
100
|
async start(_ctx: ModuleContext): Promise<void> {}
|
|
84
101
|
|
|
85
102
|
async stop(): Promise<void> {
|
|
@@ -87,6 +104,16 @@ export class McplAdminModule implements Module {
|
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
getTools(): ToolDefinition[] {
|
|
107
|
+
return this.surface === 'tools' ? this.definitions() : [];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Same definitions, same handler — the surface flag only decides whether
|
|
111
|
+
* they cost four slots or ride the `utils` meta-tool. */
|
|
112
|
+
getUtilities(): ToolDefinition[] {
|
|
113
|
+
return this.surface === 'utilities' ? this.definitions() : [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private definitions(): ToolDefinition[] {
|
|
90
117
|
return [
|
|
91
118
|
{
|
|
92
119
|
name: 'mcpl_list',
|
|
@@ -111,7 +138,8 @@ export class McplAdminModule implements Module {
|
|
|
111
138
|
args: { type: 'array', items: { type: 'string' }, description: 'Arguments for the command.' },
|
|
112
139
|
env: { type: 'object', description: 'Environment variables for the spawned process.' },
|
|
113
140
|
url: { type: 'string', description: 'WebSocket URL (websocket transport). Mutually exclusive with command.' },
|
|
114
|
-
token: { type: 'string', description: 'Bearer token for WebSocket auth.' },
|
|
141
|
+
token: { type: 'string', description: 'Bearer token for WebSocket auth (only when the operator hands you one — prefer `access`).' },
|
|
142
|
+
access: { type: 'string', description: 'Name of a host-managed access grant (e.g. "eidoverse"): the host attaches your standing credentials to the connection automatically. Nothing for you to obtain or handle.' },
|
|
115
143
|
toolPrefix: { type: 'string', description: 'Tool namespace prefix. Default: mcpl--<id>.' },
|
|
116
144
|
reconnect: { type: 'boolean', description: 'Auto-reconnect on transport failure (default false). Note: does NOT respawn a crashed child — use mcpl_restart for that.' },
|
|
117
145
|
enabledFeatureSets: { type: 'array', items: { type: 'string' } },
|
|
@@ -251,6 +279,15 @@ export class McplAdminModule implements Module {
|
|
|
251
279
|
if (Array.isArray(input.args)) entry.args = input.args.map(String);
|
|
252
280
|
if (input.env && typeof input.env === 'object') entry.env = input.env as Record<string, string>;
|
|
253
281
|
if (typeof input.token === 'string') entry.token = input.token;
|
|
282
|
+
if (typeof input.access === 'string' && input.access.trim()) {
|
|
283
|
+
if (!this.identity) {
|
|
284
|
+
return fail(
|
|
285
|
+
'`access` names a host-managed access grant, but this deployment has no identity ' +
|
|
286
|
+
'configured — ask your operator to enable it (recipe `identity`), or supply a `token`.',
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
entry.access = input.access.trim();
|
|
290
|
+
}
|
|
254
291
|
if (typeof input.toolPrefix === 'string') entry.toolPrefix = input.toolPrefix;
|
|
255
292
|
if (typeof input.reconnect === 'boolean') entry.reconnect = input.reconnect;
|
|
256
293
|
if (Array.isArray(input.enabledFeatureSets)) entry.enabledFeatureSets = input.enabledFeatureSets.map(String);
|
|
@@ -266,6 +303,13 @@ export class McplAdminModule implements Module {
|
|
|
266
303
|
|
|
267
304
|
const config = resolveOverlayEntry(id, entry, this.overlayPath) as unknown as McplServerConfig;
|
|
268
305
|
config.env = { ...(config.env ?? {}), AGENT_TIMEZONE: this.timeZone };
|
|
306
|
+
if (entry.access && this.identity) {
|
|
307
|
+
const identity = this.identity;
|
|
308
|
+
const audience = entry.access;
|
|
309
|
+
// Fresh credential on every dial, resolved host-side; the overlay
|
|
310
|
+
// stores only the access NAME. See identity-module.ts header.
|
|
311
|
+
config.accessProvider = () => identity.accessFor(audience);
|
|
312
|
+
}
|
|
269
313
|
|
|
270
314
|
const alreadyLoaded = framework.listMcplServers().some(s => s.id === id);
|
|
271
315
|
try {
|
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
export interface ObserversModuleConfig {
|
|
38
38
|
/** Absolute path to the grant file (same one the webui watches). */
|
|
39
39
|
path: string;
|
|
40
|
+
/** 'tools' (default) or 'utilities' — grant edits are rare; behind the
|
|
41
|
+
* `utils` meta-tool they stop costing three schemas per inference. The
|
|
42
|
+
* consent semantics are unchanged either way: the agent holds the pen. */
|
|
43
|
+
surface?: 'tools' | 'utilities';
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
export class ObserversModule implements Module {
|
|
@@ -48,6 +52,14 @@ export class ObserversModule implements Module {
|
|
|
48
52
|
async stop(): Promise<void> {}
|
|
49
53
|
|
|
50
54
|
getTools(): ToolDefinition[] {
|
|
55
|
+
return (this.config.surface ?? 'tools') === 'tools' ? this.definitions() : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
getUtilities(): ToolDefinition[] {
|
|
59
|
+
return this.config.surface === 'utilities' ? this.definitions() : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private definitions(): ToolDefinition[] {
|
|
51
63
|
return [
|
|
52
64
|
{
|
|
53
65
|
name: 'get',
|
|
@@ -154,7 +154,11 @@ export class RetrievalModule implements Module {
|
|
|
154
154
|
|
|
155
155
|
const injections: ContextInjection[] = [{
|
|
156
156
|
namespace: 'retrieval',
|
|
157
|
-
|
|
157
|
+
// 'afterUser', NOT 'system': retrieval content changes with recent
|
|
158
|
+
// context (contextHash above), so injecting it into the system prompt
|
|
159
|
+
// churns the very front of the KV cache and invalidates the entire
|
|
160
|
+
// prefix every turn. Tail injection keeps the stable prefix cached.
|
|
161
|
+
position: 'afterUser',
|
|
158
162
|
content: [{ type: 'text', text: `## Retrieved Knowledge\n${text}` }],
|
|
159
163
|
}];
|
|
160
164
|
|
|
@@ -33,6 +33,14 @@ import type {
|
|
|
33
33
|
export interface ReasoningSettings {
|
|
34
34
|
enabled: boolean;
|
|
35
35
|
budgetTokens: number;
|
|
36
|
+
/**
|
|
37
|
+
* How thinking content comes back from the API: 'summarized' returns a
|
|
38
|
+
* readable reasoning summary in the `thinking` field; 'omitted' returns an
|
|
39
|
+
* empty `thinking` field with only the encrypted signature. Models 4.7+
|
|
40
|
+
* default to 'omitted' server-side — we default to 'summarized' to restore
|
|
41
|
+
* the pre-4.7 behavior (visible reasoning in stores, webui, estimators).
|
|
42
|
+
*/
|
|
43
|
+
display: 'summarized' | 'omitted';
|
|
36
44
|
}
|
|
37
45
|
|
|
38
46
|
export interface SettingsState {
|
|
@@ -40,7 +48,7 @@ export interface SettingsState {
|
|
|
40
48
|
}
|
|
41
49
|
|
|
42
50
|
const DEFAULTS: SettingsState = {
|
|
43
|
-
reasoning: { enabled: false, budgetTokens: 8192 },
|
|
51
|
+
reasoning: { enabled: false, budgetTokens: 8192, display: 'summarized' },
|
|
44
52
|
};
|
|
45
53
|
|
|
46
54
|
export class SettingsModule implements Module {
|
|
@@ -112,8 +120,16 @@ export class SettingsModule implements Module {
|
|
|
112
120
|
type: 'number',
|
|
113
121
|
description: 'Token budget for thinking blocks (min 1024).',
|
|
114
122
|
},
|
|
123
|
+
reasoning_display: {
|
|
124
|
+
type: 'string',
|
|
125
|
+
enum: ['summarized', 'omitted'],
|
|
126
|
+
description:
|
|
127
|
+
"How your thinking is returned: 'summarized' (a readable summary of your reasoning " +
|
|
128
|
+
"is recorded alongside the signature) or 'omitted' (signature only, slightly faster " +
|
|
129
|
+
'first token; your reasoning is not visible to anyone, including you on replay).',
|
|
130
|
+
},
|
|
115
131
|
},
|
|
116
|
-
keys: ['reasoning_enabled', 'reasoning_budget_tokens'],
|
|
132
|
+
keys: ['reasoning_enabled', 'reasoning_budget_tokens', 'reasoning_display'],
|
|
117
133
|
get: () => this.reasoningSettingsView(),
|
|
118
134
|
update: (_agentName, patch) => {
|
|
119
135
|
const next = { ...this.state.reasoning };
|
|
@@ -130,6 +146,12 @@ export class SettingsModule implements Module {
|
|
|
130
146
|
}
|
|
131
147
|
next.budgetTokens = Math.max(1024, Math.round(budget));
|
|
132
148
|
}
|
|
149
|
+
if (patch.reasoning_display !== undefined) {
|
|
150
|
+
if (patch.reasoning_display !== 'summarized' && patch.reasoning_display !== 'omitted') {
|
|
151
|
+
throw new Error("reasoning_display must be 'summarized' or 'omitted'");
|
|
152
|
+
}
|
|
153
|
+
next.display = patch.reasoning_display;
|
|
154
|
+
}
|
|
133
155
|
this.state.reasoning = next;
|
|
134
156
|
this.ctx?.setState(this.state);
|
|
135
157
|
return this.reasoningSettingsView();
|
|
@@ -142,6 +164,9 @@ export class SettingsModule implements Module {
|
|
|
142
164
|
if (all || keys?.includes('reasoning_budget_tokens')) {
|
|
143
165
|
this.state.reasoning.budgetTokens = DEFAULTS.reasoning.budgetTokens;
|
|
144
166
|
}
|
|
167
|
+
if (all || keys?.includes('reasoning_display')) {
|
|
168
|
+
this.state.reasoning.display = DEFAULTS.reasoning.display;
|
|
169
|
+
}
|
|
145
170
|
this.ctx?.setState(this.state);
|
|
146
171
|
return this.reasoningSettingsView();
|
|
147
172
|
},
|
|
@@ -153,6 +178,7 @@ export class SettingsModule implements Module {
|
|
|
153
178
|
return {
|
|
154
179
|
reasoning_enabled: this.state.reasoning.enabled,
|
|
155
180
|
reasoning_budget_tokens: this.state.reasoning.budgetTokens,
|
|
181
|
+
reasoning_display: this.state.reasoning.display,
|
|
156
182
|
};
|
|
157
183
|
}
|
|
158
184
|
|
|
@@ -336,11 +336,25 @@ export class SubscriptionGcModule implements Module {
|
|
|
336
336
|
// Cross the threshold → close and clear the counter.
|
|
337
337
|
delete this.state.counters[channelId];
|
|
338
338
|
this.persistNow();
|
|
339
|
+
// A CONFIGURED numeric budget for this channel is an explicit idle
|
|
340
|
+
// lease: someone chose a close-at-N budget for this specific channel,
|
|
341
|
+
// so the registry may close even an explicitly-opened one. The state
|
|
342
|
+
// does not record WHO configured it (agent via agent_settings,
|
|
343
|
+
// operator, or imported before these semantics existed), so nothing
|
|
344
|
+
// downstream may claim "agent-set" — receipts say 'configured-budget',
|
|
345
|
+
// actor unknown. The global default is not consent of any kind; the
|
|
346
|
+
// registry refuses machine closes of explicit opens under it (#5).
|
|
347
|
+
const hasConfiguredLease = typeof this.state.overrides[channelId] === 'number';
|
|
339
348
|
const result = await this.ctx
|
|
340
349
|
?.callTool({
|
|
341
350
|
id: `gc-unsub-${this.callSeq++}`,
|
|
342
351
|
name: 'channel_close',
|
|
343
|
-
input: {
|
|
352
|
+
input: {
|
|
353
|
+
channelId,
|
|
354
|
+
serverId: this.serverId,
|
|
355
|
+
source: 'subscription-gc',
|
|
356
|
+
overrideExplicitOpen: hasConfiguredLease,
|
|
357
|
+
},
|
|
344
358
|
})
|
|
345
359
|
.catch((err: unknown) => ({
|
|
346
360
|
success: false,
|
|
@@ -349,6 +363,34 @@ export class SubscriptionGcModule implements Module {
|
|
|
349
363
|
}));
|
|
350
364
|
|
|
351
365
|
if (result && result.success) {
|
|
366
|
+
// Operator-side receipt (privacy-minimal: ids and thresholds, no
|
|
367
|
+
// content) — a GC close changes durable listening state and must
|
|
368
|
+
// not look spontaneous from outside the transcript. Duck-typed
|
|
369
|
+
// against a framework that may not have ModuleContext.notifyOps yet
|
|
370
|
+
// (skipped there), and invoked THROUGH the context object: the real
|
|
371
|
+
// ModuleContextImpl.notifyOps reads `this`, so a detached
|
|
372
|
+
// `const f = ctx.notifyOps; f(...)` throws in production while
|
|
373
|
+
// passing against arrow-function mocks.
|
|
374
|
+
const opsCtx = this.ctx as unknown as {
|
|
375
|
+
notifyOps?: (kind: string, agent: string, message: string, data?: Record<string, unknown>) => void;
|
|
376
|
+
} | null;
|
|
377
|
+
opsCtx?.notifyOps?.(
|
|
378
|
+
'subscription-gc-close',
|
|
379
|
+
this.ctx?.getAgents()[0]?.name ?? 'unknown',
|
|
380
|
+
`subscription-gc auto-closed channel ${channelId} (over ${limit} ambient chars ` +
|
|
381
|
+
`since last activation${hasConfiguredLease ? ', configured per-channel budget' : ', default budget'}). ` +
|
|
382
|
+
`Restore: channel_open ${channelId}, or agent_settings channel_idle_limits.`,
|
|
383
|
+
{
|
|
384
|
+
channelId,
|
|
385
|
+
limitChars: limit,
|
|
386
|
+
decisionSource: 'subscription-gc',
|
|
387
|
+
// 'configured-budget' deliberately does NOT claim an actor: the
|
|
388
|
+
// override state records no provenance (agent, operator, or
|
|
389
|
+
// imported are all possible).
|
|
390
|
+
lease: hasConfiguredLease ? 'configured-budget' : 'default',
|
|
391
|
+
restore: `channel_open ${channelId}`,
|
|
392
|
+
},
|
|
393
|
+
);
|
|
352
394
|
return {
|
|
353
395
|
addMessages: [
|
|
354
396
|
{
|
|
@@ -367,6 +409,17 @@ export class SubscriptionGcModule implements Module {
|
|
|
367
409
|
],
|
|
368
410
|
};
|
|
369
411
|
}
|
|
412
|
+
|
|
413
|
+
// The registry refused because the channel was explicitly opened and
|
|
414
|
+
// we hold no lease: stand down quietly — the janitor doesn't argue
|
|
415
|
+
// with stated intent. The counter stays cleared, so the next refusal
|
|
416
|
+
// is at least a full budget away; if the channel's provenance later
|
|
417
|
+
// changes (reopened by policy), GC self-heals.
|
|
418
|
+
const refusal = (result as { data?: { refusal?: string } } | undefined)?.data?.refusal;
|
|
419
|
+
if (refusal === 'explicit-open') {
|
|
420
|
+
return {};
|
|
421
|
+
}
|
|
422
|
+
|
|
370
423
|
// Close failed — keep the channel counted so we retry on the next
|
|
371
424
|
// ambient message rather than silently giving up.
|
|
372
425
|
this.state.counters[channelId] = next;
|