@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/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +28 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +19 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +114 -37
- package/dist/src/framework.js.map +1 -1
- package/dist/src/types/agent.d.ts +12 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/test/agent-runtime-settings.test.js +37 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -1
- package/dist/test/assistant-persist-refusal.test.d.ts +2 -0
- package/dist/test/assistant-persist-refusal.test.d.ts.map +1 -0
- package/dist/test/assistant-persist-refusal.test.js +63 -0
- package/dist/test/assistant-persist-refusal.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/agent.ts +31 -1
- package/src/framework.ts +124 -38
- package/src/types/agent.ts +12 -0
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
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
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
:
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
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
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
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).
|
package/src/types/agent.ts
CHANGED
|
@@ -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 {
|