@animalabs/agent-framework 0.7.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@animalabs/agent-framework",
3
- "version": "0.7.3",
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
 
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 {