@animalabs/agent-framework 0.10.0 → 0.11.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 +1 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +58 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +437 -28
- package/dist/src/framework.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +21 -5
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -3
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +9 -3
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +9 -6
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/queue.js +9 -2
- package/dist/src/queue.js.map +1 -1
- package/dist/src/types/agent.d.ts +3 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +7 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +29 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/discord-history-delete-window.test.d.ts +2 -0
- package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
- package/dist/test/discord-history-delete-window.test.js +81 -0
- package/dist/test/discord-history-delete-window.test.js.map +1 -0
- package/dist/test/discord-history-sync.test.d.ts +2 -0
- package/dist/test/discord-history-sync.test.d.ts.map +1 -0
- package/dist/test/discord-history-sync.test.js +56 -0
- package/dist/test/discord-history-sync.test.js.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +42 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +33 -0
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
- package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +286 -0
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.d.ts +2 -0
- package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
- package/dist/test/puppet-tool-call.test.js +106 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -0
- package/dist/test/release-changelog.test.d.ts +2 -0
- package/dist/test/release-changelog.test.d.ts.map +1 -0
- package/dist/test/release-changelog.test.js +159 -0
- package/dist/test/release-changelog.test.js.map +1 -0
- package/dist/test/workspace-path-containment.test.d.ts +19 -0
- package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
- package/dist/test/workspace-path-containment.test.js +91 -0
- package/dist/test/workspace-path-containment.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.js +15 -3
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +1 -1
- package/src/framework.ts +467 -27
- package/src/modules/discord/index.ts +24 -6
- package/src/modules/workspace/index.ts +5 -3
- package/src/modules/workspace/sync.ts +9 -3
- package/src/modules/workspace/watcher.ts +9 -6
- package/src/queue.ts +8 -2
- package/src/types/agent.ts +3 -1
- package/src/types/trace.ts +13 -0
- package/dist/test/fixtures/awareness-barrier-mcpl-server.mjs +0 -276
- package/dist/test/fixtures/flaky-mcpl-server.mjs +0 -41
|
@@ -754,7 +754,7 @@ export class DiscordModule implements Module {
|
|
|
754
754
|
});
|
|
755
755
|
|
|
756
756
|
// Build a map of stored message external IDs -> internal data
|
|
757
|
-
const storedMessageMap = new Map<string, { internalId: string; content: string }>();
|
|
757
|
+
const storedMessageMap = new Map<string, { internalId: string; content: string; sourceTimestamp?: number }>();
|
|
758
758
|
for (const msg of storedMessages) {
|
|
759
759
|
const external = msg.metadata?.external as { id?: string } | undefined;
|
|
760
760
|
if (external?.id) {
|
|
@@ -766,6 +766,9 @@ export class DiscordModule implements Module {
|
|
|
766
766
|
storedMessageMap.set(external.id, {
|
|
767
767
|
internalId: msg.id,
|
|
768
768
|
content: textContent,
|
|
769
|
+
sourceTimestamp: typeof msg.metadata?.timestamp === 'number'
|
|
770
|
+
? msg.metadata.timestamp
|
|
771
|
+
: undefined,
|
|
769
772
|
});
|
|
770
773
|
}
|
|
771
774
|
}
|
|
@@ -774,8 +777,10 @@ export class DiscordModule implements Module {
|
|
|
774
777
|
let editedMessages = 0;
|
|
775
778
|
let deletedMessages = 0;
|
|
776
779
|
|
|
777
|
-
//
|
|
778
|
-
|
|
780
|
+
// Discord history is newest-first, while addMessage appends to the stored
|
|
781
|
+
// conversation. Process oldest-first so missed messages retain chronology.
|
|
782
|
+
const chronologicalMessages = [...discordMessages].reverse();
|
|
783
|
+
for (const discordMsg of chronologicalMessages) {
|
|
779
784
|
const stored = storedMessageMap.get(discordMsg.id);
|
|
780
785
|
|
|
781
786
|
if (!stored) {
|
|
@@ -810,10 +815,23 @@ export class DiscordModule implements Module {
|
|
|
810
815
|
}
|
|
811
816
|
}
|
|
812
817
|
|
|
813
|
-
// Check for deletes
|
|
814
|
-
//
|
|
818
|
+
// Check for deletes only inside the history we actually fetched. A short
|
|
819
|
+
// response covers complete available history; a full page has an older
|
|
820
|
+
// boundary whose tied millisecond must be preserved.
|
|
821
|
+
const fetchedCompleteHistory = discordMessages.length < scrollback;
|
|
822
|
+
const oldestFetchedTimestamp = discordMessages.length > 0
|
|
823
|
+
? Math.min(...discordMessages.map((msg) => msg.timestamp.getTime()))
|
|
824
|
+
: undefined;
|
|
815
825
|
for (const [externalId, stored] of storedMessageMap) {
|
|
816
|
-
|
|
826
|
+
const isInsideFetchedWindow = fetchedCompleteHistory || (
|
|
827
|
+
stored.sourceTimestamp !== undefined &&
|
|
828
|
+
oldestFetchedTimestamp !== undefined &&
|
|
829
|
+
// Discord timestamps are millisecond-precise, so an older message just
|
|
830
|
+
// beyond the page can tie the oldest fetched timestamp. Preserve ties
|
|
831
|
+
// rather than guessing that an unseen boundary message was deleted.
|
|
832
|
+
stored.sourceTimestamp > oldestFetchedTimestamp
|
|
833
|
+
);
|
|
834
|
+
if (isInsideFetchedWindow && !discordMessageMap.has(externalId)) {
|
|
817
835
|
// Message exists in our store but not in Discord - it was deleted
|
|
818
836
|
// Note: This only catches deletes within the scrollback window
|
|
819
837
|
this.ctx.removeMessage(stored.internalId);
|
|
@@ -1134,10 +1134,12 @@ export class WorkspaceModule implements Module {
|
|
|
1134
1134
|
throw new Error(`Unknown mount: "${mountName}". Available: ${[...this.mounts.keys()].join(', ')}`);
|
|
1135
1135
|
}
|
|
1136
1136
|
|
|
1137
|
-
// Path traversal guard (CWE-22): ensure resolved path stays within mount
|
|
1137
|
+
// Path traversal guard (CWE-22): ensure resolved path stays within mount.
|
|
1138
|
+
// Containment must compare with the PLATFORM separator (isContainedPath):
|
|
1139
|
+
// resolve() emits backslashes on Windows, so a '/'-suffixed root never
|
|
1140
|
+
// prefix-matches there and every in-mount path was rejected as traversal.
|
|
1138
1141
|
const resolved = resolve(mount.config.path, relativePath);
|
|
1139
|
-
|
|
1140
|
-
if (resolved !== mount.config.path && !resolved.startsWith(mountRoot)) {
|
|
1142
|
+
if (!isContainedPath(mount.config.path, resolved)) {
|
|
1141
1143
|
throw new Error(`Path traversal detected: "${path}" resolves outside mount "${mountName}"`);
|
|
1142
1144
|
}
|
|
1143
1145
|
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { createHash } from 'node:crypto';
|
|
10
10
|
import { readFile, writeFile, mkdir, readdir, stat, access } from 'node:fs/promises';
|
|
11
|
-
import { join, dirname, relative, resolve } from 'node:path';
|
|
11
|
+
import { join, dirname, relative, resolve, sep } from 'node:path';
|
|
12
12
|
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
13
13
|
import type { MountState } from './types.js';
|
|
14
14
|
|
|
@@ -19,8 +19,11 @@ export const DEFAULT_MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
|
19
19
|
* Returns the absolute path, or null if the path is outside the mount.
|
|
20
20
|
*/
|
|
21
21
|
function safePath(mountPath: string, relativePath: string): string | null {
|
|
22
|
+
// Compare with the PLATFORM separator: resolve() emits backslashes on
|
|
23
|
+
// Windows, so a '/'-suffixed root never prefix-matches there and every
|
|
24
|
+
// in-mount path was reported as outside the mount.
|
|
22
25
|
const resolved = resolve(mountPath, relativePath);
|
|
23
|
-
const root = mountPath.endsWith(
|
|
26
|
+
const root = mountPath.endsWith(sep) ? mountPath : mountPath + sep;
|
|
24
27
|
if (resolved !== mountPath && !resolved.startsWith(root)) {
|
|
25
28
|
return null;
|
|
26
29
|
}
|
|
@@ -299,7 +302,10 @@ async function walkDirectory(
|
|
|
299
302
|
if (results.length >= maxFiles) break;
|
|
300
303
|
|
|
301
304
|
const fullPath = join(dir, entry.name);
|
|
302
|
-
|
|
305
|
+
// Logical workspace paths are '/'-separated everywhere (mount-prefixed
|
|
306
|
+
// paths, chronicle tree paths); node's relative() emits backslashes on
|
|
307
|
+
// Windows, so normalize. No-op on POSIX (sep === '/').
|
|
308
|
+
const relativePath = relative(basePath, fullPath).split(sep).join('/');
|
|
303
309
|
|
|
304
310
|
// Check ignore patterns (simple glob matching)
|
|
305
311
|
if (shouldIgnore(relativePath, entry.name, ignorePatterns)) continue;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { watch, type FSWatcher } from 'chokidar';
|
|
9
9
|
import { mkdirSync, statSync } from 'node:fs';
|
|
10
|
-
import { basename } from 'node:path';
|
|
10
|
+
import { basename, sep } from 'node:path';
|
|
11
11
|
import { type MountConfig } from './types.js';
|
|
12
12
|
|
|
13
13
|
export type FsOp = 'created' | 'modified' | 'deleted';
|
|
@@ -363,20 +363,23 @@ export class MountWatcher {
|
|
|
363
363
|
}
|
|
364
364
|
|
|
365
365
|
private toRelative(absolutePath: string): string | null {
|
|
366
|
-
|
|
366
|
+
// Chokidar hands back platform-separated absolute paths; compare with
|
|
367
|
+
// the platform separator and normalize the result to the '/'-separated
|
|
368
|
+
// logical form the rest of the workspace uses. No-op on POSIX.
|
|
369
|
+
const base = this.config.path.endsWith(sep)
|
|
367
370
|
? this.config.path
|
|
368
|
-
: this.config.path +
|
|
371
|
+
: this.config.path + sep;
|
|
369
372
|
if (absolutePath.startsWith(base)) {
|
|
370
|
-
return absolutePath.slice(base.length);
|
|
373
|
+
return absolutePath.slice(base.length).split(sep).join('/');
|
|
371
374
|
}
|
|
372
375
|
return null;
|
|
373
376
|
}
|
|
374
377
|
|
|
375
378
|
private isRootPath(absolutePath: string): boolean {
|
|
376
|
-
const root = this.config.path.endsWith(
|
|
379
|
+
const root = this.config.path.endsWith(sep)
|
|
377
380
|
? this.config.path.slice(0, -1)
|
|
378
381
|
: this.config.path;
|
|
379
|
-
const candidate = absolutePath.endsWith(
|
|
382
|
+
const candidate = absolutePath.endsWith(sep)
|
|
380
383
|
? absolutePath.slice(0, -1)
|
|
381
384
|
: absolutePath;
|
|
382
385
|
return candidate === root;
|
package/src/queue.ts
CHANGED
|
@@ -22,8 +22,14 @@ export class ProcessQueueImpl implements ProcessQueue {
|
|
|
22
22
|
if (waiter) {
|
|
23
23
|
waiter(event);
|
|
24
24
|
} else if (event.type === 'external-message') {
|
|
25
|
-
// User input
|
|
26
|
-
|
|
25
|
+
// User input stays ahead of queued internal work, but joins the end of
|
|
26
|
+
// the external-message lane so rapid messages retain arrival order.
|
|
27
|
+
const firstInternal = this.queue.findIndex((queued) => queued.type !== 'external-message');
|
|
28
|
+
if (firstInternal === -1) {
|
|
29
|
+
this.queue.push(event);
|
|
30
|
+
} else {
|
|
31
|
+
this.queue.splice(firstInternal, 0, event);
|
|
32
|
+
}
|
|
27
33
|
} else {
|
|
28
34
|
this.queue.push(event);
|
|
29
35
|
}
|
package/src/types/agent.ts
CHANGED
|
@@ -180,8 +180,10 @@ export interface AgentConfig {
|
|
|
180
180
|
* - 'hybrid': unprefixed prose keeps locus routing; `>>>destination` routes
|
|
181
181
|
* that publication envelope through the same authorized resolver.
|
|
182
182
|
* See docs/hybrid-prose-routing.md.
|
|
183
|
+
* - 'disabled': generated prose is never published. Only explicit tools may
|
|
184
|
+
* send externally; prose remains in Chronicle with a private suppression receipt.
|
|
183
185
|
*/
|
|
184
|
-
proseRouting?: 'locus' | 'explicit' | 'hybrid';
|
|
186
|
+
proseRouting?: 'locus' | 'explicit' | 'hybrid' | 'disabled';
|
|
185
187
|
}
|
|
186
188
|
|
|
187
189
|
/** The intentionally small set of agent settings that may change in-process. */
|
package/src/types/trace.ts
CHANGED
|
@@ -340,6 +340,19 @@ export type TraceEvent =
|
|
|
340
340
|
textLen: number;
|
|
341
341
|
})
|
|
342
342
|
|
|
343
|
+
// Admin puppet: an operator executed a tool AS an agent and stored the
|
|
344
|
+
// tool_use + tool_result pair in that agent's window (Framework.
|
|
345
|
+
// puppetToolCall). Provenance lives HERE and in the host log — the stored
|
|
346
|
+
// messages deliberately byte-match a model-initiated call.
|
|
347
|
+
| (TraceEventBase & {
|
|
348
|
+
type: 'puppet:tool-call';
|
|
349
|
+
agentName: string;
|
|
350
|
+
toolName: string;
|
|
351
|
+
toolUseId: string;
|
|
352
|
+
isError: boolean;
|
|
353
|
+
durationMs: number;
|
|
354
|
+
})
|
|
355
|
+
|
|
343
356
|
// MCPL server connection lifecycle (spawn / handshake / reconnect health).
|
|
344
357
|
// Previously these outcomes were visible only on the host process's own
|
|
345
358
|
// stderr, so a server that lost the boot handshake race just went missing.
|
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
-
|
|
3
|
-
const statusPath = process.env.STATUS_PATH;
|
|
4
|
-
const ledgerPath = process.env.LEDGER_PATH;
|
|
5
|
-
const releasePath = process.env.RELEASE_PATH;
|
|
6
|
-
const crashPath = process.env.CRASH_PATH;
|
|
7
|
-
const generationPath = process.env.GENERATION_PATH;
|
|
8
|
-
const listChangePath = process.env.LIST_CHANGE_PATH;
|
|
9
|
-
const failReaction = process.env.FAIL_REACTION === '1';
|
|
10
|
-
const permanentReaction = process.env.PERMANENT_REACTION === '1';
|
|
11
|
-
const failInitialGeneration = process.env.FAIL_INITIAL_GENERATION === '1';
|
|
12
|
-
const nestedListChangeGeneration = Number(process.env.NESTED_LIST_CHANGE_GENERATION ?? 0);
|
|
13
|
-
const dataMethod = process.env.DATA_METHOD ?? 'push/event';
|
|
14
|
-
const serverId = process.env.SERVER_ID ?? 'discord';
|
|
15
|
-
const arrivalPath = process.env.ARRIVAL_PATH;
|
|
16
|
-
const waitForArrivalPath = process.env.WAIT_FOR_ARRIVAL_PATH;
|
|
17
|
-
|
|
18
|
-
let generation = 1;
|
|
19
|
-
if (generationPath) {
|
|
20
|
-
try {
|
|
21
|
-
generation = Number(readFileSync(generationPath, 'utf8')) + 1;
|
|
22
|
-
} catch {
|
|
23
|
-
// First process for this test.
|
|
24
|
-
}
|
|
25
|
-
writeFileSync(generationPath, String(generation));
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const log = (event, extra = {}) => {
|
|
29
|
-
if (!statusPath) return;
|
|
30
|
-
appendFileSync(statusPath, JSON.stringify({ event, generation, serverId, ...extra }) + '\n');
|
|
31
|
-
};
|
|
32
|
-
const send = (message) => process.stdout.write(JSON.stringify(message) + '\n');
|
|
33
|
-
const request = (id, method, params) => send({ jsonrpc: '2.0', id, method, params });
|
|
34
|
-
const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
|
|
35
|
-
const channel = (suffix) => ({
|
|
36
|
-
id: `${serverId}:guild:${suffix}`,
|
|
37
|
-
type: serverId,
|
|
38
|
-
label: suffix,
|
|
39
|
-
direction: 'bidirectional',
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
if (failInitialGeneration && generation === 1) {
|
|
43
|
-
log('initial-connect-failure');
|
|
44
|
-
process.exit(9);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
let initialized = false;
|
|
48
|
-
let registered = false;
|
|
49
|
-
let registrationReceived = false;
|
|
50
|
-
let pendingToolRequest = null;
|
|
51
|
-
let reactionQueued = false;
|
|
52
|
-
let pendingControl = false;
|
|
53
|
-
let listChangeTriggered = false;
|
|
54
|
-
let reactionOrdinal = 0;
|
|
55
|
-
let buf = '';
|
|
56
|
-
|
|
57
|
-
function ledgerDeliveryStatus() {
|
|
58
|
-
if (!ledgerPath) return 'missing';
|
|
59
|
-
try {
|
|
60
|
-
const document = JSON.parse(readFileSync(ledgerPath, 'utf8'));
|
|
61
|
-
return document.batches?.[0]?.refs?.[0]?.deliveryStatus ?? 'missing';
|
|
62
|
-
} catch {
|
|
63
|
-
return 'unreadable';
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function maybeReplyToReaction() {
|
|
68
|
-
if (!pendingToolRequest || pendingControl || failReaction) return;
|
|
69
|
-
if (releasePath) {
|
|
70
|
-
if (!existsSync(releasePath)) return;
|
|
71
|
-
const releaseValue = readFileSync(releasePath, 'utf8').trim();
|
|
72
|
-
const releaseOrdinal = Number(releaseValue);
|
|
73
|
-
if (Number.isFinite(releaseOrdinal) && releaseOrdinal < pendingToolRequest.ordinal) return;
|
|
74
|
-
}
|
|
75
|
-
const requestId = pendingToolRequest.id;
|
|
76
|
-
pendingToolRequest = null;
|
|
77
|
-
log('reaction-response');
|
|
78
|
-
reply(requestId, permanentReaction
|
|
79
|
-
? { isError: true, content: [{ type: 'text', text: 'Unknown Message' }] }
|
|
80
|
-
: { content: [{ type: 'text', text: 'Reaction applied' }] });
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function serviceReaction(message) {
|
|
84
|
-
if (waitForArrivalPath && !existsSync(waitForArrivalPath)) {
|
|
85
|
-
pendingToolRequest = message;
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (!registered) {
|
|
89
|
-
pendingToolRequest = message;
|
|
90
|
-
if (!reactionQueued) {
|
|
91
|
-
reactionQueued = true;
|
|
92
|
-
log('reaction-queued-before-registration');
|
|
93
|
-
}
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
reactionOrdinal++;
|
|
97
|
-
pendingToolRequest = { ...message, ordinal: reactionOrdinal };
|
|
98
|
-
pendingControl = true;
|
|
99
|
-
log('reaction-call', { name: message.params?.name, ordinal: reactionOrdinal });
|
|
100
|
-
request(201, 'channels/register', { channels: [channel('control-during-barrier')] });
|
|
101
|
-
if (
|
|
102
|
-
nestedListChangeGeneration === generation
|
|
103
|
-
&& reactionOrdinal === 1
|
|
104
|
-
&& !listChangeTriggered
|
|
105
|
-
) {
|
|
106
|
-
listChangeTriggered = true;
|
|
107
|
-
log('tools-list-changed-notification');
|
|
108
|
-
send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: {} });
|
|
109
|
-
sendDataRequest(302, 'list-change');
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function maybeFinishRegistration() {
|
|
114
|
-
if (!registrationReceived || registered) return;
|
|
115
|
-
if (pendingToolRequest && waitForArrivalPath && !existsSync(waitForArrivalPath)) return;
|
|
116
|
-
if (pendingToolRequest && !reactionQueued) serviceReaction(pendingToolRequest);
|
|
117
|
-
registered = true;
|
|
118
|
-
log('registration-response');
|
|
119
|
-
const queued = pendingToolRequest;
|
|
120
|
-
pendingToolRequest = null;
|
|
121
|
-
if (queued) serviceReaction(queued);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function sendDataRequest(id, phase) {
|
|
125
|
-
let params;
|
|
126
|
-
if (dataMethod === 'inference/request') {
|
|
127
|
-
params = {
|
|
128
|
-
featureSet: 'chat',
|
|
129
|
-
messages: [{ role: 'user', content: `must wait for ${phase} awareness` }],
|
|
130
|
-
};
|
|
131
|
-
} else if (dataMethod === 'channels/incoming') {
|
|
132
|
-
params = {
|
|
133
|
-
messages: [{
|
|
134
|
-
channelId: `${serverId}:guild:startup`,
|
|
135
|
-
messageId: `${serverId}-${phase}-incoming-${generation}`,
|
|
136
|
-
author: { id: 'human', name: 'Human' },
|
|
137
|
-
timestamp: new Date().toISOString(),
|
|
138
|
-
content: [{ type: 'text', text: `must wait for ${phase} awareness` }],
|
|
139
|
-
}],
|
|
140
|
-
};
|
|
141
|
-
} else if (dataMethod === 'host/command') {
|
|
142
|
-
params = { command: 'barrier-probe', agentName: 'assistant' };
|
|
143
|
-
} else {
|
|
144
|
-
params = {
|
|
145
|
-
featureSet: 'chat',
|
|
146
|
-
eventId: `${serverId}-${phase}-push-${generation}`,
|
|
147
|
-
timestamp: new Date().toISOString(),
|
|
148
|
-
payload: { content: [{ type: 'text', text: `must wait for ${phase} awareness` }] },
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
|
-
log('data-request-sent', { method: dataMethod, phase });
|
|
152
|
-
if (arrivalPath) writeFileSync(arrivalPath, `${serverId}:${dataMethod}:${phase}`);
|
|
153
|
-
request(id, dataMethod, params);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function beginServerTraffic() {
|
|
157
|
-
if (initialized) return;
|
|
158
|
-
initialized = true;
|
|
159
|
-
log('initialized');
|
|
160
|
-
request(200, 'channels/register', { channels: [channel('startup')] });
|
|
161
|
-
// The reconnect generation carries an inference-bearing event in the same
|
|
162
|
-
// registration window. Initial-start tests use generation 1 as well.
|
|
163
|
-
sendDataRequest(202, 'startup');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function handle(message) {
|
|
167
|
-
if (message.method === 'initialize') {
|
|
168
|
-
reply(message.id, {
|
|
169
|
-
capabilities: {
|
|
170
|
-
experimental: {
|
|
171
|
-
mcpl: {
|
|
172
|
-
version: '0.5',
|
|
173
|
-
// §5.4/§6.2: the grant is computed from this advertisement, and
|
|
174
|
-
// admission denies what it cannot contain. This fixture SENDS
|
|
175
|
-
// channels/register, channels/incoming and inference/request, so
|
|
176
|
-
// it must advertise them — with only pushEvents declared, the
|
|
177
|
-
// 0.5 host correctly rejected the released data requests
|
|
178
|
-
// (accepted:false), which is exactly what PR #79's ubuntu CI
|
|
179
|
-
// caught on the inference/request + channels/incoming matrix.
|
|
180
|
-
pushEvents: true,
|
|
181
|
-
inferenceRequest: true,
|
|
182
|
-
channels: { register: true, incoming: true },
|
|
183
|
-
featureSets: {
|
|
184
|
-
chat: { description: 'chat', uses: ['pushEvents'] },
|
|
185
|
-
},
|
|
186
|
-
},
|
|
187
|
-
},
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
return;
|
|
191
|
-
}
|
|
192
|
-
if (message.method === 'featureSets/update') {
|
|
193
|
-
// MCPL 0.5 (§5.3/§6.7): the host sends initial policy as a Request and
|
|
194
|
-
// activates the grant only on this degradation receipt. Without it the
|
|
195
|
-
// host times out (15s), the grant stays empty, and every push/channel
|
|
196
|
-
// in these tests is rejected fail-closed.
|
|
197
|
-
if (message.id !== undefined && message.id !== null) {
|
|
198
|
-
reply(message.id, { accepted: true });
|
|
199
|
-
}
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
if (message.method === 'notifications/initialized') {
|
|
203
|
-
beginServerTraffic();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
if (message.method === 'tools/list') {
|
|
207
|
-
log('tools-list');
|
|
208
|
-
reply(message.id, {
|
|
209
|
-
tools: [
|
|
210
|
-
{ name: 'add_reaction', description: 'add', inputSchema: { type: 'object' } },
|
|
211
|
-
{ name: 'remove_reaction', description: 'remove', inputSchema: { type: 'object' } },
|
|
212
|
-
],
|
|
213
|
-
});
|
|
214
|
-
return;
|
|
215
|
-
}
|
|
216
|
-
if (message.method === 'tools/call') {
|
|
217
|
-
serviceReaction(message);
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
if (message.id === 200) {
|
|
221
|
-
registrationReceived = true;
|
|
222
|
-
maybeFinishRegistration();
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
if (message.id === 201) {
|
|
226
|
-
pendingControl = false;
|
|
227
|
-
log('control-response-during-barrier');
|
|
228
|
-
maybeReplyToReaction();
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (message.id === 202 || message.id === 302) {
|
|
232
|
-
const event = message.id === 202 ? 'push-response' : 'list-change-push-response';
|
|
233
|
-
const accepted = message.result?.accepted
|
|
234
|
-
?? message.result?.results?.[0]?.accepted
|
|
235
|
-
?? (message.error == null);
|
|
236
|
-
log(event, {
|
|
237
|
-
method: dataMethod,
|
|
238
|
-
accepted,
|
|
239
|
-
ledgerStatus: ledgerDeliveryStatus(),
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
process.stdin.on('data', (chunk) => {
|
|
245
|
-
buf += chunk.toString('utf8');
|
|
246
|
-
let newline;
|
|
247
|
-
while ((newline = buf.indexOf('\n')) >= 0) {
|
|
248
|
-
const line = buf.slice(0, newline);
|
|
249
|
-
buf = buf.slice(newline + 1);
|
|
250
|
-
if (!line.trim()) continue;
|
|
251
|
-
try {
|
|
252
|
-
handle(JSON.parse(line));
|
|
253
|
-
} catch (error) {
|
|
254
|
-
log('server-error', { message: error instanceof Error ? error.message : String(error) });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
const timer = setInterval(() => {
|
|
260
|
-
if (pendingToolRequest && !registered && waitForArrivalPath && existsSync(waitForArrivalPath)) {
|
|
261
|
-
serviceReaction(pendingToolRequest);
|
|
262
|
-
maybeFinishRegistration();
|
|
263
|
-
}
|
|
264
|
-
maybeReplyToReaction();
|
|
265
|
-
if (listChangePath && existsSync(listChangePath) && !listChangeTriggered) {
|
|
266
|
-
listChangeTriggered = true;
|
|
267
|
-
log('tools-list-changed-notification');
|
|
268
|
-
send({ jsonrpc: '2.0', method: 'notifications/tools/list_changed', params: {} });
|
|
269
|
-
sendDataRequest(302, 'list-change');
|
|
270
|
-
}
|
|
271
|
-
if (crashPath && existsSync(crashPath) && generation === 1) {
|
|
272
|
-
log('crashing');
|
|
273
|
-
process.exit(7);
|
|
274
|
-
}
|
|
275
|
-
}, 5);
|
|
276
|
-
timer.unref();
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
// Minimal MCP server for reconnect tests.
|
|
2
|
-
//
|
|
3
|
-
// Availability is controlled by a flag file passed as argv[2]: when the file
|
|
4
|
-
// does not exist the process exits(1) immediately (simulating a server that
|
|
5
|
-
// loses the boot race); when it exists the process answers the MCP
|
|
6
|
-
// `initialize` handshake and stays alive.
|
|
7
|
-
//
|
|
8
|
-
// Receiving a `tools/list` request makes it exit(7) — tests use this as a
|
|
9
|
-
// remote-controlled crash to exercise the unexpected-exit reconnect path.
|
|
10
|
-
import { existsSync } from 'node:fs';
|
|
11
|
-
import { createInterface } from 'node:readline';
|
|
12
|
-
|
|
13
|
-
const flagFile = process.argv[2];
|
|
14
|
-
if (flagFile && !existsSync(flagFile)) process.exit(1);
|
|
15
|
-
|
|
16
|
-
const rl = createInterface({ input: process.stdin });
|
|
17
|
-
rl.on('line', (line) => {
|
|
18
|
-
let msg;
|
|
19
|
-
try {
|
|
20
|
-
msg = JSON.parse(line);
|
|
21
|
-
} catch {
|
|
22
|
-
return;
|
|
23
|
-
}
|
|
24
|
-
if (msg.method === 'initialize') {
|
|
25
|
-
process.stdout.write(
|
|
26
|
-
JSON.stringify({
|
|
27
|
-
jsonrpc: '2.0',
|
|
28
|
-
id: msg.id,
|
|
29
|
-
result: {
|
|
30
|
-
protocolVersion: '2024-11-05',
|
|
31
|
-
capabilities: {
|
|
32
|
-
experimental: { mcpl: { version: '0.5', pushEvents: true } },
|
|
33
|
-
},
|
|
34
|
-
serverInfo: { name: 'flaky-mcpl-server', version: '0.0.0' },
|
|
35
|
-
},
|
|
36
|
-
}) + '\n',
|
|
37
|
-
);
|
|
38
|
-
} else if (msg.method === 'tools/list') {
|
|
39
|
-
process.exit(7);
|
|
40
|
-
}
|
|
41
|
-
});
|