@animalabs/agent-framework 0.15.0 → 0.16.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.
Files changed (96) hide show
  1. package/README.md +22 -1
  2. package/dist/src/agent.d.ts +32 -0
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +55 -0
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/server.d.ts +9 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +156 -0
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/api/types.d.ts +15 -0
  11. package/dist/src/api/types.d.ts.map +1 -1
  12. package/dist/src/framework.d.ts +418 -3
  13. package/dist/src/framework.d.ts.map +1 -1
  14. package/dist/src/framework.js +1782 -68
  15. package/dist/src/framework.js.map +1 -1
  16. package/dist/src/gate/event-gate.d.ts +16 -0
  17. package/dist/src/gate/event-gate.d.ts.map +1 -1
  18. package/dist/src/gate/event-gate.js +41 -3
  19. package/dist/src/gate/event-gate.js.map +1 -1
  20. package/dist/src/index.d.ts +4 -1
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -1
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/mcpl/channel-registry.d.ts +172 -0
  25. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  26. package/dist/src/mcpl/channel-registry.js +444 -2
  27. package/dist/src/mcpl/channel-registry.js.map +1 -1
  28. package/dist/src/mcpl/server-connection.d.ts +9 -0
  29. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  30. package/dist/src/mcpl/server-connection.js +71 -13
  31. package/dist/src/mcpl/server-connection.js.map +1 -1
  32. package/dist/src/modules/history/index.d.ts +89 -13
  33. package/dist/src/modules/history/index.d.ts.map +1 -1
  34. package/dist/src/modules/history/index.js +655 -24
  35. package/dist/src/modules/history/index.js.map +1 -1
  36. package/dist/src/operator-log.d.ts +78 -0
  37. package/dist/src/operator-log.d.ts.map +1 -0
  38. package/dist/src/operator-log.js +102 -0
  39. package/dist/src/operator-log.js.map +1 -0
  40. package/dist/src/recovery/discord-awareness-outbox.d.ts +10 -0
  41. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -1
  42. package/dist/src/recovery/discord-awareness-outbox.js +20 -0
  43. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -1
  44. package/dist/src/types/framework.d.ts +24 -0
  45. package/dist/src/types/framework.d.ts.map +1 -1
  46. package/dist/src/types/trace.d.ts +36 -0
  47. package/dist/src/types/trace.d.ts.map +1 -1
  48. package/dist/test/agent-runtime-settings.test.js +199 -1
  49. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  50. package/dist/test/api-admin-gate.test.d.ts +2 -0
  51. package/dist/test/api-admin-gate.test.d.ts.map +1 -0
  52. package/dist/test/api-admin-gate.test.js +116 -0
  53. package/dist/test/api-admin-gate.test.js.map +1 -0
  54. package/dist/test/channel-label-history.test.d.ts +2 -0
  55. package/dist/test/channel-label-history.test.d.ts.map +1 -0
  56. package/dist/test/channel-label-history.test.js +424 -0
  57. package/dist/test/channel-label-history.test.js.map +1 -0
  58. package/dist/test/discord-awareness-outbox.test.js +16 -0
  59. package/dist/test/discord-awareness-outbox.test.js.map +1 -1
  60. package/dist/test/event-gate.test.js +44 -0
  61. package/dist/test/event-gate.test.js.map +1 -1
  62. package/dist/test/helpers/quiesce-crash-child.d.ts +2 -0
  63. package/dist/test/helpers/quiesce-crash-child.d.ts.map +1 -0
  64. package/dist/test/helpers/quiesce-crash-child.js +156 -0
  65. package/dist/test/helpers/quiesce-crash-child.js.map +1 -0
  66. package/dist/test/history-module-overview.test.d.ts +2 -0
  67. package/dist/test/history-module-overview.test.d.ts.map +1 -0
  68. package/dist/test/history-module-overview.test.js +711 -0
  69. package/dist/test/history-module-overview.test.js.map +1 -0
  70. package/dist/test/history-module.test.js +146 -0
  71. package/dist/test/history-module.test.js.map +1 -1
  72. package/dist/test/host-quiesce.test.d.ts +2 -0
  73. package/dist/test/host-quiesce.test.d.ts.map +1 -0
  74. package/dist/test/host-quiesce.test.js +863 -0
  75. package/dist/test/host-quiesce.test.js.map +1 -0
  76. package/dist/test/operator-surgery.test.d.ts +2 -0
  77. package/dist/test/operator-surgery.test.d.ts.map +1 -0
  78. package/dist/test/operator-surgery.test.js +398 -0
  79. package/dist/test/operator-surgery.test.js.map +1 -0
  80. package/dist/test/puppet-tool-call.test.js +5 -0
  81. package/dist/test/puppet-tool-call.test.js.map +1 -1
  82. package/dist/tsconfig.tsbuildinfo +1 -1
  83. package/package.json +2 -2
  84. package/src/agent.ts +69 -0
  85. package/src/api/server.ts +159 -2
  86. package/src/api/types.ts +15 -0
  87. package/src/framework.ts +2081 -89
  88. package/src/gate/event-gate.ts +47 -4
  89. package/src/index.ts +5 -1
  90. package/src/mcpl/channel-registry.ts +495 -2
  91. package/src/mcpl/server-connection.ts +73 -13
  92. package/src/modules/history/index.ts +689 -25
  93. package/src/operator-log.ts +132 -0
  94. package/src/recovery/discord-awareness-outbox.ts +19 -0
  95. package/src/types/framework.ts +27 -0
  96. package/src/types/trace.ts +42 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "license": "MIT",
48
48
  "dependencies": {
49
49
  "@animalabs/chronicle": "^0.4.0",
50
- "@animalabs/context-manager": "^0.9.2",
50
+ "@animalabs/context-manager": "^0.10.0",
51
51
  "@animalabs/membrane": "^0.5.78",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
package/src/agent.ts CHANGED
@@ -276,6 +276,75 @@ export class Agent {
276
276
  return { ...this.runtimeSettingsOverrides };
277
277
  }
278
278
 
279
+ /**
280
+ * Read-only mirror of `updateRuntimeSettings`' budget semantics: derive what
281
+ * the next compile would actually plan at if `patch` were applied, WITHOUT
282
+ * mutating anything. This is the settings→config mapping a feasibility
283
+ * preview must share with the live path — previewing `patch.contextBudgetTokens`
284
+ * directly models the wrong compile for a paced descent (a non-immediate
285
+ * decrease never changes the compile budget; it only arms a prepared-window
286
+ * transition — see the else-branch in updateRuntimeSettings).
287
+ *
288
+ * - `path: 'immediate'` — the patch changes the compile budget (increase, or
289
+ * decrease with `immediate: true`); `effectiveBudgetTokens` = patch value.
290
+ * - `path: 'paced'` — non-immediate decrease; `effectiveBudgetTokens` stays
291
+ * at the live budget and `advisoryTargetTokens` carries the descent target.
292
+ * - `path: 'none'` — patch absent or budget untouched.
293
+ *
294
+ * `overrides` uses strategy-config names (`recentWindowTokens`,
295
+ * `kvStableReachTokens`) suitable for `previewContext`. `preparedWindowTokens`
296
+ * is deliberately never mapped: it is strategy instance state, not config,
297
+ * and cannot be simulated by a preview. A `kvStableReachTokens` override is
298
+ * omitted while a prepared window is in flight — the live runtime pace
299
+ * shadows the config value there, so the override would not be observed.
300
+ */
301
+ planRuntimeSettings(patch?: AgentRuntimeSettingsPatch): {
302
+ effectiveBudgetTokens: number;
303
+ /** The budget compiles currently plan at — the baseline the patch moves
304
+ * from. Lets callers distinguish a lowering from a no-op or an increase
305
+ * (an increase on an already-wedged agent never makes things worse). */
306
+ liveBudgetTokens: number;
307
+ advisoryTargetTokens?: number;
308
+ path: 'immediate' | 'paced' | 'none';
309
+ overrides: Record<string, unknown>;
310
+ } {
311
+ if (patch && Object.keys(patch).length > 0) {
312
+ this.validateRuntimeSettingsPatch(patch);
313
+ }
314
+ const hot = this.getHotContextSettings();
315
+ const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS;
316
+
317
+ const overrides: Record<string, unknown> = {};
318
+ if (patch?.tailTokens !== undefined) {
319
+ overrides.recentWindowTokens = patch.tailTokens;
320
+ }
321
+ if (
322
+ patch?.transitionPaceTokens !== undefined &&
323
+ hot?.preparedWindowTokens === undefined
324
+ ) {
325
+ overrides.kvStableReachTokens = patch.transitionPaceTokens;
326
+ }
327
+
328
+ if (patch?.contextBudgetTokens === undefined) {
329
+ return { effectiveBudgetTokens: live, liveBudgetTokens: live, path: 'none', overrides };
330
+ }
331
+ if (patch.contextBudgetTokens >= live || patch.immediate) {
332
+ return {
333
+ effectiveBudgetTokens: patch.contextBudgetTokens,
334
+ liveBudgetTokens: live,
335
+ path: 'immediate',
336
+ overrides,
337
+ };
338
+ }
339
+ return {
340
+ effectiveBudgetTokens: live,
341
+ liveBudgetTokens: live,
342
+ advisoryTargetTokens: patch.contextBudgetTokens,
343
+ path: 'paced',
344
+ overrides,
345
+ };
346
+ }
347
+
279
348
  getEffectiveSameRoundThinkTextPolicy(): SameRoundThinkTextPolicy {
280
349
  return this.runtimeSettingsOverrides.sameRoundThinkTextPolicy
281
350
  ?? this.configuredSameRoundThinkTextPolicy
package/src/api/server.ts CHANGED
@@ -8,8 +8,10 @@
8
8
 
9
9
  import { WebSocketServer, WebSocket } from 'ws';
10
10
  import { createServer, IncomingMessage, ServerResponse } from 'node:http';
11
+ import { timingSafeEqual } from 'node:crypto';
11
12
  import type { ContentBlock } from '@animalabs/membrane';
12
13
  import type { AgentFramework } from '../framework.js';
14
+ import { ResumeBlockedError } from '../framework.js';
13
15
  import type { TraceEvent, ProcessEvent } from '../types/index.js';
14
16
  import type {
15
17
  ApiServerConfig,
@@ -46,7 +48,9 @@ import type {
46
48
 
47
49
  export * from './types.js';
48
50
 
49
- const DEFAULT_CONFIG: Required<ApiServerConfig> = {
51
+ type ResolvedApiServerConfig = Required<Omit<ApiServerConfig, 'adminToken' | 'allowedOrigins'>> & Pick<ApiServerConfig, 'adminToken' | 'allowedOrigins'>;
52
+
53
+ const DEFAULT_CONFIG: ResolvedApiServerConfig = {
50
54
  port: 8765,
51
55
  host: 'localhost',
52
56
  path: '/ws',
@@ -56,11 +60,19 @@ const DEFAULT_CONFIG: Required<ApiServerConfig> = {
56
60
  /** Polling interval for subscriptions (ms) */
57
61
  const SUBSCRIPTION_POLL_INTERVAL = 50;
58
62
 
63
+ /** Constant-time token comparison (length leak is acceptable; content isn't). */
64
+ function timingSafeTokenEqual(presented: string, expected: string): boolean {
65
+ const a = Buffer.from(presented);
66
+ const b = Buffer.from(expected);
67
+ if (a.length !== b.length) return false;
68
+ return timingSafeEqual(a, b);
69
+ }
70
+
59
71
  /**
60
72
  * API Server for the agent framework.
61
73
  */
62
74
  export class ApiServer {
63
- private config: Required<ApiServerConfig>;
75
+ private config: ResolvedApiServerConfig;
64
76
  private framework: AgentFramework;
65
77
  private wss: WebSocketServer | null = null;
66
78
  private httpServer: ReturnType<typeof createServer> | null = null;
@@ -74,9 +86,37 @@ export class ApiServer {
74
86
 
75
87
  constructor(framework: AgentFramework, config: ApiServerConfig = {}) {
76
88
  this.framework = framework;
89
+ // An empty token would look configured while matching an empty header:
90
+ // the gate would pass for anyone. Refuse it at construction.
91
+ if (config.adminToken !== undefined && config.adminToken.length === 0) {
92
+ throw new Error('ApiServerConfig.adminToken must be non-empty when set (omit it to disable the gate)');
93
+ }
77
94
  this.config = { ...DEFAULT_CONFIG, ...config };
78
95
  }
79
96
 
97
+ /**
98
+ * WebSocket upgrade origin policy (see ApiServerConfig.allowedOrigins).
99
+ * Non-browser clients send no Origin and pass; a browser Origin must match
100
+ * this server's own host or the configured allow-list.
101
+ */
102
+ private originAllowed(origin: string | undefined, hostHeader: string | undefined): boolean {
103
+ if (origin === undefined || origin === '' || origin === 'null') return origin !== 'null';
104
+ let originHost: string;
105
+ try {
106
+ originHost = new URL(origin).host;
107
+ } catch {
108
+ return false;
109
+ }
110
+ if (hostHeader && originHost.toLowerCase() === hostHeader.toLowerCase()) return true;
111
+ return (this.config.allowedOrigins ?? []).some((allowed) => {
112
+ try {
113
+ return new URL(allowed).host.toLowerCase() === originHost.toLowerCase();
114
+ } catch {
115
+ return allowed.toLowerCase() === originHost.toLowerCase();
116
+ }
117
+ });
118
+ }
119
+
80
120
  /**
81
121
  * Start the API server.
82
122
  */
@@ -95,6 +135,14 @@ export class ApiServer {
95
135
  this.wss = new WebSocketServer({
96
136
  server: this.httpServer,
97
137
  path: this.config.path,
138
+ // Browsers skip CORS for WS handshakes, so the Origin check is the
139
+ // only thing standing between a visited web page and this command
140
+ // surface on a localhost bind (host.quiesce, undo, branch.delete…).
141
+ verifyClient: (info: { origin: string; req: IncomingMessage }, done: (ok: boolean, code?: number, msg?: string) => void) => {
142
+ const ok = this.originAllowed(info.origin, info.req.headers.host);
143
+ if (!ok) console.warn(`[api] rejected WebSocket upgrade from origin ${info.origin}`);
144
+ done(ok, 403, 'origin not allowed');
145
+ },
98
146
  });
99
147
 
100
148
  this.wss.on('connection', (ws) => {
@@ -357,11 +405,37 @@ export class ApiServer {
357
405
  case 'events.subscribe':
358
406
  return this.cmdEventsSubscribe(params as unknown as EventsSubscribeParams);
359
407
 
408
+ // Host quiesce/maintenance mode (issue #122). When an adminToken is
409
+ // configured, the mutating verbs require it here too — the WS surface
410
+ // must not be a token-free path to the same authority as the HTTP verbs.
411
+ case 'host.quiesce':
412
+ this.requireAdminToken(params);
413
+ return this.framework.quiesce(params as
414
+ { reason?: string; timeoutMs?: number; abandon?: boolean } | undefined);
415
+ case 'host.resume':
416
+ this.requireAdminToken(params);
417
+ return this.framework.resume(params as { force?: boolean } | undefined);
418
+ case 'host.status':
419
+ return this.framework.getHostModeStatus();
420
+ case 'host.maintenanceTick':
421
+ this.requireAdminToken(params);
422
+ return this.framework.maintenanceTick();
423
+
360
424
  default:
361
425
  throw new Error(`Unknown command: ${command}`);
362
426
  }
363
427
  }
364
428
 
429
+ /** WS-side admin gate for the mutating host verbs: no-op unless an
430
+ * adminToken is configured, then `params.adminToken` must match. */
431
+ private requireAdminToken(params?: Record<string, unknown>): void {
432
+ if (this.config.adminToken === undefined) return;
433
+ const presented = params?.adminToken;
434
+ if (typeof presented !== 'string' || !timingSafeTokenEqual(presented, this.config.adminToken)) {
435
+ throw new Error('adminToken required for host verbs on this server');
436
+ }
437
+ }
438
+
365
439
  // ==========================================================================
366
440
  // Conversation Commands
367
441
  // ==========================================================================
@@ -861,11 +935,94 @@ export class ApiServer {
861
935
  agents: agents.map((a) => ({ name: a.name, status: a.state.status })),
862
936
  clients: this.clients.size,
863
937
  queueDepth: this.framework.getQueueDepth(),
938
+ quiesced: this.framework.getHostModeStatus().quiesced,
864
939
  })
865
940
  );
866
941
  return;
867
942
  }
868
943
 
944
+ // Host quiesce/maintenance mode (issue #122). Options ride the query
945
+ // string — this file has no body parser and these verbs don't warrant
946
+ // introducing one. Auth: opt-in shared secret (config.adminToken); the
947
+ // default bind is localhost and the WS surface carries the same authority,
948
+ // so no ambient remoteAddress check is attempted (it breaks behind
949
+ // proxies both ways and adds nothing on a local bind).
950
+ if (url.pathname === '/hostmode') {
951
+ // Carries the operator's free-text reason; when a token is configured,
952
+ // reads require it too (ACAO:* makes this cross-origin readable).
953
+ if (this.config.adminToken !== undefined) {
954
+ const presented = req.headers['x-admin-token'];
955
+ if (typeof presented !== 'string' || !timingSafeTokenEqual(presented, this.config.adminToken)) {
956
+ res.writeHead(401, { 'Content-Type': 'application/json' });
957
+ res.end(JSON.stringify({ error: 'x-admin-token required' }));
958
+ return;
959
+ }
960
+ }
961
+ res.writeHead(200, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify(this.framework.getHostModeStatus()));
963
+ return;
964
+ }
965
+ const hostVerb = req.method === 'POST'
966
+ && ['/quiesce', '/resume', '/maintenance/tick'].includes(url.pathname);
967
+ if (hostVerb) {
968
+ // CSRF guard: the x-admin-token header is REQUIRED even when no token
969
+ // is configured (any value then). A custom header makes the request
970
+ // non-simple, forcing a CORS preflight — and the OPTIONS response only
971
+ // allows Content-Type, so browsers refuse to send it cross-origin.
972
+ // Without this, ACAO:* + query-string options made POST /quiesce a
973
+ // simple request any web page could fire at a localhost bind.
974
+ const presented = req.headers['x-admin-token'];
975
+ const authorized = typeof presented === 'string' && (
976
+ this.config.adminToken === undefined || timingSafeTokenEqual(presented, this.config.adminToken)
977
+ );
978
+ if (!authorized) {
979
+ res.writeHead(401, { 'Content-Type': 'application/json' });
980
+ res.end(JSON.stringify({
981
+ error: this.config.adminToken !== undefined
982
+ ? 'x-admin-token required (configured token mismatch or missing header)'
983
+ : 'x-admin-token header required (any value; forces CORS preflight)',
984
+ }));
985
+ return;
986
+ }
987
+ const respond = (code: number, body: unknown) => {
988
+ res.writeHead(code, { 'Content-Type': 'application/json' });
989
+ res.end(JSON.stringify(body));
990
+ };
991
+ void (async () => {
992
+ try {
993
+ if (url.pathname === '/quiesce') {
994
+ const timeoutMs = url.searchParams.get('timeoutMs');
995
+ respond(200, await this.framework.quiesce({
996
+ ...(url.searchParams.get('reason')
997
+ ? { reason: url.searchParams.get('reason')! } : {}),
998
+ ...(timeoutMs ? { timeoutMs: Number(timeoutMs) } : {}),
999
+ ...(url.searchParams.get('abandon') === 'true' ? { abandon: true } : {}),
1000
+ }));
1001
+ } else if (url.pathname === '/resume') {
1002
+ respond(200, await this.framework.resume(
1003
+ url.searchParams.get('force') === 'true' ? { force: true } : undefined,
1004
+ ));
1005
+ } else {
1006
+ respond(200, await this.framework.maintenanceTick());
1007
+ }
1008
+ } catch (error) {
1009
+ if (error instanceof ResumeBlockedError) {
1010
+ respond(409, {
1011
+ error: error.message,
1012
+ verdicts: error.verdicts,
1013
+ hostMode: this.framework.getHostModeStatus(),
1014
+ });
1015
+ } else {
1016
+ respond(500, { error: error instanceof Error ? error.message : String(error) });
1017
+ }
1018
+ }
1019
+ })().catch(() => {
1020
+ // The client went away mid-drain: respond() threw, and the fallback
1021
+ // respond(500) threw again. Nothing left to tell anyone.
1022
+ });
1023
+ return;
1024
+ }
1025
+
869
1026
  res.writeHead(404, { 'Content-Type': 'application/json' });
870
1027
  res.end(JSON.stringify({ error: 'Not found' }));
871
1028
  }
package/src/api/types.ts CHANGED
@@ -539,4 +539,19 @@ export interface ApiServerConfig {
539
539
  path?: string;
540
540
  /** Enable HTTP endpoints alongside WebSocket (default: true) */
541
541
  enableHttp?: boolean;
542
+ /** Opt-in shared secret for the HTTP host verbs (POST /quiesce, /resume,
543
+ * /maintenance/tick). When set, those requests must carry it in the
544
+ * `x-admin-token` header (401 otherwise). Unset preserves the historical
545
+ * bind-address-is-the-security-model behavior — the default bind is
546
+ * localhost; deployments that front the port with a proxy should set this. */
547
+ adminToken?: string;
548
+ /**
549
+ * Browser origins allowed to open the WebSocket. Browsers do not apply
550
+ * CORS to WebSocket handshakes, so without this check any web page the
551
+ * operator visits could drive the WS command surface on a localhost bind.
552
+ * Upgrades with no Origin header (non-browser clients) always pass; a
553
+ * browser Origin passes when it names this server's own host (the
554
+ * request's Host header) or appears here. Default: none beyond same-host.
555
+ */
556
+ allowedOrigins?: string[];
542
557
  }