@animalabs/agent-framework 0.7.2 → 0.7.4

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 (31) hide show
  1. package/dist/src/agent.d.ts.map +1 -1
  2. package/dist/src/agent.js +28 -1
  3. package/dist/src/agent.js.map +1 -1
  4. package/dist/src/code-execution/py-runner.d.ts +9 -4
  5. package/dist/src/code-execution/py-runner.d.ts.map +1 -1
  6. package/dist/src/code-execution/py-runner.js +13 -6
  7. package/dist/src/code-execution/py-runner.js.map +1 -1
  8. package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
  9. package/dist/src/code-execution/tool-definition.js +4 -2
  10. package/dist/src/code-execution/tool-definition.js.map +1 -1
  11. package/dist/src/framework.d.ts +19 -0
  12. package/dist/src/framework.d.ts.map +1 -1
  13. package/dist/src/framework.js +114 -37
  14. package/dist/src/framework.js.map +1 -1
  15. package/dist/src/types/agent.d.ts +12 -0
  16. package/dist/src/types/agent.d.ts.map +1 -1
  17. package/dist/test/agent-runtime-settings.test.js +37 -0
  18. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  19. package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
  20. package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
  21. package/dist/test/assistant-persist-refusal.test.js +63 -0
  22. package/dist/test/assistant-persist-refusal.test.js.map +1 -0
  23. package/dist/test/code-execution.test.js +17 -6
  24. package/dist/test/code-execution.test.js.map +1 -1
  25. package/dist/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +1 -1
  27. package/src/agent.ts +31 -1
  28. package/src/code-execution/py-runner.ts +12 -6
  29. package/src/code-execution/tool-definition.ts +4 -2
  30. package/src/framework.ts +124 -38
  31. package/src/types/agent.ts +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution",
5
5
  "repository": {
6
6
  "type": "git",
package/src/agent.ts CHANGED
@@ -270,7 +270,11 @@ export class Agent {
270
270
 
271
271
  if (patch.contextBudgetTokens !== undefined) {
272
272
  const live = this.contextBudgetTokens ?? DEFAULT_CONTEXT_BUDGET_TOKENS;
273
- if (patch.contextBudgetTokens >= live) {
273
+ if (patch.contextBudgetTokens >= live || patch.immediate) {
274
+ // Increases are always immediate. Decreases take this path only with
275
+ // the explicit `immediate` flag: the next compile plans straight at
276
+ // the new budget — one-shot fold-down, KV invalidation paid this
277
+ // turn — and any in-flight descent is cancelled.
274
278
  nextContextBudget = patch.contextBudgetTokens;
275
279
  nextContextTarget = undefined;
276
280
  if (this.getHotContextSettings()) hotPatch.preparedWindowTokens = null;
@@ -298,6 +302,7 @@ export class Agent {
298
302
  }
299
303
  for (const [key, value] of Object.entries(patch)) {
300
304
  if (key === 'sameRoundThinkTextPolicy') continue;
305
+ if (key === 'immediate') continue; // a mode for this apply, never a persisted setting
301
306
  if (value !== undefined) (this.runtimeSettingsOverrides as Record<string, unknown>)[key] = value;
302
307
  }
303
308
  if (patch.sameRoundThinkTextPolicy !== undefined) {
@@ -706,6 +711,31 @@ export class Agent {
706
711
  * Called by framework when stream completes.
707
712
  */
708
713
  addAssistantResponse(content: ContentBlock[]): void {
714
+ // A turn whose entire output is thinking blocks produced NOTHING: no
715
+ // speech, no tool call. That is what a refusal looks like on the wire —
716
+ // the provider returns signed thinking (often with empty text, the
717
+ // fable-5 hidden-CoT packaging) and no content. Persisting it records an
718
+ // action that never happened, and two such records landing adjacent are
719
+ // actively toxic: formatters merge consecutive same-role messages
720
+ // (membrane `mergeConsecutiveRoles`), producing one assistant message
721
+ // carrying signed thinking from two different responses, which the
722
+ // provider cannot verify — 400 "`thinking` or `redacted_thinking` blocks
723
+ // in the latest assistant message cannot be modified", unrecoverable by
724
+ // retry (labclaude, 2026-07-27: hard down ~4h on exactly this pair).
725
+ //
726
+ // Refused turns are surfaced by the refusal path (stderr, failures.log,
727
+ // the [inference-failed] notice); the store does not also need a
728
+ // content-free artifact of them. Loud, never silent.
729
+ if (content.length > 0 && content.every(
730
+ (b) => b.type === 'thinking' || b.type === 'redacted_thinking',
731
+ )) {
732
+ console.error(
733
+ `[assistant-persist] agent=${this.name}: refusing to store a thinking-only assistant ` +
734
+ `message (${content.length} block(s), no text and no tool_use) — the turn produced no ` +
735
+ `output. See the refusal/inference-failed record for why.`,
736
+ );
737
+ return;
738
+ }
709
739
  this.contextManager.addMessage(this.name, content);
710
740
  }
711
741
 
@@ -404,10 +404,15 @@ export class PyRunner {
404
404
  }
405
405
 
406
406
  /**
407
- * Map framework tool names to python identifiers: `--` becomes `__`.
408
- * Names that still aren't valid identifiers, or that collide after
409
- * sanitization, are skipped loudly — a skipped tool is unreachable from
410
- * scripts (the exact-name tools[...] dict is built from this same list).
407
+ * Map framework tool names to python identifiers: `--` (the framework's
408
+ * prefix separator) becomes `__`, and every remaining character that is not
409
+ * valid in a python identifier becomes `_` (fleet reality: module and server
410
+ * ids contain single hyphens — `mcpl-admin`, `dog-events` — found live on
411
+ * the first canary run, 2026-07-26). A leading digit gets a `_` prefix.
412
+ *
413
+ * Names that sanitize to nothing, or that collide after sanitization, are
414
+ * skipped loudly — a skipped tool is unreachable from scripts (the
415
+ * exact-name tools[...] dict is built from this same list).
411
416
  */
412
417
  export function buildInjectedTools(
413
418
  toolNames: string[],
@@ -416,9 +421,10 @@ export function buildInjectedTools(
416
421
  const seen = new Map<string, string>();
417
422
  const injected: InjectedTool[] = [];
418
423
  for (const toolName of toolNames) {
419
- const pyName = toolName.replace(/--/g, '__');
424
+ let pyName = toolName.replace(/--/g, '__').replace(/[^A-Za-z0-9_]/g, '_');
425
+ if (/^[0-9]/.test(pyName)) pyName = '_' + pyName;
420
426
  if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(pyName)) {
421
- log(`[pytc] tool '${toolName}' skipped: '${pyName}' is not a valid python identifier`);
427
+ log(`[pytc] tool '${toolName}' skipped: cannot derive a python identifier`);
422
428
  continue;
423
429
  }
424
430
  const existing = seen.get(pyName);
@@ -23,8 +23,10 @@ export function buildCodeExecutionToolDefinition(opts?: {
23
23
  description:
24
24
  'Run Python code that can call your other tools programmatically. ' +
25
25
  'Every tool you have is available inside the script as an async Python function: ' +
26
- "the function name is the tool name with '--' replaced by '__' " +
27
- "(e.g. tool 'mcpl--discord--fetch_history' is the function mcpl__discord__fetch_history). " +
26
+ "the function name is the tool name with '--' replaced by '__' and any other " +
27
+ "non-identifier character replaced by '_' " +
28
+ "(e.g. tool 'mcpl--discord--fetch_history' is the function mcpl__discord__fetch_history, " +
29
+ "and tool 'mcpl--dog-events--status' is mcpl__dog_events__status). " +
28
30
  'Each function takes a single dict of arguments and returns a string — the same text ' +
29
31
  'the tool would have returned to you directly; parse structured results with json.loads. ' +
30
32
  "An exact-name lookup dict is also available: tools['mcpl--discord--fetch_history']({...}). " +
package/src/framework.ts CHANGED
@@ -2210,6 +2210,12 @@ export class AgentFramework {
2210
2210
  context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
2211
2211
  tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
2212
2212
  transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
2213
+ immediate: {
2214
+ type: 'boolean',
2215
+ description:
2216
+ 'With a context_budget_tokens DECREASE: apply it immediately instead of a paced descent — ' +
2217
+ 'the whole fold-down and its KV re-read land on the next compile, and any in-flight descent is cancelled.',
2218
+ },
2213
2219
  same_round_think_text_policy: {
2214
2220
  type: 'string',
2215
2221
  enum: ['public', 'private'],
@@ -3754,7 +3760,27 @@ export class AgentFramework {
3754
3760
  return;
3755
3761
  }
3756
3762
 
3757
- const id = this.addMessage('user', event.content, metadata);
3763
+ // Addressed-while-closed invitation — parity with the push-event path
3764
+ // (channels.publish surfaces like portal-mcpl deliver mentions here).
3765
+ const incomingContent = [...event.content];
3766
+ {
3767
+ const invitation = this.buildClosedChannelInvitation({
3768
+ serverId: event.serverId,
3769
+ channelId: event.channelId,
3770
+ messageId: event.messageId,
3771
+ authorName: event.author?.name || 'Someone',
3772
+ guildName: typeof event.metadata?.guildName === 'string' && event.metadata.guildName
3773
+ ? (event.metadata.guildName as string)
3774
+ : undefined,
3775
+ tags: event.tags,
3776
+ });
3777
+ if (invitation) {
3778
+ incomingContent.push(invitation.block);
3779
+ Object.assign(metadata, invitation.metadataPatch);
3780
+ }
3781
+ }
3782
+
3783
+ const id = this.addMessage('user', incomingContent, metadata);
3758
3784
  this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
3759
3785
 
3760
3786
  if (event.triggerInference) {
@@ -4037,6 +4063,67 @@ export class AgentFramework {
4037
4063
  }
4038
4064
  }
4039
4065
 
4066
+ /**
4067
+ * Build the addressed-while-CLOSED channel invitation block, shared by the
4068
+ * push-event and channels/incoming ingest paths. Until 2026-07-27 only push
4069
+ * events (discord-mcpl mentions) carried it; channels.publish surfaces
4070
+ * (portal-mcpl) delivered bare mentions with no guidance and no route — an
4071
+ * explicit-prose agent's natural plain reply bounced with nowhere to go
4072
+ * (Rhys's first portal mention: a 2,320-char reply stranded).
4073
+ *
4074
+ * Wording: option 1 teaches the `>>` destination prefix, which delivers for
4075
+ * BOTH legacy (host-inferred) and explicit prose-routing agents. The
4076
+ * previous "simply write your reply as normal text" was a false promise
4077
+ * under explicit routing (the metadata flags it set had no consumers).
4078
+ *
4079
+ * Human-readable names wherever the surface provided them — a mind reading
4080
+ * this should see WHO addressed it WHERE, not a wall of snowflake ids. Raw
4081
+ * ids remain only inside the tool-argument instructions, where they are the
4082
+ * literal values to pass.
4083
+ */
4084
+ private buildClosedChannelInvitation(opts: {
4085
+ serverId: string;
4086
+ channelId: string;
4087
+ channelLabel?: string;
4088
+ messageId: string;
4089
+ authorName: string;
4090
+ guildName?: string;
4091
+ tags?: string[];
4092
+ }): { block: ContentBlock; metadataPatch: Record<string, unknown> } | null {
4093
+ if (!this.channelRegistry) return null;
4094
+ if (!opts.tags?.includes('chat:addressed')) return null;
4095
+ if (this.channelRegistry.isChannelOpen(opts.channelId)) return null;
4096
+ const descriptor = this.channelRegistry.getDescriptor(opts.channelId);
4097
+ const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
4098
+ const label = opts.channelLabel ?? descriptor?.label;
4099
+ const channelLabel = label ? `#${label}` : `"${opts.channelId}"`;
4100
+ const place = opts.guildName ? `${channelLabel} in "${opts.guildName}"` : channelLabel;
4101
+ const block: ContentBlock = {
4102
+ type: 'text',
4103
+ text:
4104
+ `\n[Channel invitation] ${opts.authorName} addressed you in ${place} — ` +
4105
+ `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4106
+ `Your options:\n` +
4107
+ `1. Reply without joining — write your reply this turn prefixed with ">>${channelLabel}"; it will be delivered there.\n` +
4108
+ `2. Join the channel — call channel_open with channelId "${opts.channelId}" and serverId "${opts.serverId}"` +
4109
+ (maxBackscroll > 0
4110
+ ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${opts.messageId}".\n`
4111
+ : `.\n`) +
4112
+ `3. Stay out — call channel_decline with channelId "${opts.channelId}", serverId "${opts.serverId}", ` +
4113
+ `and messageId "${opts.messageId}" (optionally set acknowledge to an emoji like 👀 so ${opts.authorName} isn't left hanging).\n` +
4114
+ `Doing nothing is also fine.`,
4115
+ };
4116
+ return {
4117
+ block,
4118
+ metadataPatch: {
4119
+ channelInvitation: true,
4120
+ channelOpen: false,
4121
+ channelId: opts.channelId,
4122
+ invitationMessageId: opts.messageId,
4123
+ },
4124
+ };
4125
+ }
4126
+
4040
4127
  /**
4041
4128
  * Convert an MCPL push event to a context message.
4042
4129
  */
@@ -4070,45 +4157,21 @@ export class AgentFramework {
4070
4157
  }
4071
4158
 
4072
4159
  const content = [...event.content];
4073
- const addressedWhileClosed = triggerChannel &&
4074
- event.tags?.includes('chat:addressed') &&
4075
- this.channelRegistry &&
4076
- !this.channelRegistry.isChannelOpen(triggerChannel.channelId);
4077
- if (addressedWhileClosed) {
4078
- const descriptor = this.channelRegistry!.getDescriptor(triggerChannel.channelId);
4079
- const messageId = typeof event.origin?.messageId === 'string'
4080
- ? event.origin.messageId
4081
- : event.eventId;
4082
- const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
4083
- // Resolve human-readable names from the event origin wherever the
4084
- // surface provided them — a mind reading this should see WHO addressed
4085
- // it WHERE, not a wall of snowflake ids. Raw ids remain only inside the
4086
- // tool-argument instructions, where they are the literal values to pass.
4160
+ if (triggerChannel) {
4087
4161
  const origin = (event.origin ?? {}) as Record<string, unknown>;
4088
- const authorName = typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone';
4089
- const channelLabel = triggerChannel.label ? `#${triggerChannel.label}` : `"${triggerChannel.channelId}"`;
4090
- const place = typeof origin.guildName === 'string' && origin.guildName
4091
- ? `${channelLabel} in "${origin.guildName}"`
4092
- : channelLabel;
4093
- content.push({
4094
- type: 'text',
4095
- text:
4096
- `\n[Channel invitation] ${authorName} addressed you in ${place} — ` +
4097
- `a channel you haven't joined. You're seeing this one message; you won't receive more from it unless you join. ` +
4098
- `Your options:\n` +
4099
- `1. Reply without joining — simply write your reply as normal text this turn; it will be delivered to ${channelLabel}.\n` +
4100
- `2. Join the channel — call channel_open with channelId "${triggerChannel.channelId}" and serverId "${event.serverId}"` +
4101
- (maxBackscroll > 0
4102
- ? `; to also read recent history, add backscroll (a number up to ${maxBackscroll}) and beforeMessageId "${messageId}".\n`
4103
- : `.\n`) +
4104
- `3. Stay out — call channel_decline with channelId "${triggerChannel.channelId}", serverId "${event.serverId}", ` +
4105
- `and messageId "${messageId}" (optionally set acknowledge to an emoji like 👀 so ${authorName} isn't left hanging).\n` +
4106
- `Doing nothing is also fine.`,
4162
+ const invitation = this.buildClosedChannelInvitation({
4163
+ serverId: event.serverId,
4164
+ channelId: triggerChannel.channelId,
4165
+ channelLabel: triggerChannel.label,
4166
+ messageId: typeof origin.messageId === 'string' ? origin.messageId : event.eventId,
4167
+ authorName: typeof origin.authorName === 'string' && origin.authorName ? origin.authorName : 'Someone',
4168
+ guildName: typeof origin.guildName === 'string' && origin.guildName ? origin.guildName : undefined,
4169
+ tags: event.tags,
4107
4170
  });
4108
- metadata.channelInvitation = true;
4109
- metadata.channelOpen = false;
4110
- metadata.channelId = triggerChannel.channelId;
4111
- metadata.invitationMessageId = messageId;
4171
+ if (invitation) {
4172
+ content.push(invitation.block);
4173
+ Object.assign(metadata, invitation.metadataPatch);
4174
+ }
4112
4175
  }
4113
4176
 
4114
4177
  const id = this.addMessage('user', content, metadata);
@@ -4643,6 +4706,21 @@ export class AgentFramework {
4643
4706
  this.eventGate?.onInferenceStarted(agent.name);
4644
4707
  this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
4645
4708
 
4709
+ // Typing indicator, started at TURN START. It says "attending", which is
4710
+ // true from the moment the turn is accepted — but it used to start inside
4711
+ // driveStream, i.e. only after module gatherContext + MCPL beforeInference
4712
+ // RPCs + the full context compile + stream initiation. All of that read as
4713
+ // dead air to whoever just messaged the agent (30+ s during the 2026-07
4714
+ // mythos compile regression; still seconds of hooks+compile after).
4715
+ // Same channel choice as driveStream's own startTyping (which stays, is
4716
+ // idempotent per channel, and owns the 7s refresh); the catch below stops
4717
+ // it on the no-driveStream failure paths (e.g. a compile refusal).
4718
+ const earlyTypingChannel =
4719
+ agent.proseRouting === 'explicit'
4720
+ ? trigger?.channelId ?? null
4721
+ : this.turnLocusPins.get(agent.name) ?? null;
4722
+ if (earlyTypingChannel) this.channelRegistry?.startTyping(earlyTypingChannel);
4723
+
4646
4724
  try {
4647
4725
  const requestSnapshot = this.captureInferenceToolSnapshot(agent);
4648
4726
  const allTools = this.getToolsForAgent(agent.name, requestSnapshot);
@@ -4700,6 +4778,10 @@ export class AgentFramework {
4700
4778
  );
4701
4779
  this.activeStreams.set(agent.name, handle);
4702
4780
  } catch (error) {
4781
+ // The early typing indicator has no driveStream `finally` on this path —
4782
+ // a hook/compile/stream-setup failure must not leave "typing…" stuck.
4783
+ // (Retries below restart it; stopTyping is idempotent.)
4784
+ this.channelRegistry?.stopTyping();
4703
4785
  const err = error instanceof Error ? error : new Error(String(error));
4704
4786
  this.emitTrace({
4705
4787
  type: 'inference:failed',
@@ -8459,6 +8541,7 @@ export class AgentFramework {
8459
8541
  tail_tokens?: unknown;
8460
8542
  transition_pace_tokens?: unknown;
8461
8543
  same_round_think_text_policy?: unknown;
8544
+ immediate?: unknown;
8462
8545
  settings?: unknown;
8463
8546
  } & Record<string, unknown>;
8464
8547
  const extensions = this.collectAgentSettingsExtensions();
@@ -8490,6 +8573,9 @@ export class AgentFramework {
8490
8573
  if (input.same_round_think_text_policy !== undefined) {
8491
8574
  patch.sameRoundThinkTextPolicy = input.same_round_think_text_policy as AgentRuntimeSettingsPatch['sameRoundThinkTextPolicy'];
8492
8575
  }
8576
+ // Immediate budget decrease: skip the paced descent; the next
8577
+ // compile plans straight at the new budget (one-shot fold-down).
8578
+ if (input.immediate !== undefined) patch.immediate = Boolean(input.immediate);
8493
8579
  // Route extension-owned keys to their modules; apply the core patch
8494
8580
  // only when it touches core keys (an extension-only update must not
8495
8581
  // disturb a converging budget transition).
@@ -151,6 +151,18 @@ export interface AgentRuntimeSettingsPatch {
151
151
  tailTokens?: number;
152
152
  transitionPaceTokens?: number;
153
153
  sameRoundThinkTextPolicy?: SameRoundThinkTextPolicy;
154
+ /**
155
+ * Apply a `contextBudgetTokens` DECREASE immediately instead of starting a
156
+ * paced descent: the next compile plans straight at the new budget and the
157
+ * whole fold-down (and its KV invalidation) is paid on that one turn. Also
158
+ * cancels any in-flight descent. No effect on increases (already immediate)
159
+ * or on other keys. A MODE for this apply, not a setting — never persisted.
160
+ *
161
+ * This is the emergency lever: when window content must shrink NOW
162
+ * (refusal streaks, over-wall wedges), a converging descent is the wrong
163
+ * tool and used to be the only one (mythos, 2026-07-26).
164
+ */
165
+ immediate?: boolean;
154
166
  }
155
167
 
156
168
  export interface AgentRuntimeSettingsSnapshot {