@animalabs/agent-framework 0.12.0 → 0.13.0
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/dist/src/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +45 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +606 -102
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +160 -4
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +7 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +3 -0
- package/src/framework.ts +662 -100
- package/src/gate/event-gate.ts +28 -3
- package/src/mcpl/channel-registry.ts +235 -7
- package/src/modules/workspace/index.ts +5 -1
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +8 -0
- package/src/types/framework.ts +7 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TuneOutCoordinator (issue #77) — the host-side machinery between a
|
|
3
|
+
* tuned-out channel and the subconscious resident.
|
|
4
|
+
*
|
|
5
|
+
* Responsibilities: classify diverted traffic (stamp everything, wake on
|
|
6
|
+
* addressed), acknowledge suppressed mentions deterministically, run the
|
|
7
|
+
* per-channel summary cadence, count wakes durably with max-wakes
|
|
8
|
+
* auto-cancel, and deliver the cancel dump.
|
|
9
|
+
*
|
|
10
|
+
* Voice doctrine ("no words in models' mouths"): everything the resident
|
|
11
|
+
* reads from the subconscious is the subconscious's verbatim text under its
|
|
12
|
+
* own participant name. Host-authored framing is system-styled and never
|
|
13
|
+
* voiced: `<tuned-out-backlog …>` wraps raw diverted messages (the
|
|
14
|
+
* <backscroll> precedent), and `[Tune-out: …]` bracket notices carry
|
|
15
|
+
* lifecycle facts (the [Gate: …] precedent).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { randomUUID } from 'node:crypto';
|
|
19
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
20
|
+
import type { StoredMessage } from '@animalabs/context-manager';
|
|
21
|
+
import type { ChannelRegistry, TuneOutParams } from '../mcpl/channel-registry.js';
|
|
22
|
+
import type { McplServerRegistry } from '../mcpl/server-registry.js';
|
|
23
|
+
import { CapabilityGrant } from '../mcpl/capability-grant.js';
|
|
24
|
+
|
|
25
|
+
/** Defaults for tune_out tool params. */
|
|
26
|
+
export const TUNE_OUT_DEFAULTS = {
|
|
27
|
+
cadenceSeconds: 1800,
|
|
28
|
+
backlogCap: 200,
|
|
29
|
+
maxWakes: 5,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
/** Coalescing window for wake invocations: a mention storm becomes one
|
|
33
|
+
* subconscious turn, and max-wakes counts invocations, not messages. */
|
|
34
|
+
const WAKE_COALESCE_MS = 2_000;
|
|
35
|
+
|
|
36
|
+
export interface TuneOutFrameworkHooks {
|
|
37
|
+
/** Deliver a message into an agent's window (per-agent seam). */
|
|
38
|
+
addMessage: (
|
|
39
|
+
participant: string,
|
|
40
|
+
content: ContentBlock[],
|
|
41
|
+
metadata?: Record<string, unknown>,
|
|
42
|
+
forAgent?: string,
|
|
43
|
+
) => string;
|
|
44
|
+
/** Queue an inference request for an agent. */
|
|
45
|
+
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
46
|
+
/** The subconscious's registry/participant name. */
|
|
47
|
+
subconsciousName: () => string | null;
|
|
48
|
+
/** The primary resident's registry name. */
|
|
49
|
+
primaryName: () => string | null;
|
|
50
|
+
/** Read the primary's stored messages (shared slot, unfiltered). */
|
|
51
|
+
getStoredMessages: () => StoredMessage[];
|
|
52
|
+
/** Current chronicle head sequence (window anchor for new epochs). */
|
|
53
|
+
currentSequence: () => number;
|
|
54
|
+
/** Move the subconscious window anchor (never forward past active epochs). */
|
|
55
|
+
setSubconsciousAnchor: (sequence: number) => void;
|
|
56
|
+
/** True when a channel is bound to a conversation-fork agent. */
|
|
57
|
+
isForkBound: (channelId: string) => boolean;
|
|
58
|
+
/** Gate privileged-users list (#77: privileged authors wake, like mentions). */
|
|
59
|
+
isPrivilegedAuthor: (authorId: string | null | undefined) => boolean;
|
|
60
|
+
/** speak_in_channel master switch (recipe-side, canary-gated). */
|
|
61
|
+
allowChannelSpeech: () => boolean;
|
|
62
|
+
emitTrace: (event: { type: string; [key: string]: unknown }) => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class TuneOutCoordinator {
|
|
66
|
+
private cadenceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
67
|
+
/** Duration-expiry timers (params.expiresAtMs); cleared on any cancel. */
|
|
68
|
+
private expiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
69
|
+
/** Channels with a wake invocation pending inside the coalescing window. */
|
|
70
|
+
private pendingWakes = new Map<string, { count: number; timer: ReturnType<typeof setTimeout> }>();
|
|
71
|
+
/** Diverted-message tallies since the last subconscious invocation, per channel key. */
|
|
72
|
+
private divertedSinceInvocation = new Map<string, number>();
|
|
73
|
+
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly channelRegistry: ChannelRegistry,
|
|
76
|
+
private readonly serverRegistry: McplServerRegistry,
|
|
77
|
+
private readonly hooks: TuneOutFrameworkHooks,
|
|
78
|
+
) {}
|
|
79
|
+
|
|
80
|
+
private key(serverId: string, channelId: string): string {
|
|
81
|
+
return `${serverId}\0${channelId}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Restart cadence + expiry timers and re-anchor after a process restart. */
|
|
85
|
+
resumeActiveEpochs(): void {
|
|
86
|
+
let oldestAnchor: number | null = null;
|
|
87
|
+
for (const { serverId, channelId, params } of this.listActive()) {
|
|
88
|
+
// An epoch whose deadline passed while the process was down cancels
|
|
89
|
+
// immediately — the resident gets their backlog on the schedule they
|
|
90
|
+
// committed to, not whenever the host happened to restart.
|
|
91
|
+
if (params.expiresAtMs !== undefined && params.expiresAtMs <= Date.now()) {
|
|
92
|
+
this.cancel(serverId, channelId, 'duration', 'duration elapsed');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
this.startCadence(serverId, channelId, params.cadenceSeconds);
|
|
96
|
+
this.armExpiry(serverId, channelId, params);
|
|
97
|
+
if (oldestAnchor === null || params.startedAtSequence < oldestAnchor) {
|
|
98
|
+
oldestAnchor = params.startedAtSequence;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (oldestAnchor !== null) {
|
|
102
|
+
this.hooks.setSubconsciousAnchor(oldestAnchor);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private armExpiry(serverId: string, channelId: string, params: TuneOutParams): void {
|
|
107
|
+
if (params.expiresAtMs === undefined) return;
|
|
108
|
+
const key = this.key(serverId, channelId);
|
|
109
|
+
const existing = this.expiryTimers.get(key);
|
|
110
|
+
if (existing) clearTimeout(existing);
|
|
111
|
+
const remaining = Math.max(0, params.expiresAtMs - Date.now());
|
|
112
|
+
this.expiryTimers.set(key, setTimeout(() => {
|
|
113
|
+
this.expiryTimers.delete(key);
|
|
114
|
+
this.cancel(serverId, channelId, 'duration', 'duration elapsed');
|
|
115
|
+
}, remaining));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private listActive(): Array<{ serverId: string; channelId: string; params: TuneOutParams }> {
|
|
119
|
+
const active: Array<{ serverId: string; channelId: string; params: TuneOutParams }> = [];
|
|
120
|
+
for (const entry of this.channelRegistry.listChannelsRaw()) {
|
|
121
|
+
const state = this.channelRegistry.getTuneOutState(entry.serverId, entry.descriptor.id);
|
|
122
|
+
if (state) {
|
|
123
|
+
active.push({ serverId: entry.serverId, channelId: entry.descriptor.id, params: state.params });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return active;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ==========================================================================
|
|
130
|
+
// Enter / cancel
|
|
131
|
+
// ==========================================================================
|
|
132
|
+
|
|
133
|
+
enter(
|
|
134
|
+
serverId: string,
|
|
135
|
+
channelId: string,
|
|
136
|
+
opts: { cadenceSeconds?: number; backlogCap?: number; maxWakes?: number; durationSeconds?: number },
|
|
137
|
+
source: string,
|
|
138
|
+
): { ok: true; params: TuneOutParams } | { ok: false; error: string } {
|
|
139
|
+
if (!this.hooks.subconsciousName()) {
|
|
140
|
+
return { ok: false, error: 'tune-out requires the subconscious resident (framework config `subconscious`)' };
|
|
141
|
+
}
|
|
142
|
+
if (this.hooks.isForkBound(channelId)) {
|
|
143
|
+
return { ok: false, error: `channel ${channelId} is bound to a conversation fork; tune-out and fork routing are mutually exclusive` };
|
|
144
|
+
}
|
|
145
|
+
if (this.channelRegistry.getTuneOutState(serverId, channelId)) {
|
|
146
|
+
return { ok: false, error: `channel ${channelId} is already tuned out (cancel first to change params)` };
|
|
147
|
+
}
|
|
148
|
+
const params: TuneOutParams = {
|
|
149
|
+
epochId: randomUUID(),
|
|
150
|
+
cadenceSeconds: Math.max(30, opts.cadenceSeconds ?? TUNE_OUT_DEFAULTS.cadenceSeconds),
|
|
151
|
+
backlogCap: Math.max(0, opts.backlogCap ?? TUNE_OUT_DEFAULTS.backlogCap),
|
|
152
|
+
maxWakes: Math.max(1, opts.maxWakes ?? TUNE_OUT_DEFAULTS.maxWakes),
|
|
153
|
+
startedAtSequence: this.hooks.currentSequence(),
|
|
154
|
+
...(opts.durationSeconds !== undefined
|
|
155
|
+
? { expiresAtMs: Date.now() + Math.max(60, opts.durationSeconds) * 1000 }
|
|
156
|
+
: {}),
|
|
157
|
+
};
|
|
158
|
+
this.channelRegistry.enterTuneOut(serverId, channelId, params, source);
|
|
159
|
+
this.armExpiry(serverId, channelId, params);
|
|
160
|
+
|
|
161
|
+
// Window anchor: start of the OLDEST active tune-out. Never move the
|
|
162
|
+
// anchor forward here — an older active epoch owns it.
|
|
163
|
+
const oldest = this.listActive().reduce(
|
|
164
|
+
(min, e) => Math.min(min, e.params.startedAtSequence),
|
|
165
|
+
params.startedAtSequence,
|
|
166
|
+
);
|
|
167
|
+
this.hooks.setSubconsciousAnchor(oldest);
|
|
168
|
+
|
|
169
|
+
this.divertedSinceInvocation.set(this.key(serverId, channelId), 0);
|
|
170
|
+
this.startCadence(serverId, channelId, params.cadenceSeconds);
|
|
171
|
+
this.hooks.emitTrace({ type: 'tune-out:entered', serverId, channelId, epochId: params.epochId });
|
|
172
|
+
return { ok: true, params };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* End a tune-out: durable flip, system-framed backlog dump into the
|
|
177
|
+
* resident's window (capped), lifecycle notice, one wake for the
|
|
178
|
+
* resident. `subconsciousNote` is the subconscious's own text (from its
|
|
179
|
+
* cancel_tuneout call) — delivered verbatim under its name, never
|
|
180
|
+
* synthesized here.
|
|
181
|
+
*/
|
|
182
|
+
cancel(
|
|
183
|
+
serverId: string,
|
|
184
|
+
channelId: string,
|
|
185
|
+
source: string,
|
|
186
|
+
reason: string,
|
|
187
|
+
subconsciousNote?: string,
|
|
188
|
+
): { ok: true } | { ok: false; error: string } {
|
|
189
|
+
const ended = this.channelRegistry.cancelTuneOut(serverId, channelId, 'open', source);
|
|
190
|
+
if (!ended) {
|
|
191
|
+
return { ok: false, error: `channel ${channelId} is not tuned out` };
|
|
192
|
+
}
|
|
193
|
+
this.stopCadence(serverId, channelId);
|
|
194
|
+
const expiry = this.expiryTimers.get(this.key(serverId, channelId));
|
|
195
|
+
if (expiry) {
|
|
196
|
+
clearTimeout(expiry);
|
|
197
|
+
this.expiryTimers.delete(this.key(serverId, channelId));
|
|
198
|
+
}
|
|
199
|
+
const pending = this.pendingWakes.get(this.key(serverId, channelId));
|
|
200
|
+
if (pending) {
|
|
201
|
+
clearTimeout(pending.timer);
|
|
202
|
+
this.pendingWakes.delete(this.key(serverId, channelId));
|
|
203
|
+
}
|
|
204
|
+
this.divertedSinceInvocation.delete(this.key(serverId, channelId));
|
|
205
|
+
|
|
206
|
+
// Backlog: every message stamped with this epoch, oldest first.
|
|
207
|
+
const epochId = ended.params.epochId;
|
|
208
|
+
const backlog = this.hooks.getStoredMessages().filter(
|
|
209
|
+
(m) => (m.metadata as { tuneOut?: { epochId?: string } } | undefined)?.tuneOut?.epochId === epochId,
|
|
210
|
+
);
|
|
211
|
+
const cap = ended.params.backlogCap;
|
|
212
|
+
const shown = cap > 0 ? backlog.slice(-cap) : [];
|
|
213
|
+
const truncated = backlog.length - shown.length;
|
|
214
|
+
|
|
215
|
+
const lines = shown.map((m) => {
|
|
216
|
+
const author = (m.metadata as { author?: { name?: string } } | undefined)?.author?.name ?? m.participant;
|
|
217
|
+
const text = m.content
|
|
218
|
+
.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text')
|
|
219
|
+
.map((b) => b.text)
|
|
220
|
+
.join('\n');
|
|
221
|
+
return `${author}: ${text}`;
|
|
222
|
+
});
|
|
223
|
+
const attrs = [
|
|
224
|
+
`channel="${channelId}"`,
|
|
225
|
+
`messages=${backlog.length}`,
|
|
226
|
+
...(truncated > 0 ? [`truncated=${truncated} (oldest; fetch_history covers the rest)`] : []),
|
|
227
|
+
];
|
|
228
|
+
const dump =
|
|
229
|
+
`<tuned-out-backlog ${attrs.join(' ')}>\n${lines.join('\n')}\n</tuned-out-backlog>`;
|
|
230
|
+
|
|
231
|
+
// Host-framed system artifacts — never voiced as anyone.
|
|
232
|
+
this.hooks.addMessage(
|
|
233
|
+
'user',
|
|
234
|
+
[{ type: 'text', text: `[Tune-out: ${channelId} cancelled — ${reason}, ${ended.wakeCount} wake${ended.wakeCount === 1 ? '' : 's'}]\n\n${dump}` }],
|
|
235
|
+
{ system: true, kind: 'tune-out-cancel', channelId, epochId },
|
|
236
|
+
);
|
|
237
|
+
// The subconscious's own words, under its own name, verbatim.
|
|
238
|
+
const subName = this.hooks.subconsciousName();
|
|
239
|
+
if (subconsciousNote && subName) {
|
|
240
|
+
this.hooks.addMessage(
|
|
241
|
+
subName,
|
|
242
|
+
[{ type: 'text', text: subconsciousNote }],
|
|
243
|
+
{ kind: 'subconscious-note', channelId, epochId },
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const primary = this.hooks.primaryName();
|
|
247
|
+
if (primary) {
|
|
248
|
+
this.hooks.requestInference(primary, `tune-out cancelled (${reason})`, 'tune-out');
|
|
249
|
+
}
|
|
250
|
+
// The subconscious gets one cancel turn of its own (unless it initiated
|
|
251
|
+
// the cancel — its note already carries its words): the issue's cancel
|
|
252
|
+
// shape is dump + a report from the subconscious, and when the cap
|
|
253
|
+
// truncated the raw dump, its summary is what covers the tail.
|
|
254
|
+
if (source !== 'subconscious' && subName) {
|
|
255
|
+
const truncNote = truncated > 0
|
|
256
|
+
? ` ${truncated} of ${backlog.length} diverted messages were above the cap and not delivered raw.`
|
|
257
|
+
: '';
|
|
258
|
+
this.hooks.addMessage(
|
|
259
|
+
'user',
|
|
260
|
+
[{ type: 'text', text: `[Tune-out cancelled: ${channelId} — ${reason}.${truncNote}]` }],
|
|
261
|
+
{ system: true, kind: 'tune-out-cancel-report', channelId },
|
|
262
|
+
subName,
|
|
263
|
+
);
|
|
264
|
+
this.hooks.requestInference(subName, `tune-out cancelled: ${channelId}`, 'tune-out');
|
|
265
|
+
}
|
|
266
|
+
this.hooks.emitTrace({ type: 'tune-out:cancelled', serverId, channelId, epochId, reason, wakeCount: ended.wakeCount });
|
|
267
|
+
return { ok: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ==========================================================================
|
|
271
|
+
// Ingestion: divert, stamp, classify
|
|
272
|
+
// ==========================================================================
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Consulted by the framework for every incoming channel message BEFORE
|
|
276
|
+
* storage. Null = channel not tuned out (proceed normally). Otherwise the
|
|
277
|
+
* caller stamps `metadata.tuneOut = { epochId }`, stores the message
|
|
278
|
+
* without waking the residents, and this coordinator has already handled
|
|
279
|
+
* wake bookkeeping (coalesced subconscious invocation, durable count,
|
|
280
|
+
* suppression ack, possible auto-cancel).
|
|
281
|
+
*/
|
|
282
|
+
onIncoming(
|
|
283
|
+
serverId: string,
|
|
284
|
+
channelId: string,
|
|
285
|
+
messageId: string,
|
|
286
|
+
tags: string[] | undefined,
|
|
287
|
+
authorId?: string,
|
|
288
|
+
gatePass = true,
|
|
289
|
+
): { epochId: string } | null {
|
|
290
|
+
const state = this.channelRegistry.getTuneOutState(serverId, channelId);
|
|
291
|
+
if (!state) return null;
|
|
292
|
+
|
|
293
|
+
const key = this.key(serverId, channelId);
|
|
294
|
+
this.divertedSinceInvocation.set(key, (this.divertedSinceInvocation.get(key) ?? 0) + 1);
|
|
295
|
+
|
|
296
|
+
// Wake classification: the resident's wake gate composes as a
|
|
297
|
+
// PRECONDITION (antra, #tuneout-talk: the subconscious is affected BY
|
|
298
|
+
// main's wake gate, it does not replace it) — an event the gate would
|
|
299
|
+
// suppress for the resident (muted author, rate-limit) must not wake
|
|
300
|
+
// the subconscious either; it diverts silently into the backlog and
|
|
301
|
+
// surfaces in cadence summaries. On top of the gate: addressed
|
|
302
|
+
// mentions and gate-privileged authors wake, per the issue.
|
|
303
|
+
const addressed = Array.isArray(tags) && tags.includes('chat:addressed');
|
|
304
|
+
const privileged = this.hooks.isPrivilegedAuthor(authorId);
|
|
305
|
+
if (gatePass && addressed) {
|
|
306
|
+
// The mention-er always gets a deterministic signal, independent of
|
|
307
|
+
// what the subconscious later decides (grant-gated; decline
|
|
308
|
+
// precedent). Gate-suppressed mentions get NO reaction: main would
|
|
309
|
+
// not have signaled either, and reacting would leak the resident's
|
|
310
|
+
// standing mute to its subject.
|
|
311
|
+
void this.acknowledgeSuppressed(serverId, channelId, messageId);
|
|
312
|
+
}
|
|
313
|
+
if (gatePass && (addressed || privileged)) {
|
|
314
|
+
this.scheduleWakeInvocation(serverId, channelId);
|
|
315
|
+
}
|
|
316
|
+
return { epochId: state.params.epochId };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async acknowledgeSuppressed(serverId: string, channelId: string, messageId: string): Promise<void> {
|
|
320
|
+
try {
|
|
321
|
+
const server = this.serverRegistry.getServer(serverId);
|
|
322
|
+
if (!server || !CapabilityGrant.of(server).has('channels.acknowledge')) return;
|
|
323
|
+
await server.sendChannelsAcknowledge({
|
|
324
|
+
channelId,
|
|
325
|
+
messageId,
|
|
326
|
+
intent: 'suppressed-tuned-out',
|
|
327
|
+
});
|
|
328
|
+
} catch (err) {
|
|
329
|
+
this.hooks.emitTrace({
|
|
330
|
+
type: 'tune-out:ack-failed',
|
|
331
|
+
serverId,
|
|
332
|
+
channelId,
|
|
333
|
+
error: (err as Error).message,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ==========================================================================
|
|
339
|
+
// Subconscious invocation (cadence + coalesced wakes)
|
|
340
|
+
// ==========================================================================
|
|
341
|
+
|
|
342
|
+
private startCadence(serverId: string, channelId: string, cadenceSeconds: number): void {
|
|
343
|
+
this.stopCadence(serverId, channelId);
|
|
344
|
+
const key = this.key(serverId, channelId);
|
|
345
|
+
const tick = () => {
|
|
346
|
+
const state = this.channelRegistry.getTuneOutState(serverId, channelId);
|
|
347
|
+
if (!state) return; // cancelled meanwhile
|
|
348
|
+
const diverted = this.divertedSinceInvocation.get(key) ?? 0;
|
|
349
|
+
// An empty period needs no turn at all — skip silently.
|
|
350
|
+
if (diverted > 0) {
|
|
351
|
+
this.invokeSubconscious(serverId, channelId, 'cadence', diverted);
|
|
352
|
+
}
|
|
353
|
+
this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
|
|
354
|
+
};
|
|
355
|
+
this.cadenceTimers.set(key, setTimeout(tick, cadenceSeconds * 1000));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
private stopCadence(serverId: string, channelId: string): void {
|
|
359
|
+
const key = this.key(serverId, channelId);
|
|
360
|
+
const timer = this.cadenceTimers.get(key);
|
|
361
|
+
if (timer) clearTimeout(timer);
|
|
362
|
+
this.cadenceTimers.delete(key);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private scheduleWakeInvocation(serverId: string, channelId: string): void {
|
|
366
|
+
const key = this.key(serverId, channelId);
|
|
367
|
+
const pending = this.pendingWakes.get(key);
|
|
368
|
+
if (pending) {
|
|
369
|
+
pending.count++;
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const timer = setTimeout(() => {
|
|
373
|
+
const entry = this.pendingWakes.get(key);
|
|
374
|
+
this.pendingWakes.delete(key);
|
|
375
|
+
// Count ONE durable wake per coalesced invocation.
|
|
376
|
+
const counted = this.channelRegistry.recordTuneOutWake(serverId, channelId);
|
|
377
|
+
if (!counted) return; // cancelled inside the window
|
|
378
|
+
if (counted.wakeCount > counted.params.maxWakes) {
|
|
379
|
+
this.cancel(
|
|
380
|
+
serverId,
|
|
381
|
+
channelId,
|
|
382
|
+
'tune-out-max-wakes',
|
|
383
|
+
`wake budget exhausted (${counted.params.maxWakes})`,
|
|
384
|
+
);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
this.invokeSubconscious(serverId, channelId, 'wake', entry?.count ?? 1);
|
|
388
|
+
}, WAKE_COALESCE_MS);
|
|
389
|
+
this.pendingWakes.set(key, { count: 1, timer });
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* One subconscious turn: a system-framed trigger notice (bracket style —
|
|
394
|
+
* what happened, never what to say) plus standing dispositions, then an
|
|
395
|
+
* inference request. The merged view supplies the actual traffic.
|
|
396
|
+
*/
|
|
397
|
+
private invokeSubconscious(
|
|
398
|
+
serverId: string,
|
|
399
|
+
channelId: string,
|
|
400
|
+
trigger: 'cadence' | 'wake',
|
|
401
|
+
count: number,
|
|
402
|
+
): void {
|
|
403
|
+
const subName = this.hooks.subconsciousName();
|
|
404
|
+
if (!subName) return;
|
|
405
|
+
const key = this.key(serverId, channelId);
|
|
406
|
+
this.divertedSinceInvocation.set(key, 0);
|
|
407
|
+
|
|
408
|
+
const notice = trigger === 'wake'
|
|
409
|
+
? `[Tune-out wake: ${channelId} — ${count} addressed message${count === 1 ? '' : 's'}]`
|
|
410
|
+
: `[Tune-out cadence: ${channelId} — ${count} diverted message${count === 1 ? '' : 's'} since your last look]`;
|
|
411
|
+
this.hooks.addMessage(
|
|
412
|
+
'user',
|
|
413
|
+
[{ type: 'text', text: notice }],
|
|
414
|
+
{ system: true, kind: `tune-out-${trigger}`, channelId },
|
|
415
|
+
subName,
|
|
416
|
+
);
|
|
417
|
+
this.hooks.requestInference(subName, `tune-out ${trigger}: ${channelId}`, 'tune-out');
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ==========================================================================
|
|
421
|
+
// Dispositions (standing notes, snapshot slot)
|
|
422
|
+
// ==========================================================================
|
|
423
|
+
|
|
424
|
+
private dispositionsCache: Record<string, string> | null = null;
|
|
425
|
+
|
|
426
|
+
private get dispositionsStateId(): string {
|
|
427
|
+
return `subconscious/${this.hooks.primaryName() ?? 'default'}/dispositions`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The dispositions as a fixed system-position ContextInjection for the
|
|
432
|
+
* subconscious's compiles (antra: deliver changes, never let them wash
|
|
433
|
+
* away — it has no long-term memory, so they must not live in window
|
|
434
|
+
* content). Bytes change only when a disposition changes: KV-friendly.
|
|
435
|
+
*/
|
|
436
|
+
getDispositionsInjection(): {
|
|
437
|
+
namespace: string;
|
|
438
|
+
position: 'system';
|
|
439
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
440
|
+
} | null {
|
|
441
|
+
const dispositions = this.readDispositions();
|
|
442
|
+
const entries = Object.entries(dispositions);
|
|
443
|
+
if (entries.length === 0) return null;
|
|
444
|
+
return {
|
|
445
|
+
namespace: 'dispositions',
|
|
446
|
+
position: 'system',
|
|
447
|
+
content: [{
|
|
448
|
+
type: 'text',
|
|
449
|
+
text: `[Standing dispositions]\n${entries.map(([k, v]) => `- ${k}: ${v}`).join('\n')}`,
|
|
450
|
+
}],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
private readDispositions(): Record<string, string> {
|
|
455
|
+
if (this.dispositionsCache) return this.dispositionsCache;
|
|
456
|
+
try {
|
|
457
|
+
const raw = this.channelRegistry.readCoordinatorState(this.dispositionsStateId);
|
|
458
|
+
this.dispositionsCache = (raw && typeof raw === 'object') ? raw as Record<string, string> : {};
|
|
459
|
+
} catch {
|
|
460
|
+
this.dispositionsCache = {};
|
|
461
|
+
}
|
|
462
|
+
return this.dispositionsCache;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
noteDisposition(key: string, text: string): void {
|
|
466
|
+
const dispositions = { ...this.readDispositions() };
|
|
467
|
+
if (text === '') {
|
|
468
|
+
delete dispositions[key];
|
|
469
|
+
} else {
|
|
470
|
+
dispositions[key] = text;
|
|
471
|
+
}
|
|
472
|
+
this.dispositionsCache = dispositions;
|
|
473
|
+
this.channelRegistry.writeCoordinatorState(this.dispositionsStateId, dispositions);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ==========================================================================
|
|
477
|
+
// Subconscious tool dispatch
|
|
478
|
+
// ==========================================================================
|
|
479
|
+
|
|
480
|
+
async handleSubconsciousTool(
|
|
481
|
+
name: string,
|
|
482
|
+
input: Record<string, unknown>,
|
|
483
|
+
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
484
|
+
switch (name) {
|
|
485
|
+
case 'deliver_summary': {
|
|
486
|
+
const text = String(input.text ?? '').trim();
|
|
487
|
+
const channelId = String(input.channelId ?? '');
|
|
488
|
+
if (!text) return { success: false, error: 'empty summary', isError: false };
|
|
489
|
+
const subName = this.hooks.subconsciousName();
|
|
490
|
+
if (!subName) return { success: false, error: 'subconscious not configured', isError: true };
|
|
491
|
+
// Verbatim, under its own participant name, into the resident's window.
|
|
492
|
+
this.hooks.addMessage(subName, [{ type: 'text', text }], {
|
|
493
|
+
kind: 'subconscious-summary',
|
|
494
|
+
channelId,
|
|
495
|
+
});
|
|
496
|
+
return { success: true, data: { delivered: true } };
|
|
497
|
+
}
|
|
498
|
+
case 'cancel_tuneout': {
|
|
499
|
+
const channelId = String(input.channelId ?? '');
|
|
500
|
+
const located = this.findByChannel(channelId);
|
|
501
|
+
if (!located) return { success: false, error: `channel ${channelId} is not tuned out`, isError: false };
|
|
502
|
+
const note = typeof input.text === 'string' && input.text.trim() ? input.text.trim() : undefined;
|
|
503
|
+
const result = this.cancel(located.serverId, channelId, 'subconscious', 'subconscious judgment', note);
|
|
504
|
+
return result.ok
|
|
505
|
+
? { success: true, data: { cancelled: true } }
|
|
506
|
+
: { success: false, error: result.error, isError: false };
|
|
507
|
+
}
|
|
508
|
+
case 'note_disposition': {
|
|
509
|
+
const key = String(input.key ?? '').trim();
|
|
510
|
+
if (!key) return { success: false, error: 'key required', isError: false };
|
|
511
|
+
this.noteDisposition(key, String(input.text ?? ''));
|
|
512
|
+
return { success: true, data: { noted: key } };
|
|
513
|
+
}
|
|
514
|
+
case 'speak_in_channel': {
|
|
515
|
+
if (!this.hooks.allowChannelSpeech()) {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
isError: false,
|
|
519
|
+
error: 'channel speech is disabled for the subconscious (recipe: subconscious.allowChannelSpeech)',
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const channelId = String(input.channelId ?? '');
|
|
523
|
+
const text = String(input.text ?? '').trim();
|
|
524
|
+
if (!text) return { success: false, error: 'empty message', isError: false };
|
|
525
|
+
return this.channelRegistry.publishForAgent(channelId, text, this.hooks.subconsciousName() ?? 'Subconscious');
|
|
526
|
+
}
|
|
527
|
+
default:
|
|
528
|
+
return { success: false, error: `unknown subconscious tool: ${name}`, isError: true };
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private findByChannel(channelId: string): { serverId: string } | null {
|
|
533
|
+
for (const entry of this.listActive()) {
|
|
534
|
+
if (entry.channelId === channelId) return { serverId: entry.serverId };
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Stop all timers (framework shutdown). */
|
|
540
|
+
stop(): void {
|
|
541
|
+
for (const timer of this.cadenceTimers.values()) clearTimeout(timer);
|
|
542
|
+
this.cadenceTimers.clear();
|
|
543
|
+
for (const timer of this.expiryTimers.values()) clearTimeout(timer);
|
|
544
|
+
this.expiryTimers.clear();
|
|
545
|
+
for (const { timer } of this.pendingWakes.values()) clearTimeout(timer);
|
|
546
|
+
this.pendingWakes.clear();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The subconscious's tool surface (issue #77).
|
|
3
|
+
*
|
|
4
|
+
* Deliberately small and channel-scoped: the subconscious observes the
|
|
5
|
+
* merged timeline, judges wakes, and reports — it is not a general agent.
|
|
6
|
+
* Names and results carry no credential/config vocabulary; everything it
|
|
7
|
+
* says to the resident is its own text (never host-templated), delivered
|
|
8
|
+
* under its own participant name. Prose is never auto-routed
|
|
9
|
+
* (proseRouting: 'explicit' on its AgentConfig): speaking into a channel
|
|
10
|
+
* happens only through speak_in_channel, so a timer-triggered turn can
|
|
11
|
+
* never leak bare prose to the default locus.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ToolDefinition } from '../types/index.js';
|
|
15
|
+
|
|
16
|
+
export const SUBCONSCIOUS_TOOL_NAMES = [
|
|
17
|
+
'deliver_summary',
|
|
18
|
+
'cancel_tuneout',
|
|
19
|
+
'note_disposition',
|
|
20
|
+
'speak_in_channel',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
export type SubconsciousToolName = (typeof SUBCONSCIOUS_TOOL_NAMES)[number];
|
|
24
|
+
|
|
25
|
+
export const SUBCONSCIOUS_TOOLS: ToolDefinition[] = [
|
|
26
|
+
{
|
|
27
|
+
name: 'deliver_summary',
|
|
28
|
+
description:
|
|
29
|
+
'Deliver a summary into the resident\'s context, in your own voice, ' +
|
|
30
|
+
'addressed to them (second person). Use at cadence when the diverted ' +
|
|
31
|
+
'traffic merits it; staying silent is always allowed — an empty ' +
|
|
32
|
+
'period needs no report.',
|
|
33
|
+
inputSchema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
text: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'The summary, verbatim as the resident will read it.',
|
|
39
|
+
},
|
|
40
|
+
channelId: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'The tuned-out channel this summary covers.',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
required: ['text', 'channelId'],
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'cancel_tuneout',
|
|
50
|
+
description:
|
|
51
|
+
'End the tune-out on a channel now — use when something needs the ' +
|
|
52
|
+
'resident\'s full attention. The diverted backlog is delivered to ' +
|
|
53
|
+
'them (capped), and normal attention resumes. Add your own note in ' +
|
|
54
|
+
'`text`; it arrives alongside the backlog, in your voice.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
channelId: { type: 'string' },
|
|
59
|
+
text: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
description: 'Your accompanying note to the resident (optional).',
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
required: ['channelId'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'note_disposition',
|
|
69
|
+
description:
|
|
70
|
+
'Record a standing disposition for yourself — a durable note that ' +
|
|
71
|
+
'shapes how you treat future traffic ("antra\'s pings always ' +
|
|
72
|
+
'escalate", "ignore release-bot"). Replaces any previous note under ' +
|
|
73
|
+
'the same key.',
|
|
74
|
+
inputSchema: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
properties: {
|
|
77
|
+
key: { type: 'string', description: 'Short stable identifier.' },
|
|
78
|
+
text: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'The disposition. Empty string deletes the key.',
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
required: ['key', 'text'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'speak_in_channel',
|
|
88
|
+
description:
|
|
89
|
+
'Post a message into a tuned-out channel, clearly as yourself (not ' +
|
|
90
|
+
'as the resident). Use sparingly — e.g. to tell someone the resident ' +
|
|
91
|
+
'is tuned out and when to expect them. Disabled unless the resident\'s ' +
|
|
92
|
+
'configuration allows it.',
|
|
93
|
+
inputSchema: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
channelId: { type: 'string' },
|
|
97
|
+
text: { type: 'string' },
|
|
98
|
+
},
|
|
99
|
+
required: ['channelId', 'text'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Configuration for the subconscious resident (FrameworkConfig.subconscious).
|
|
106
|
+
*/
|
|
107
|
+
export interface SubconsciousConfig {
|
|
108
|
+
/** Master switch. */
|
|
109
|
+
enabled: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Registry + participant name. Default 'Subconscious' — following the
|
|
112
|
+
* Context Manager precedent: a title-case functional voice, not an
|
|
113
|
+
* agent-prefixed identifier.
|
|
114
|
+
*/
|
|
115
|
+
name?: string;
|
|
116
|
+
/** Model id; defaults to the primary agent's (same-model side-process). */
|
|
117
|
+
model?: string;
|
|
118
|
+
/**
|
|
119
|
+
* The voice/criteria mode block — recipe-side and co-authored with the
|
|
120
|
+
* resident (it is an aspect of their attention). Report-shaped, second
|
|
121
|
+
* person toward the resident. Canary before fleet use (issue #77).
|
|
122
|
+
*/
|
|
123
|
+
systemPrompt: string;
|
|
124
|
+
/** Allow speak_in_channel. Default false until the voice block has
|
|
125
|
+
* passed its canary round. */
|
|
126
|
+
allowChannelSpeech?: boolean;
|
|
127
|
+
/** WindowedPassthroughStrategy re-anchor fraction (default 0.5). */
|
|
128
|
+
reAnchorFraction?: number;
|
|
129
|
+
}
|
package/src/types/agent.ts
CHANGED
|
@@ -184,6 +184,14 @@ export interface AgentConfig {
|
|
|
184
184
|
* send externally; prose remains in Chronicle with a private suppression receipt.
|
|
185
185
|
*/
|
|
186
186
|
proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fail-closed containment for a text response whose entire visible prose is
|
|
190
|
+
* an invocation-shaped wrapper naming a tool registered on that exact turn.
|
|
191
|
+
* The wrapper is neither executed nor stored as assistant continuity; a
|
|
192
|
+
* content-free system receipt is stored instead. Default false.
|
|
193
|
+
*/
|
|
194
|
+
toolWrapperProseGuard?: boolean;
|
|
187
195
|
}
|
|
188
196
|
|
|
189
197
|
/** The intentionally small set of agent settings that may change in-process. */
|