@animalabs/agent-framework 0.6.6 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/dist/src/agent.d.ts +5 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +35 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/mcp-server.js +0 -0
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +7 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +91 -3
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +988 -82
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +9 -2
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +3 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +29 -15
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +36 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +142 -28
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +12 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +661 -2
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +4 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
- package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
- package/dist/src/recovery/discord-awareness-outbox.js +334 -0
- package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
- package/dist/src/recovery/offline-branch.d.ts +45 -0
- package/dist/src/recovery/offline-branch.d.ts.map +1 -0
- package/dist/src/recovery/offline-branch.js +362 -0
- package/dist/src/recovery/offline-branch.js.map +1 -0
- package/dist/src/recovery/recover-cli.d.ts +3 -0
- package/dist/src/recovery/recover-cli.d.ts.map +1 -0
- package/dist/src/recovery/recover-cli.js +129 -0
- package/dist/src/recovery/recover-cli.js.map +1 -0
- package/dist/src/types/agent.d.ts +10 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +14 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +31 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +200 -93
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
- package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
- package/dist/test/busy-agent-scheduler.test.js +62 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
- package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
- package/dist/test/channel-lifecycle-provenance.test.js +47 -0
- package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
- package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
- package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
- package/dist/test/discord-awareness-outbox.test.js +248 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -0
- package/dist/test/maintenance-timer.test.js +100 -0
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
- package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +618 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
- package/dist/test/mcpl-request-timeout.test.js +15 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -1
- package/dist/test/offline-recovery-branch.test.d.ts +2 -0
- package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
- package/dist/test/offline-recovery-branch.test.js +226 -0
- package/dist/test/offline-recovery-branch.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +3 -2
- package/dist/test/present-while-acting.test.d.ts.map +1 -1
- package/dist/test/present-while-acting.test.js +31 -3
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/refusal-single-call.test.d.ts +2 -0
- package/dist/test/refusal-single-call.test.d.ts.map +1 -0
- package/dist/test/refusal-single-call.test.js +177 -0
- package/dist/test/refusal-single-call.test.js.map +1 -0
- package/dist/test/same-round-think-policy.test.d.ts +2 -0
- package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
- package/dist/test/same-round-think-policy.test.js +853 -0
- package/dist/test/same-round-think-policy.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.d.ts +2 -0
- package/dist/test/workspace-read-image.test.d.ts.map +1 -0
- package/dist/test/workspace-read-image.test.js +518 -0
- package/dist/test/workspace-read-image.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -3
- package/src/agent.ts +39 -2
- package/src/api/server.ts +8 -2
- package/src/framework.ts +1180 -79
- package/src/gate/event-gate.ts +15 -3
- package/src/index.ts +20 -0
- package/src/mcpl/channel-registry.ts +31 -16
- package/src/mcpl/server-connection.ts +163 -28
- package/src/modules/workspace/index.ts +759 -2
- package/src/modules/workspace/types.ts +5 -0
- package/src/recovery/discord-awareness-outbox.ts +459 -0
- package/src/recovery/offline-branch.ts +510 -0
- package/src/recovery/recover-cli.ts +130 -0
- package/src/types/agent.ts +14 -0
- package/src/types/framework.ts +17 -0
- package/src/types/module.ts +33 -0
- package/src/types/trace.ts +2 -0
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import { JsStore } from '@animalabs/chronicle';
|
|
2
|
+
import { ContextManager } from '@animalabs/context-manager';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_DISCORD_AWARENESS_EMOJI,
|
|
5
|
+
DiscordAwarenessOutbox,
|
|
6
|
+
defaultDiscordAwarenessOutboxPath,
|
|
7
|
+
extractDiscordAwarenessRefs,
|
|
8
|
+
type DiscordAwarenessRef,
|
|
9
|
+
} from './discord-awareness-outbox.js';
|
|
10
|
+
|
|
11
|
+
export interface OfflineRecoveryBranchOptions {
|
|
12
|
+
storePath: string;
|
|
13
|
+
agentName: string;
|
|
14
|
+
/** Preferred: Discord message that remains as the last visible anchor. */
|
|
15
|
+
messageId?: string;
|
|
16
|
+
/** Exact internal ContextManager message ID that remains as the anchor. */
|
|
17
|
+
contextId?: string;
|
|
18
|
+
/** Backward-compatible alternative: remove the newest N context entries. */
|
|
19
|
+
messages?: number;
|
|
20
|
+
/** Exact Discord messages to suppress on the new branch. */
|
|
21
|
+
suppressMessageIds?: string[];
|
|
22
|
+
/** Inclusive context-order ranges between Discord message endpoints. */
|
|
23
|
+
suppressRanges?: Array<{ fromMessageId: string; toMessageId: string }>;
|
|
24
|
+
branchName?: string;
|
|
25
|
+
/** Defaults to the primary-agent namespace `agents/{agentName}`. */
|
|
26
|
+
namespace?: string;
|
|
27
|
+
/** Use for old message records that do not carry metadata.serverId. */
|
|
28
|
+
discordServerId?: string;
|
|
29
|
+
outboxPath?: string;
|
|
30
|
+
emoji?: string;
|
|
31
|
+
dryRun?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface OfflineRecoveryBranchResult {
|
|
35
|
+
dryRun: boolean;
|
|
36
|
+
sourceBranch: string;
|
|
37
|
+
targetBranch: string;
|
|
38
|
+
messagesRemoved: number;
|
|
39
|
+
messagesSuppressed: number;
|
|
40
|
+
discordMarkersQueued: number;
|
|
41
|
+
refs: DiscordAwarenessRef[];
|
|
42
|
+
outboxPath: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a safe Chronicle branch while the normal agent host is stopped.
|
|
47
|
+
*
|
|
48
|
+
* History is scanned in bounded windows and blobs are not re-inlined. The
|
|
49
|
+
* affected messages are never compiled and their content is never written to
|
|
50
|
+
* the recovery outbox; only Discord addressing metadata leaves Chronicle.
|
|
51
|
+
*/
|
|
52
|
+
export async function createOfflineRecoveryBranch(
|
|
53
|
+
options: OfflineRecoveryBranchOptions,
|
|
54
|
+
): Promise<OfflineRecoveryBranchResult> {
|
|
55
|
+
const anchorCount = Number(!!options.messageId)
|
|
56
|
+
+ Number(!!options.contextId)
|
|
57
|
+
+ Number(options.messages !== undefined);
|
|
58
|
+
if (anchorCount !== 1) {
|
|
59
|
+
throw new Error('Specify exactly one of messageId, contextId, or messages');
|
|
60
|
+
}
|
|
61
|
+
const count = options.messages === undefined ? undefined : Math.floor(options.messages);
|
|
62
|
+
if (count !== undefined && (!Number.isFinite(count) || count < 1)) {
|
|
63
|
+
throw new Error('messages must be a positive integer');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const store = JsStore.openOrCreate({ path: options.storePath });
|
|
67
|
+
let contextManager: ContextManager | null = null;
|
|
68
|
+
try {
|
|
69
|
+
contextManager = await ContextManager.open({
|
|
70
|
+
store,
|
|
71
|
+
namespace: options.namespace ?? `agents/${options.agentName}`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const total = contextManager.getMessageCount();
|
|
75
|
+
if (count !== undefined && count >= total) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Cannot remove ${count} message(s) — history has ${total}; at least one must remain.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let target: StoredWindowMessage;
|
|
82
|
+
let targetIndex: number;
|
|
83
|
+
let removedCount: number;
|
|
84
|
+
let refs: DiscordAwarenessRef[];
|
|
85
|
+
|
|
86
|
+
if (options.messageId) {
|
|
87
|
+
// Search backward in bounded windows: recovery anchors are normally near
|
|
88
|
+
// the tail, and even a very large Chronicle is never materialized all at
|
|
89
|
+
// once. If an adaptive strategy sharded the Discord message, choosing the
|
|
90
|
+
// newest matching shard keeps the complete message at the branch point.
|
|
91
|
+
const anchorId = normalizeDiscordMessageId(options.messageId);
|
|
92
|
+
const located = findDiscordMessageFromTail(contextManager, total, anchorId);
|
|
93
|
+
if (!located) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Discord message ${anchorId} is not an addressable message in this context.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
target = located.message;
|
|
99
|
+
targetIndex = located.index;
|
|
100
|
+
removedCount = total - located.index - 1;
|
|
101
|
+
refs = collectDiscordRefs(
|
|
102
|
+
contextManager,
|
|
103
|
+
located.index + 1,
|
|
104
|
+
total,
|
|
105
|
+
options.discordServerId,
|
|
106
|
+
);
|
|
107
|
+
} else if (options.contextId) {
|
|
108
|
+
const located = findInternalMessageFromTail(contextManager, total, options.contextId.trim());
|
|
109
|
+
if (!located) {
|
|
110
|
+
throw new Error(`Internal context message ${options.contextId} was not found.`);
|
|
111
|
+
}
|
|
112
|
+
target = located.message;
|
|
113
|
+
targetIndex = located.index;
|
|
114
|
+
removedCount = total - located.index - 1;
|
|
115
|
+
refs = collectDiscordRefs(
|
|
116
|
+
contextManager,
|
|
117
|
+
located.index + 1,
|
|
118
|
+
total,
|
|
119
|
+
options.discordServerId,
|
|
120
|
+
);
|
|
121
|
+
} else {
|
|
122
|
+
// Count mode remains for compatibility. Read only the target plus suffix,
|
|
123
|
+
// avoiding full-history materialization and attachment blob inflation.
|
|
124
|
+
const window = contextManager.getMessageWindow(total - count! - 1, count! + 1, {
|
|
125
|
+
resolveBlobs: false,
|
|
126
|
+
}).messages;
|
|
127
|
+
const countTarget = window[0];
|
|
128
|
+
const discarded = window.slice(1);
|
|
129
|
+
if (!countTarget || discarded.length !== count) {
|
|
130
|
+
throw new Error('Could not read the requested recovery window from Chronicle');
|
|
131
|
+
}
|
|
132
|
+
target = countTarget;
|
|
133
|
+
targetIndex = total - count! - 1;
|
|
134
|
+
removedCount = count!;
|
|
135
|
+
refs = extractDiscordAwarenessRefs(discarded, options.discordServerId);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
validateAnchorBoundary(contextManager, targetIndex, total, target);
|
|
139
|
+
|
|
140
|
+
const suppression = buildSuppressionPlan(
|
|
141
|
+
contextManager,
|
|
142
|
+
targetIndex,
|
|
143
|
+
options.suppressMessageIds ?? [],
|
|
144
|
+
options.suppressRanges ?? [],
|
|
145
|
+
options.discordServerId,
|
|
146
|
+
);
|
|
147
|
+
validateRemovalIntegrity(
|
|
148
|
+
contextManager,
|
|
149
|
+
total,
|
|
150
|
+
[
|
|
151
|
+
...(removedCount > 0 ? [{
|
|
152
|
+
start: targetIndex + 1,
|
|
153
|
+
end: total - 1,
|
|
154
|
+
fromId: '',
|
|
155
|
+
toId: '',
|
|
156
|
+
}] : []),
|
|
157
|
+
...suppression.intervals,
|
|
158
|
+
],
|
|
159
|
+
);
|
|
160
|
+
refs = dedupeDiscordRefs([...refs, ...suppression.refs]);
|
|
161
|
+
|
|
162
|
+
const sourceBranch = store.currentBranch().name;
|
|
163
|
+
const targetBranch = options.branchName
|
|
164
|
+
?? `recovery/${options.agentName}/${Date.now()}`;
|
|
165
|
+
if (targetBranch === sourceBranch) {
|
|
166
|
+
throw new Error('Recovery branch name must differ from the active source branch');
|
|
167
|
+
}
|
|
168
|
+
const outboxPath = options.outboxPath
|
|
169
|
+
?? defaultDiscordAwarenessOutboxPath(options.storePath);
|
|
170
|
+
|
|
171
|
+
const result: OfflineRecoveryBranchResult = {
|
|
172
|
+
dryRun: options.dryRun === true,
|
|
173
|
+
sourceBranch,
|
|
174
|
+
targetBranch,
|
|
175
|
+
messagesRemoved: removedCount,
|
|
176
|
+
messagesSuppressed: suppression.messageCount,
|
|
177
|
+
discordMarkersQueued: refs.length,
|
|
178
|
+
refs,
|
|
179
|
+
outboxPath,
|
|
180
|
+
};
|
|
181
|
+
if (options.dryRun) return result;
|
|
182
|
+
|
|
183
|
+
const outbox = new DiscordAwarenessOutbox(outboxPath);
|
|
184
|
+
const batch = outbox.prepare({
|
|
185
|
+
agentName: options.agentName,
|
|
186
|
+
sourceBranch,
|
|
187
|
+
targetBranch,
|
|
188
|
+
refs,
|
|
189
|
+
emoji: options.emoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
|
|
190
|
+
// Merely seeing targetBranch active does not prove branch-local
|
|
191
|
+
// suppressions finished. Only the recovery operation may activate this
|
|
192
|
+
// batch after every removal below succeeds.
|
|
193
|
+
activationPolicy: suppression.intervals.length > 0 ? 'explicit' : 'target-branch',
|
|
194
|
+
suppressionIntervals: suppression.intervals.map((interval) => ({
|
|
195
|
+
fromId: String(interval.fromId),
|
|
196
|
+
toId: String(interval.toId),
|
|
197
|
+
})),
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// branchAt uses the target message's origin sequence. No message content is
|
|
201
|
+
// compiled or submitted to Membrane during this operation.
|
|
202
|
+
const createdBranch = contextManager.branchAt(target.id, targetBranch);
|
|
203
|
+
try {
|
|
204
|
+
await contextManager.switchBranch(createdBranch);
|
|
205
|
+
// Work from newest to oldest so earlier redactions never perturb the
|
|
206
|
+
// live positions used by later range endpoints.
|
|
207
|
+
for (const interval of [...suppression.intervals].reverse()) {
|
|
208
|
+
if (interval.fromId === interval.toId) contextManager.removeMessage(interval.fromId);
|
|
209
|
+
else contextManager.removeMessages(interval.fromId, interval.toId);
|
|
210
|
+
}
|
|
211
|
+
if (batch) outbox.activate(batch.id);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
// A partially suppressed branch is not safe to boot into. Preserve it
|
|
214
|
+
// for diagnosis, but leave Chronicle on the untouched source branch and
|
|
215
|
+
// keep the explicit outbox batch non-deliverable.
|
|
216
|
+
if (store.currentBranch().name === createdBranch) {
|
|
217
|
+
await contextManager.switchBranch(sourceBranch);
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return result;
|
|
223
|
+
} finally {
|
|
224
|
+
contextManager?.close();
|
|
225
|
+
store.close();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const RECOVERY_SCAN_WINDOW = 1_000;
|
|
230
|
+
type StoredWindowMessage = ReturnType<ContextManager['getMessageWindow']>['messages'][number];
|
|
231
|
+
|
|
232
|
+
interface SuppressionInterval {
|
|
233
|
+
start: number;
|
|
234
|
+
end: number;
|
|
235
|
+
fromId: StoredWindowMessage['id'];
|
|
236
|
+
toId: StoredWindowMessage['id'];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
interface MessageLocation {
|
|
240
|
+
first: number;
|
|
241
|
+
last: number;
|
|
242
|
+
firstId: StoredWindowMessage['id'];
|
|
243
|
+
lastId: StoredWindowMessage['id'];
|
|
244
|
+
/** Usually one run; multiple runs tolerate duplicate external metadata. */
|
|
245
|
+
runs: SuppressionInterval[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function normalizeDiscordMessageId(input: string): string {
|
|
249
|
+
const trimmed = input.trim();
|
|
250
|
+
const link = trimmed.match(/channels\/\d+\/\d+\/(\d+)/);
|
|
251
|
+
return link?.[1] ?? trimmed;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function findDiscordMessageFromTail(
|
|
255
|
+
contextManager: ContextManager,
|
|
256
|
+
total: number,
|
|
257
|
+
messageId: string,
|
|
258
|
+
): { message: ReturnType<ContextManager['getMessageWindow']>['messages'][number]; index: number } | null {
|
|
259
|
+
let end = total;
|
|
260
|
+
while (end > 0) {
|
|
261
|
+
const start = Math.max(0, end - RECOVERY_SCAN_WINDOW);
|
|
262
|
+
const messages = contextManager.getMessageWindow(start, end - start, {
|
|
263
|
+
resolveBlobs: false,
|
|
264
|
+
}).messages;
|
|
265
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
266
|
+
if (String(messages[i].metadata?.messageId ?? '') === messageId) {
|
|
267
|
+
return { message: messages[i], index: start + i };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
end = start;
|
|
271
|
+
}
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function findInternalMessageFromTail(
|
|
276
|
+
contextManager: ContextManager,
|
|
277
|
+
total: number,
|
|
278
|
+
contextId: string,
|
|
279
|
+
): { message: StoredWindowMessage; index: number } | null {
|
|
280
|
+
let end = total;
|
|
281
|
+
while (end > 0) {
|
|
282
|
+
const start = Math.max(0, end - RECOVERY_SCAN_WINDOW);
|
|
283
|
+
const messages = contextManager.getMessageWindow(start, end - start, {
|
|
284
|
+
resolveBlobs: false,
|
|
285
|
+
}).messages;
|
|
286
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
287
|
+
if (String(messages[i].id) === contextId) {
|
|
288
|
+
return { message: messages[i], index: start + i };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
end = start;
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function validateAnchorBoundary(
|
|
297
|
+
contextManager: ContextManager,
|
|
298
|
+
targetIndex: number,
|
|
299
|
+
total: number,
|
|
300
|
+
target: StoredWindowMessage,
|
|
301
|
+
): void {
|
|
302
|
+
const groupId = (target as StoredWindowMessage & { bodyGroupId?: string }).bodyGroupId;
|
|
303
|
+
if (!groupId || targetIndex + 1 >= total) return;
|
|
304
|
+
const next = contextManager.getMessageWindow(targetIndex + 1, 1, {
|
|
305
|
+
resolveBlobs: false,
|
|
306
|
+
}).messages[0] as (StoredWindowMessage & { bodyGroupId?: string }) | undefined;
|
|
307
|
+
if (next?.bodyGroupId === groupId) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Recovery anchor ${String(target.id)} splits body group ${groupId}; choose its final shard.`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function validateRemovalIntegrity(
|
|
315
|
+
contextManager: ContextManager,
|
|
316
|
+
total: number,
|
|
317
|
+
intervals: SuppressionInterval[],
|
|
318
|
+
): void {
|
|
319
|
+
if (intervals.length === 0) return;
|
|
320
|
+
const removed = (index: number) => intervals.some((interval) =>
|
|
321
|
+
index >= interval.start && index <= interval.end);
|
|
322
|
+
const toolUses = new Map<string, boolean>();
|
|
323
|
+
const toolResults = new Map<string, boolean>();
|
|
324
|
+
const groupMembership = new Map<string, boolean>();
|
|
325
|
+
|
|
326
|
+
for (let offset = 0; offset < total; offset += RECOVERY_SCAN_WINDOW) {
|
|
327
|
+
const messages = contextManager.getMessageWindow(
|
|
328
|
+
offset,
|
|
329
|
+
Math.min(RECOVERY_SCAN_WINDOW, total - offset),
|
|
330
|
+
{ resolveBlobs: false },
|
|
331
|
+
).messages;
|
|
332
|
+
for (let i = 0; i < messages.length; i++) {
|
|
333
|
+
const index = offset + i;
|
|
334
|
+
const isRemoved = removed(index);
|
|
335
|
+
const message = messages[i] as StoredWindowMessage & { bodyGroupId?: string };
|
|
336
|
+
if (message.bodyGroupId) {
|
|
337
|
+
const prior = groupMembership.get(message.bodyGroupId);
|
|
338
|
+
if (prior !== undefined && prior !== isRemoved) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`Recovery selection splits body group ${message.bodyGroupId}; adjust the anchor or suppression boundary.`,
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
groupMembership.set(message.bodyGroupId, isRemoved);
|
|
344
|
+
}
|
|
345
|
+
for (const rawBlock of message.content) {
|
|
346
|
+
const block = rawBlock as { type?: unknown; id?: unknown; toolUseId?: unknown };
|
|
347
|
+
if (block.type === 'tool_use' && typeof block.id === 'string') {
|
|
348
|
+
toolUses.set(block.id, isRemoved);
|
|
349
|
+
} else if (block.type === 'tool_result' && typeof block.toolUseId === 'string') {
|
|
350
|
+
toolResults.set(block.toolUseId, isRemoved);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
for (const [toolUseId, useRemoved] of toolUses) {
|
|
357
|
+
const resultRemoved = toolResults.get(toolUseId);
|
|
358
|
+
if (resultRemoved !== undefined && resultRemoved !== useRemoved) {
|
|
359
|
+
throw new Error(
|
|
360
|
+
`Recovery selection would split tool exchange ${toolUseId}; suppress or retain both tool_use and tool_result.`,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function buildSuppressionPlan(
|
|
367
|
+
contextManager: ContextManager,
|
|
368
|
+
targetIndex: number,
|
|
369
|
+
rawMessageIds: string[],
|
|
370
|
+
rawRanges: Array<{ fromMessageId: string; toMessageId: string }>,
|
|
371
|
+
forcedServerId?: string,
|
|
372
|
+
): { intervals: SuppressionInterval[]; refs: DiscordAwarenessRef[]; messageCount: number } {
|
|
373
|
+
const messageIds = rawMessageIds.map(normalizeDiscordMessageId);
|
|
374
|
+
const ranges = rawRanges.map((range) => ({
|
|
375
|
+
fromMessageId: normalizeDiscordMessageId(range.fromMessageId),
|
|
376
|
+
toMessageId: normalizeDiscordMessageId(range.toMessageId),
|
|
377
|
+
}));
|
|
378
|
+
const wanted = new Set([
|
|
379
|
+
...messageIds,
|
|
380
|
+
...ranges.flatMap((range) => [range.fromMessageId, range.toMessageId]),
|
|
381
|
+
]);
|
|
382
|
+
if (wanted.size === 0) return { intervals: [], refs: [], messageCount: 0 };
|
|
383
|
+
|
|
384
|
+
const locations = new Map<string, MessageLocation>();
|
|
385
|
+
for (let offset = 0; offset <= targetIndex; offset += RECOVERY_SCAN_WINDOW) {
|
|
386
|
+
const messages = contextManager.getMessageWindow(
|
|
387
|
+
offset,
|
|
388
|
+
Math.min(RECOVERY_SCAN_WINDOW, targetIndex - offset + 1),
|
|
389
|
+
{ resolveBlobs: false },
|
|
390
|
+
).messages;
|
|
391
|
+
for (let i = 0; i < messages.length; i++) {
|
|
392
|
+
const externalId = String(messages[i].metadata?.messageId ?? '');
|
|
393
|
+
if (!wanted.has(externalId)) continue;
|
|
394
|
+
const index = offset + i;
|
|
395
|
+
const location = locations.get(externalId);
|
|
396
|
+
if (!location) {
|
|
397
|
+
const run = {
|
|
398
|
+
start: index,
|
|
399
|
+
end: index,
|
|
400
|
+
fromId: messages[i].id,
|
|
401
|
+
toId: messages[i].id,
|
|
402
|
+
};
|
|
403
|
+
locations.set(externalId, {
|
|
404
|
+
first: index,
|
|
405
|
+
last: index,
|
|
406
|
+
firstId: messages[i].id,
|
|
407
|
+
lastId: messages[i].id,
|
|
408
|
+
runs: [run],
|
|
409
|
+
});
|
|
410
|
+
} else {
|
|
411
|
+
const lastRun = location.runs.at(-1)!;
|
|
412
|
+
if (index === lastRun.end + 1) {
|
|
413
|
+
lastRun.end = index;
|
|
414
|
+
lastRun.toId = messages[i].id;
|
|
415
|
+
} else {
|
|
416
|
+
location.runs.push({
|
|
417
|
+
start: index,
|
|
418
|
+
end: index,
|
|
419
|
+
fromId: messages[i].id,
|
|
420
|
+
toId: messages[i].id,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
location.last = index;
|
|
424
|
+
location.lastId = messages[i].id;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const id of wanted) {
|
|
430
|
+
if (!locations.has(id)) {
|
|
431
|
+
throw new Error(
|
|
432
|
+
`Suppression endpoint ${id} is not visible at the selected branch point.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const intervals: SuppressionInterval[] = [];
|
|
438
|
+
for (const id of messageIds) {
|
|
439
|
+
const location = locations.get(id)!;
|
|
440
|
+
intervals.push(...location.runs.map((run) => ({ ...run })));
|
|
441
|
+
}
|
|
442
|
+
for (const range of ranges) {
|
|
443
|
+
const from = locations.get(range.fromMessageId)!;
|
|
444
|
+
const to = locations.get(range.toMessageId)!;
|
|
445
|
+
const earlier = from.first <= to.first ? from : to;
|
|
446
|
+
const later = from.first <= to.first ? to : from;
|
|
447
|
+
intervals.push({
|
|
448
|
+
start: earlier.first,
|
|
449
|
+
end: later.last,
|
|
450
|
+
fromId: earlier.firstId,
|
|
451
|
+
toId: later.lastId,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
intervals.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
456
|
+
const merged: SuppressionInterval[] = [];
|
|
457
|
+
for (const interval of intervals) {
|
|
458
|
+
const previous = merged.at(-1);
|
|
459
|
+
if (previous && interval.start <= previous.end + 1) {
|
|
460
|
+
if (interval.end > previous.end) {
|
|
461
|
+
previous.end = interval.end;
|
|
462
|
+
previous.toId = interval.toId;
|
|
463
|
+
}
|
|
464
|
+
} else {
|
|
465
|
+
merged.push({ ...interval });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const refs: DiscordAwarenessRef[] = [];
|
|
470
|
+
for (const interval of merged) {
|
|
471
|
+
refs.push(...collectDiscordRefs(
|
|
472
|
+
contextManager,
|
|
473
|
+
interval.start,
|
|
474
|
+
interval.end + 1,
|
|
475
|
+
forcedServerId,
|
|
476
|
+
));
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
intervals: merged,
|
|
480
|
+
refs: dedupeDiscordRefs(refs),
|
|
481
|
+
messageCount: merged.reduce((sum, interval) => sum + interval.end - interval.start + 1, 0),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function collectDiscordRefs(
|
|
486
|
+
contextManager: ContextManager,
|
|
487
|
+
start: number,
|
|
488
|
+
end: number,
|
|
489
|
+
forcedServerId?: string,
|
|
490
|
+
): DiscordAwarenessRef[] {
|
|
491
|
+
const refs: DiscordAwarenessRef[] = [];
|
|
492
|
+
for (let offset = start; offset < end; offset += RECOVERY_SCAN_WINDOW) {
|
|
493
|
+
const messages = contextManager.getMessageWindow(
|
|
494
|
+
offset,
|
|
495
|
+
Math.min(RECOVERY_SCAN_WINDOW, end - offset),
|
|
496
|
+
{ resolveBlobs: false },
|
|
497
|
+
).messages;
|
|
498
|
+
refs.push(...extractDiscordAwarenessRefs(messages, forcedServerId));
|
|
499
|
+
}
|
|
500
|
+
// A sharded message may repeat Discord metadata across adjacent records.
|
|
501
|
+
return dedupeDiscordRefs(refs);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function dedupeDiscordRefs(refs: DiscordAwarenessRef[]): DiscordAwarenessRef[] {
|
|
505
|
+
const deduped = new Map<string, DiscordAwarenessRef>();
|
|
506
|
+
for (const ref of refs) {
|
|
507
|
+
deduped.set(`${ref.serverId}\0${ref.channelId}\0${ref.messageId}`, ref);
|
|
508
|
+
}
|
|
509
|
+
return [...deduped.values()];
|
|
510
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createOfflineRecoveryBranch } from './offline-branch.js';
|
|
4
|
+
|
|
5
|
+
interface CliOptions {
|
|
6
|
+
storePath?: string;
|
|
7
|
+
agentName?: string;
|
|
8
|
+
messageId?: string;
|
|
9
|
+
contextId?: string;
|
|
10
|
+
messages?: number;
|
|
11
|
+
suppressMessageIds: string[];
|
|
12
|
+
suppressRanges: Array<{ fromMessageId: string; toMessageId: string }>;
|
|
13
|
+
branchName?: string;
|
|
14
|
+
namespace?: string;
|
|
15
|
+
discordServerId?: string;
|
|
16
|
+
outboxPath?: string;
|
|
17
|
+
emoji?: string;
|
|
18
|
+
dryRun: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function usage(): string {
|
|
22
|
+
return `Usage:
|
|
23
|
+
agent-framework-recover --store <path> --agent <name> <anchor> [options]
|
|
24
|
+
|
|
25
|
+
Creates and activates a Chronicle recovery branch while the normal host is down,
|
|
26
|
+
then queues Discord reactions for addressable messages removed from awareness.
|
|
27
|
+
Message content is never compiled or written to the recovery outbox.
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
--message-id <id|link> Last Discord message the agent should still see
|
|
31
|
+
--context-id <internal-id> Exact internal context message to retain as anchor
|
|
32
|
+
--messages <count> Legacy alternative: remove N context entries
|
|
33
|
+
--suppress <id|link> Suppress one Discord message; repeat or comma-separate
|
|
34
|
+
--suppress-range <a>..<b> Suppress an inclusive context range; repeatable
|
|
35
|
+
--branch <name> Recovery branch name (generated by default)
|
|
36
|
+
--namespace <namespace> Context namespace (default: agents/<agent>)
|
|
37
|
+
--discord-server <id> Override/inject the Discord MCPL server id
|
|
38
|
+
--outbox <path> Override the branch-independent outbox path
|
|
39
|
+
--emoji <emoji> Marker reaction (default: 💤)
|
|
40
|
+
--dry-run Inspect counts/addresses without writing
|
|
41
|
+
--help Show this help
|
|
42
|
+
|
|
43
|
+
The agent host must be stopped before running this command.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseArgs(args: string[]): CliOptions {
|
|
47
|
+
const options: CliOptions = { dryRun: false, suppressMessageIds: [], suppressRanges: [] };
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const arg = args[i];
|
|
50
|
+
const value = () => {
|
|
51
|
+
const next = args[++i];
|
|
52
|
+
if (!next) throw new Error(`${arg} requires a value`);
|
|
53
|
+
return next;
|
|
54
|
+
};
|
|
55
|
+
switch (arg) {
|
|
56
|
+
case '--store': options.storePath = value(); break;
|
|
57
|
+
case '--agent': options.agentName = value(); break;
|
|
58
|
+
case '--message-id':
|
|
59
|
+
case '--message': options.messageId = value(); break;
|
|
60
|
+
case '--context-id': options.contextId = value(); break;
|
|
61
|
+
case '--messages': options.messages = Number(value()); break;
|
|
62
|
+
case '--suppress':
|
|
63
|
+
case '--suppress-message':
|
|
64
|
+
options.suppressMessageIds.push(...value().split(',').map((item) => item.trim()).filter(Boolean));
|
|
65
|
+
break;
|
|
66
|
+
case '--suppress-range': {
|
|
67
|
+
const raw = value();
|
|
68
|
+
const separator = raw.indexOf('..');
|
|
69
|
+
if (separator <= 0 || separator >= raw.length - 2) {
|
|
70
|
+
throw new Error('--suppress-range must be formatted as <first>..<last>');
|
|
71
|
+
}
|
|
72
|
+
options.suppressRanges.push({
|
|
73
|
+
fromMessageId: raw.slice(0, separator),
|
|
74
|
+
toMessageId: raw.slice(separator + 2),
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case '--branch': options.branchName = value(); break;
|
|
79
|
+
case '--namespace': options.namespace = value(); break;
|
|
80
|
+
case '--discord-server': options.discordServerId = value(); break;
|
|
81
|
+
case '--outbox': options.outboxPath = value(); break;
|
|
82
|
+
case '--emoji': options.emoji = value(); break;
|
|
83
|
+
case '--dry-run': options.dryRun = true; break;
|
|
84
|
+
case '--help':
|
|
85
|
+
case '-h':
|
|
86
|
+
console.log(usage());
|
|
87
|
+
process.exit(0);
|
|
88
|
+
default: throw new Error(`Unknown argument: ${arg}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return options;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function main(): Promise<void> {
|
|
95
|
+
const options = parseArgs(process.argv.slice(2));
|
|
96
|
+
if (
|
|
97
|
+
!options.storePath
|
|
98
|
+
|| !options.agentName
|
|
99
|
+
|| (!options.messageId && !options.contextId && options.messages === undefined)
|
|
100
|
+
) {
|
|
101
|
+
throw new Error(`--store, --agent, and one anchor (--message-id, --context-id, or --messages) are required\n\n${usage()}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = await createOfflineRecoveryBranch({
|
|
105
|
+
storePath: options.storePath,
|
|
106
|
+
agentName: options.agentName,
|
|
107
|
+
messageId: options.messageId,
|
|
108
|
+
contextId: options.contextId,
|
|
109
|
+
messages: options.messages,
|
|
110
|
+
suppressMessageIds: options.suppressMessageIds,
|
|
111
|
+
suppressRanges: options.suppressRanges,
|
|
112
|
+
branchName: options.branchName,
|
|
113
|
+
namespace: options.namespace,
|
|
114
|
+
discordServerId: options.discordServerId,
|
|
115
|
+
outboxPath: options.outboxPath,
|
|
116
|
+
emoji: options.emoji,
|
|
117
|
+
dryRun: options.dryRun,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log(JSON.stringify({
|
|
121
|
+
...result,
|
|
122
|
+
// Keep the output useful for ops without ever exposing message content.
|
|
123
|
+
refs: result.refs.map(({ serverId, channelId, messageId }) => ({ serverId, channelId, messageId })),
|
|
124
|
+
}, null, 2));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
main().catch((error) => {
|
|
128
|
+
console.error(`[agent-framework-recover] ${error instanceof Error ? error.message : String(error)}`);
|
|
129
|
+
process.exitCode = 1;
|
|
130
|
+
});
|
package/src/types/agent.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { ContentBlock, YieldingStream } from '@animalabs/membrane';
|
|
|
2
2
|
import type { ContextStrategy } from '@animalabs/context-manager';
|
|
3
3
|
import type { ToolCallId, ToolResult, ToolCall } from './events.js';
|
|
4
4
|
|
|
5
|
+
export type SameRoundThinkTextPolicy = 'public' | 'private';
|
|
6
|
+
export type SameRoundThinkTextPolicySource =
|
|
7
|
+
| 'runtime_override'
|
|
8
|
+
| 'recipe'
|
|
9
|
+
| 'compatibility_default';
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* Configuration for an agent.
|
|
7
13
|
*/
|
|
@@ -65,6 +71,11 @@ export interface AgentConfig {
|
|
|
65
71
|
/** Provider-specific request parameters (for example Responses reasoning
|
|
66
72
|
* and server-side compaction settings). */
|
|
67
73
|
providerParams?: Record<string, unknown>;
|
|
74
|
+
/**
|
|
75
|
+
* Same-round routing policy for ordinary text emitted beside `think`.
|
|
76
|
+
* Omitted preserves the compatibility carry-forward: public.
|
|
77
|
+
*/
|
|
78
|
+
sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
|
|
68
79
|
/**
|
|
69
80
|
* Extended thinking config. When `enabled: true`, the agent runs with
|
|
70
81
|
* Anthropic's native extended thinking; responses include `thinking` blocks
|
|
@@ -112,12 +123,15 @@ export interface AgentRuntimeSettingsPatch {
|
|
|
112
123
|
contextBudgetTokens?: number;
|
|
113
124
|
tailTokens?: number;
|
|
114
125
|
transitionPaceTokens?: number;
|
|
126
|
+
sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
export interface AgentRuntimeSettingsSnapshot {
|
|
118
130
|
contextBudgetTokens: number;
|
|
119
131
|
tailTokens?: number;
|
|
120
132
|
transitionPaceTokens?: number;
|
|
133
|
+
sameRoundThinkTextPolicy: SameRoundThinkTextPolicy;
|
|
134
|
+
sameRoundThinkTextPolicySource: SameRoundThinkTextPolicySource;
|
|
121
135
|
transition: 'stable' | 'converging' | 'blocked';
|
|
122
136
|
transitionReason?: 'transition_pace_too_small' | 'protected_context_exceeds_target';
|
|
123
137
|
}
|
package/src/types/framework.ts
CHANGED
|
@@ -42,6 +42,23 @@ export interface FrameworkConfig {
|
|
|
42
42
|
/** Path to Chronicle store */
|
|
43
43
|
storePath?: string;
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Branch-independent Discord awareness-marker outbox. Defaults to
|
|
47
|
+
* `<storePath>/recovery/discord-awareness-outbox.json`. Set explicitly when
|
|
48
|
+
* providing an app-owned `store` without `storePath`; omit to disable.
|
|
49
|
+
*/
|
|
50
|
+
discordAwarenessOutboxPath?: string;
|
|
51
|
+
|
|
52
|
+
/** Emoji used for branch-awareness markers (default: 💤). */
|
|
53
|
+
discordAwarenessEmoji?: string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Mandatory deadline for each Discord awareness marker tools/call. This is
|
|
57
|
+
* independent of MCPL requestTimeoutMs and cannot be disabled with 0.
|
|
58
|
+
* Values are clamped to 50..60000ms; 0 selects the 10000ms default.
|
|
59
|
+
*/
|
|
60
|
+
discordAwarenessDeadlineMs?: number;
|
|
61
|
+
|
|
45
62
|
/** Or existing store (app-owned) */
|
|
46
63
|
store?: JsStore;
|
|
47
64
|
|