@animalabs/agent-framework 0.20.0 → 0.21.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 (43) hide show
  1. package/dist/src/framework.d.ts.map +1 -1
  2. package/dist/src/framework.js +6 -0
  3. package/dist/src/framework.js.map +1 -1
  4. package/dist/src/mcpl/conversation-router.d.ts +8 -6
  5. package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
  6. package/dist/src/mcpl/conversation-router.js +12 -6
  7. package/dist/src/mcpl/conversation-router.js.map +1 -1
  8. package/dist/src/mcpl/references.d.ts.map +1 -1
  9. package/dist/src/mcpl/references.js +9 -4
  10. package/dist/src/mcpl/references.js.map +1 -1
  11. package/dist/src/mcpl/tool-lifecycle.d.ts +1 -1
  12. package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
  13. package/dist/src/mcpl/tool-lifecycle.js +19 -4
  14. package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
  15. package/dist/src/mcpl/transport.d.ts +7 -4
  16. package/dist/src/mcpl/transport.d.ts.map +1 -1
  17. package/dist/src/mcpl/transport.js +45 -13
  18. package/dist/src/mcpl/transport.js.map +1 -1
  19. package/dist/src/mcpl/types.d.ts +3 -1
  20. package/dist/src/mcpl/types.d.ts.map +1 -1
  21. package/dist/src/mcpl/types.js.map +1 -1
  22. package/dist/test/conversation-router.test.js +33 -19
  23. package/dist/test/conversation-router.test.js.map +1 -1
  24. package/dist/test/conversation-routing.test.js +89 -0
  25. package/dist/test/conversation-routing.test.js.map +1 -1
  26. package/dist/test/mcpl-child-env.test.js +89 -0
  27. package/dist/test/mcpl-child-env.test.js.map +1 -1
  28. package/dist/test/mcpl-reference-fields.test.d.ts +2 -0
  29. package/dist/test/mcpl-reference-fields.test.d.ts.map +1 -0
  30. package/dist/test/mcpl-reference-fields.test.js +82 -0
  31. package/dist/test/mcpl-reference-fields.test.js.map +1 -0
  32. package/dist/test/tools-observe-parser-conformance.test.d.ts +2 -0
  33. package/dist/test/tools-observe-parser-conformance.test.d.ts.map +1 -0
  34. package/dist/test/tools-observe-parser-conformance.test.js +129 -0
  35. package/dist/test/tools-observe-parser-conformance.test.js.map +1 -0
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +2 -2
  38. package/src/framework.ts +6 -0
  39. package/src/mcpl/conversation-router.ts +12 -8
  40. package/src/mcpl/references.ts +11 -3
  41. package/src/mcpl/tool-lifecycle.ts +19 -4
  42. package/src/mcpl/transport.ts +41 -9
  43. package/src/mcpl/types.ts +3 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.20.0",
3
+ "version": "0.21.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.12.0",
50
+ "@animalabs/context-manager": "^0.13.0",
51
51
  "@animalabs/membrane": "^0.5.78",
52
52
  "chokidar": "^4.0.3",
53
53
  "discord.js": "^14.25.1",
package/src/framework.ts CHANGED
@@ -6994,6 +6994,11 @@ export class AgentFramework {
6994
6994
  if (this.conversationRouter && this.conversationAgentHomes.has(target.name)) {
6995
6995
  metadata.triggered = event.triggerInference ?? false;
6996
6996
  const id = target.getContextManager().addMessage('user', event.content, metadata);
6997
+ // A correction may target an older engagement. Refresh only the
6998
+ // binding whose agent actually received the message.
6999
+ if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) {
7000
+ this.conversationRouter.touch(event.channelId);
7001
+ }
6997
7002
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
6998
7003
  if (event.triggerInference) {
6999
7004
  this.pendingRequests.push({
@@ -7173,6 +7178,7 @@ export class AgentFramework {
7173
7178
  messageMetadata.triggered = trigger;
7174
7179
 
7175
7180
  const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
7181
+ router.touch(event.channelId);
7176
7182
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
7177
7183
 
7178
7184
  if (trigger) {
@@ -95,8 +95,6 @@ export interface IncomingMessageFacts {
95
95
  mentioned: boolean;
96
96
  /** Channel classification (see classifyChannel). */
97
97
  kind: ChannelKind;
98
- /** Clock injection for tests; defaults to Date.now(). */
99
- now?: number;
100
98
  }
101
99
 
102
100
  export type RoutingDecision =
@@ -149,17 +147,15 @@ export class ConversationRouter {
149
147
  }
150
148
 
151
149
  /**
152
- * Decide where an incoming message goes. Pure read except for refreshing
153
- * lastActivity on an existing binding — a 'spawn' decision does NOT create
154
- * the binding; the framework calls bind() after the fork agent actually
155
- * exists, so a failed spawn doesn't leave a dangling route.
150
+ * Decide where an incoming message goes without changing router state.
151
+ * The framework calls bind() after a fork exists and touch() after a
152
+ * message is delivered. Queries and failed deliveries leave idle clocks
153
+ * unchanged; a failed spawn doesn't leave a dangling route.
156
154
  */
157
155
  route(facts: IncomingMessageFacts): RoutingDecision {
158
- const now = facts.now ?? Date.now();
159
156
  const existing = this.bindings.get(facts.channelId);
160
157
 
161
158
  if (existing) {
162
- existing.lastActivity = now;
163
159
  return {
164
160
  kind: 'existing',
165
161
  agentName: existing.agentName,
@@ -202,6 +198,14 @@ export class ConversationRouter {
202
198
  return binding;
203
199
  }
204
200
 
201
+ /** Record delivered activity on an existing binding. Does not bind a
202
+ * channel or advance its generation. Call after successful delivery, not
203
+ * when previewing a route. The optional clock supports deterministic use. */
204
+ touch(channelId: string, now = Date.now()): void {
205
+ const binding = this.bindings.get(channelId);
206
+ if (binding) binding.lastActivity = now;
207
+ }
208
+
205
209
  unbind(channelId: string): void {
206
210
  this.bindings.delete(channelId);
207
211
  }
@@ -56,12 +56,20 @@ function isRecord(x: unknown): x is Record<string, unknown> {
56
56
 
57
57
  function takeString(
58
58
  raw: unknown, field: keyof typeof REFERENCE_LIMITS & string,
59
- out: { rejected: string[]; truncated: string[] },
59
+ out: { rejected: string[] },
60
60
  ): string | undefined {
61
61
  if (raw === undefined) return undefined;
62
62
  if (typeof raw !== 'string') { out.rejected.push(field); return undefined; }
63
- const limit = REFERENCE_LIMITS[field];
64
- if (raw.length > limit) { out.truncated.push(field); return raw.slice(0, limit); }
63
+ // JSON Schema maxLength counts Unicode code points, not UTF-16 units.
64
+ // Stop as soon as the optional field is invalid, without allocating a copy
65
+ // of an arbitrarily large server string. Display truncation happens later.
66
+ let length = 0;
67
+ for (const _codePoint of raw) {
68
+ if (++length > REFERENCE_LIMITS[field]) {
69
+ out.rejected.push(field);
70
+ return undefined;
71
+ }
72
+ }
65
73
  return raw;
66
74
  }
67
75
 
@@ -106,7 +106,7 @@ export interface ToolLifecycleConfig {
106
106
  export const DEFAULT_MAX_INPUT_BYTES = 16 * 1024;
107
107
 
108
108
  /** Accepted sizes for a `tools/observe` request (RFC-007 §6.6 asks hosts to
109
- * accept at least 64 rules, 64 paths per rule, 256-character strings). */
109
+ * accept at least 64 rules, 64 paths per rule, 256-code-point strings). */
110
110
  export const TOOL_OBSERVE_LIMITS = {
111
111
  rules: 256,
112
112
  pathsPerRule: 256,
@@ -128,6 +128,16 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
128
128
  return typeof value === 'object' && value !== null && !Array.isArray(value);
129
129
  }
130
130
 
131
+ /** JSON Schema maxLength counts code points, not UTF-16 code units.
132
+ * Stop at the limit rather than allocating a copy of an oversized input. */
133
+ function exceedsCodePointLimit(value: string, limit: number): boolean {
134
+ let count = 0;
135
+ for (const _point of value) {
136
+ if (++count > limit) return true;
137
+ }
138
+ return false;
139
+ }
140
+
131
141
  /**
132
142
  * Validate `tools/observe` params. `rules` absent or null clears the filter
133
143
  * (`{ok, rules: null}`); `[]` is a valid filter that reports nothing. Unknown
@@ -135,7 +145,9 @@ function isPlainObject(value: unknown): value is Record<string, unknown> {
135
145
  * make a rule match more than its author wrote.
136
146
  */
137
147
  export function parseToolObserveParams(params: unknown): ToolObserveParseResult {
138
- if (params === undefined || params === null) return { ok: true, rules: null };
148
+ // Omitted transport params normalize to {}, but explicit JSON null is
149
+ // not an object and must not clear an existing restrictive filter.
150
+ if (params === undefined) return { ok: true, rules: null };
139
151
  if (!isPlainObject(params)) return { ok: false, message: 'tools/observe params must be an object' };
140
152
  // Unknown members are rejected here too (MCP's own `_meta` excepted): a
141
153
  // misspelled `rules` must not read as "no rules" and silently clear a
@@ -145,6 +157,9 @@ export function parseToolObserveParams(params: unknown): ToolObserveParseResult
145
157
  return { ok: false, message: `tools/observe: unknown params member "${key}"` };
146
158
  }
147
159
  }
160
+ if (params._meta !== undefined && !isPlainObject(params._meta)) {
161
+ return { ok: false, message: 'tools/observe: _meta must be an object' };
162
+ }
148
163
  const raw = params.rules;
149
164
  if (raw === undefined || raw === null) return { ok: true, rules: null };
150
165
  if (!Array.isArray(raw)) return { ok: false, message: 'tools/observe: rules must be an array or null' };
@@ -173,7 +188,7 @@ export function parseToolObserveParams(params: unknown): ToolObserveParseResult
173
188
  continue;
174
189
  }
175
190
  if (typeof value !== 'string') return { ok: false, message: `tools/observe: ${at}.match.${key} must be a string` };
176
- if (value.length > TOOL_OBSERVE_LIMITS.stringLength) {
191
+ if (exceedsCodePointLimit(value, TOOL_OBSERVE_LIMITS.stringLength)) {
177
192
  return { ok: false, message: `tools/observe: ${at}.match.${key} is too long`, data: { limit: 'stringLength' } };
178
193
  }
179
194
  (match as Record<string, string>)[key] = value;
@@ -196,7 +211,7 @@ export function parseToolObserveParams(params: unknown): ToolObserveParseResult
196
211
  if (typeof path !== 'string' || path.length === 0) {
197
212
  return { ok: false, message: `tools/observe: ${at}.input must hold non-empty strings` };
198
213
  }
199
- if (path.length > TOOL_OBSERVE_LIMITS.stringLength) {
214
+ if (exceedsCodePointLimit(path, TOOL_OBSERVE_LIMITS.stringLength)) {
200
215
  return { ok: false, message: `tools/observe: ${at}.input path is too long`, data: { limit: 'stringLength' } };
201
216
  }
202
217
  paths.push(path);
@@ -104,10 +104,12 @@ export abstract class McplTransport extends EventEmitter {
104
104
  /**
105
105
  * Host variables a stdio child gets by default: just enough for an ordinary
106
106
  * process to run (find binaries, a home dir, temp dir, locale, TLS roots,
107
- * a display for GUI helpers). Everything else in the host env — notably API
107
+ * network proxies, a display for GUI helpers). Everything else in the host env — notably API
108
108
  * keys and bot tokens — is NOT passed on; a server that needs a variable must
109
109
  * declare it in its `env` (recipes can `${VAR}`-substitute from .env), or set
110
- * `inheritEnv: true` to get the whole host env as before.
110
+ * `inheritEnv: true` to get the whole host env as before. Proxy URLs may include
111
+ * credentials. This limits accidental inheritance, not access by a hostile
112
+ * same-user child process; it is not a process isolation boundary.
111
113
  */
112
114
  export const CHILD_ENV_ALLOWLIST = [
113
115
  'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TERM', 'LANG', 'TZ',
@@ -115,24 +117,54 @@ export const CHILD_ENV_ALLOWLIST = [
115
117
  'DISPLAY', 'WAYLAND_DISPLAY',
116
118
  'XDG_RUNTIME_DIR', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME',
117
119
  'SSL_CERT_FILE', 'SSL_CERT_DIR', 'NODE_EXTRA_CA_CERTS',
120
+ 'REQUESTS_CA_BUNDLE', 'CURL_CA_BUNDLE',
121
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
122
+ 'http_proxy', 'https_proxy', 'no_proxy',
118
123
  '__CF_USER_TEXT_ENCODING',
119
124
  // Windows equivalents, harmless elsewhere.
120
125
  'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA', 'USERPROFILE',
121
126
  ];
122
127
 
123
128
  /** Environment for a stdio child: allowlisted host vars (+ LC_*), then the
124
- * server's declared `env` on top. `inheritEnv: true` restores full inheritance. */
129
+ * server's declared `env` on top. Windows names merge case-insensitively;
130
+ * POSIX names remain case-sensitive. `inheritEnv: true` restores full inheritance. */
125
131
  export function buildChildEnv(
126
132
  config: Pick<McplServerConfig, 'env' | 'inheritEnv'>,
127
133
  hostEnv: NodeJS.ProcessEnv = process.env,
134
+ platform: NodeJS.Platform = process.platform,
128
135
  ): NodeJS.ProcessEnv {
129
- if (config.inheritEnv) return { ...hostEnv, ...config.env };
130
- const env: NodeJS.ProcessEnv = {};
131
- for (const [key, value] of Object.entries(hostEnv)) {
132
- if (value === undefined) continue;
133
- if (CHILD_ENV_ALLOWLIST.includes(key) || key.startsWith('LC_')) env[key] = value;
136
+ const win = platform === 'win32';
137
+ const env: NodeJS.ProcessEnv = config.inheritEnv ? { ...hostEnv } : {};
138
+ if (!config.inheritEnv) {
139
+ for (const [key, value] of Object.entries(hostEnv)) {
140
+ if (value === undefined) continue;
141
+ // Windows commonly enumerates Path/SystemRoot rather than PATH/SYSTEMROOT.
142
+ const probe = win ? key.toUpperCase() : key;
143
+ if (CHILD_ENV_ALLOWLIST.includes(probe) || probe.startsWith('LC_')) env[key] = value;
144
+ }
145
+ }
146
+ if (!win) return { ...env, ...config.env };
147
+
148
+ // Windows spawn folds environment names case-insensitively and may choose
149
+ // an earlier host spelling over a differently cased declared override.
150
+ // Select Node's first lexicographic spelling within each source, then layer
151
+ // declared values (including '') over host values. Source precedence and
152
+ // duplicate-name resolution are separate decisions.
153
+ const merged: NodeJS.ProcessEnv = {};
154
+ const spellings = new Map<string, string>();
155
+ for (const source of [env, config.env ?? {}]) {
156
+ const seen = new Set<string>();
157
+ for (const key of Object.keys(source).sort()) {
158
+ const canonical = key.toUpperCase();
159
+ if (seen.has(canonical)) continue;
160
+ seen.add(canonical);
161
+ const previous = spellings.get(canonical);
162
+ if (previous !== undefined && previous !== key) delete merged[previous];
163
+ merged[key] = source[key];
164
+ spellings.set(canonical, key);
165
+ }
134
166
  }
135
- return { ...env, ...config.env };
167
+ return merged;
136
168
  }
137
169
 
138
170
  export class StdioTransport extends McplTransport {
package/src/mcpl/types.ts CHANGED
@@ -229,7 +229,9 @@ export interface McplServerConfig {
229
229
  /**
230
230
  * Environment variables for the child process. Stdio children do NOT inherit
231
231
  * the host environment wholesale: they get a small allowlist (PATH, HOME,
232
- * LANG, LC_*, TMPDIR, ... — see CHILD_ENV_ALLOWLIST) plus exactly these.
232
+ * LANG, LC_*, TMPDIR, TLS CA bundles, and HTTP(S) proxy settings — see
233
+ * CHILD_ENV_ALLOWLIST) plus exactly these. Proxy URLs can contain credentials.
234
+ * This limits accidental inheritance; it does not isolate a hostile child.
233
235
  */
234
236
  env?: Record<string, string>;
235
237