Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/skip-reply-wake-values.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
- `skip_reply` reads `wake_in_seconds` one way for every part of the host: a number of seconds from 1 to 3600, or a string that reads as one, taken in whole seconds. A larger number waits 3600, `Infinity` included, where before `Infinity` armed nothing and the turn ended as an ordinary skip. The tool's description of the parameter now says "from 1 to 3600; a larger number waits 3600" in place of "clamped to 1–3600".
- With an EventGate, a `wake_in_seconds` that isn't such a number is refused as a tool error that names the value. That covers a number below 1, such as `0`, a negative or `0.5`, as well as `"soon"`, `""`, `true`, an array or an object. It arms nothing and doesn't end the turn. Before, `0`, negatives and `"soon"` ended the turn with no wake, a number between 0 and 1 such as `0.5` armed a wake of 1 s, and `true` and `[5]` armed wakes of 1 s and 5 s. A number below 1 is refused rather than raised to 1, so that `0`, which a model may write to fill an optional number, can't become a 1-second wake after every turn.
- Without an EventGate, any `wake_in_seconds` other than absent or `null` is now refused, as a positive number, `true` and `[5]` already were. `0`, negatives, `Infinity`, a blank string and values `Number()` can't read used to end the turn as an ordinary skip, which dropped the wake the call asked for without saying so.
- A `skip_reply` call whose input is `null` ends the turn as an ordinary skip, where the channel registry's handler threw reading `wake_in_seconds`.
61 changes: 31 additions & 30 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import { ToolLifecycleEmitter, parseToolObserveParams, type ScriptCallOrigin } f
import { checkToolPattern, describeUnmatchedPattern, type PatternServer } from './mcpl/tool-pattern-check.js';
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
import { readSkipReplyWake, skipReplyWakeRefusal } from './mcpl/skip-reply-wake.js';
import { convertBlock } from './mcpl/convert-block.js';
import {
PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
Expand Down Expand Up @@ -178,8 +179,8 @@ function sniffImageMediaType(data: Buffer): string | undefined {
* `send_message` followed by "sent it" from double-posting, but a new message
* starts a new conversational round and must be answerable with plain prose.
* `skip_reply` ends the turn at the tool-result boundary, unless it's refused
* (wake_in_seconds without an EventGate): then the turn goes on, and only its
* own round stays silent.
* (a wake_in_seconds without an EventGate, or one that isn't a number of
* seconds): then the turn goes on, and only its own round stays silent.
*/
const SILENCING_TOOLS = new Set([
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
Expand Down Expand Up @@ -236,18 +237,6 @@ const silenceCauseOf = (calls: Array<{ name: string; input?: unknown }>): 'send'
return calls.some(isSilencingCall) ? 'send' : null;
};

/**
* The seconds a skip_reply call asks to be woken after: its wake_in_seconds
* read as a number, when that's finite and above 0, and undefined when it asks
* for no timed wake. Without an EventGate such a call is refused, so the
* dispatch that refuses it and the round that keeps its prose private both
* read the request here.
*/
const requestedSelfWakeSeconds = (input: unknown): number | undefined => {
const secs = Number((input as Record<string, unknown> | null | undefined)?.wake_in_seconds);
return Number.isFinite(secs) && secs > 0 ? secs : undefined;
};

/**
* True when an injected message is real conversational input — something the
* agent might actually be replying to — rather than ambient machinery.
Expand Down Expand Up @@ -18545,15 +18534,23 @@ export class AgentFramework {
}

/**
* Whether this host refuses a skip_reply call: it asks for a timed wake
* (requestedSelfWakeSeconds), the channel registry handles skip_reply, and
* there's no EventGate to set the wake. The dispatch that refuses the call,
* the round that keeps its prose private and the turn's silencing all read
* it here, for the model's own calls.
* Why this host refuses a skip_reply call, as the model will read it, or
* undefined when it doesn't (skipReplyWakeRefusal): without an EventGate,
* any wake_in_seconds; with one, a wake_in_seconds that isn't a number of
* seconds. Only when the channel registry handles skip_reply. The dispatch
* that refuses the call reads it here, and so does the turn's bookkeeping of
* a refused round, for the model's own calls: the silence before it, which
* comes back after its round, and in XML mode which round's prose stays
* private. A skip_reply round's own prose stays private by the tool's name,
* refused or not.
*/
private skipReplyRefusal(call: { name?: string; input?: unknown }): string | undefined {
if (call.name !== 'skip_reply' || !this.channelRegistry) return undefined;
return skipReplyWakeRefusal(readSkipReplyWake(call.input), !!this.eventGate);
}

private refusesSelfWake(call: { name?: string; input?: unknown }): boolean {
return call.name === 'skip_reply' && !!this.channelRegistry && !this.eventGate
&& requestedSelfWakeSeconds(call.input) !== undefined;
return this.skipReplyRefusal(call) !== undefined;
}

/**
Expand Down Expand Up @@ -18680,21 +18677,25 @@ export class AgentFramework {
// the agent after N seconds unless something else wakes it first
// (any turn start cancels the pending self-wake). Armed here because
// the framework owns the EventGate; the registry only words the
// confirmation. Without a gate, a positive timer request is refused so
// the model learns that its turn remains open and no wake was armed.
// confirmation. A wake the host can't set is refused, so the model
// learns that its turn remains open and no wake was armed: any
// wake_in_seconds without a gate, and one that isn't a number of
// seconds with one.
if (enrichedCall.name === 'skip_reply') {
const secs = requestedSelfWakeSeconds(enrichedCall.input);
if (secs !== undefined && this.eventGate) {
const { inMs } = this.eventGate.armSelfWake(agentName, secs, 'skip_reply');
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
} else if (this.refusesSelfWake(enrichedCall)) {
const error = "Your turn didn't end, and no wake was set: this host can't wake you on a timer, so skip_reply can't take wake_in_seconds here. To end the turn and wait for the next message, call skip_reply without it. Anything you meant to come back to, do now.";
console.error(`[self-wake] agent=${agentName} No EventGate configured (FrameworkConfig.gate is unset). ${error}`);
const error = this.skipReplyRefusal(enrichedCall);
if (error !== undefined) {
const wakeValue = (enrichedCall.input as { wake_in_seconds?: unknown } | null | undefined)?.wake_in_seconds;
console.error(`[self-wake] agent=${agentName} wake_in_seconds=${logValue(wakeValue)} skip_reply refused: ${logDetail(this.eventGate ? "wake_in_seconds isn't a number of seconds" : 'No EventGate configured (FrameworkConfig.gate is unset)')}`);
this.toolLifecycleEmitter?.refuse(agentName, enrichedCall.id);
this.emitTrace({ type: 'tool:failed', module: 'channels', tool: enrichedCall.name, callId: enrichedCall.id, error });
this.pushEvent({ type: 'tool-result', callId: enrichedCall.id, toolName: enrichedCall.name, agentName, moduleName: 'channels', result: { success: false, error, isError: true } });
return;
}
const wake = readSkipReplyWake(enrichedCall.input);
if (wake.kind === 'wake' && this.eventGate) {
const { inMs } = this.eventGate.armSelfWake(agentName, wake.seconds, 'skip_reply');
console.error(`[self-wake] agent=${agentName} armed in ${Math.round(inMs / 1000)}s (skip_reply)`);
}
}
this.dispatchChannelToolCall(agentName, enrichedCall);
return;
Expand Down
23 changes: 13 additions & 10 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { RefusalLines } from './refusal-lines.js';
import { CapabilityGrant } from './capability-grant.js';
import type { InboundSource } from './inbound-source.js';
import { McplRequestError } from './server-connection.js';
import { readSkipReplyWake } from './skip-reply-wake.js';

// ============================================================================
// Typing indicator interval (Discord typing lasts ~10s, so 7s keeps it alive)
Expand Down Expand Up @@ -382,8 +383,9 @@ const CHANNEL_TOOL_DEFINITIONS: ToolDefinition[] = [
type: 'number',
description:
'Optional self-wake: if nothing else wakes you first, you wake again after this ' +
'many seconds (1 = go again almost immediately; clamped to 1–3600). Any other ' +
'wake before then cancels it. Omit to stay idle until the next external wake.',
'many seconds, from 1 to 3600; a larger number waits 3600 (1 = go again almost ' +
'immediately). Any other wake before then cancels it. Omit to stay idle until the ' +
'next external wake.',
},
},
required: [],
Expand Down Expand Up @@ -1515,7 +1517,7 @@ export class ChannelRegistry {
return this.handleToolJournal(input as { content?: string });

case 'skip_reply':
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: number });
return this.handleToolSkipReply(input as { reason?: string; wake_in_seconds?: unknown });

default:
return { success: false, error: `Unknown channel tool: ${toolName}`, isError: true };
Expand Down Expand Up @@ -3032,14 +3034,15 @@ export class ChannelRegistry {
* tool, so any trailing prose this turn is NOT posted. Replaces the old
* overloaded use of `think` for staying silent.
*/
private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: number }): ToolResult {
private handleToolSkipReply(input: { reason?: string; wake_in_seconds?: unknown }): ToolResult {
// The self-wake itself is armed by the framework (which owns the
// EventGate) BEFORE this dispatch. Without a gate, a positive timer request
// is refused before this successful end-turn handler is reached.
const wakeSecs = Number(input.wake_in_seconds);
const selfWake = Number.isFinite(wakeSecs) && wakeSecs > 0
? Math.max(1, Math.min(3600, Math.floor(wakeSecs)))
: undefined;
// EventGate) BEFORE its dispatch reaches this handler, from the same
// reading, and the framework refuses a wake the host can't set before
// dispatching. A caller of this registry's handleChannelToolCall
// (AgentFramework.channels) goes past both, so its note names a wake
// nothing armed.
const wake = readSkipReplyWake(input);
const selfWake = wake.kind === 'wake' ? wake.seconds : undefined;
return {
success: true,
// The note says "ended the turn" — make it TRUE. Without endTurn the
Expand Down
73 changes: 73 additions & 0 deletions src/mcpl/skip-reply-wake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* How `skip_reply` reads its `wake_in_seconds`, for every site that acts on
* it: the framework arms the EventGate's timer or refuses the call, the turn's
* bookkeeping of a refused round asks whether it was refused, and the channel
* registry words the confirmation. One reading, so a value can't arm a timer
* at one site and read as no wake at another, and the confirmation names the
* seconds the timer is set for.
*/
import { safeSlice } from '../safe-slice.js';

/** The range the tool's schema offers, in seconds: a larger number waits the most. */
export const SKIP_REPLY_WAKE_MIN_SECONDS = 1;
export const SKIP_REPLY_WAKE_MAX_SECONDS = 3600;

/**
* What a `skip_reply` call's `wake_in_seconds` asks for:
* - `none`: absent or `null`, an ordinary skip;
* - `wake`: a number of at least 1, or a string that `Number()` reads as one,
* in whole seconds and at most 3600, so 90.7 waits 90 s and anything over
* 3600, Infinity included, waits 3600 s;
* - `invalid`: anything else, which asks for no number of seconds the host can
* set: a number below 1, such as 0, a negative or 0.5, and "soon", "",
* `true`, an array or an object. Below 1 is refused rather than raised to 1:
* 0 is a value a model may write when it fills an optional number with a
* default, and as a 1 s wake it would wake the agent again a second after
* each turn, for as long as it kept skipping with 0.
*/
export type SkipReplyWake =
| { kind: 'none' }
| { kind: 'wake'; seconds: number }
| { kind: 'invalid'; value: unknown };

export function readSkipReplyWake(input: unknown): SkipReplyWake {
const value = input !== null && typeof input === 'object'
? (input as Record<string, unknown>).wake_in_seconds
: undefined;
if (value === undefined || value === null) return { kind: 'none' };
// A blank string reads as 0, so it's refused as a number below 1.
const seconds = typeof value === 'number' ? value
: typeof value === 'string' ? Number(value)
: Number.NaN;
if (Number.isNaN(seconds) || seconds < SKIP_REPLY_WAKE_MIN_SECONDS) return { kind: 'invalid', value };
return { kind: 'wake', seconds: Math.floor(Math.min(SKIP_REPLY_WAKE_MAX_SECONDS, seconds)) };
}

/** The refusal the model reads when its host has no EventGate to set a timer. */
export const SKIP_REPLY_WAKE_NO_GATE_REFUSAL =
"Your turn didn't end, and no wake was set: this host can't wake you on a timer, so skip_reply can't take wake_in_seconds here. To end the turn and wait for the next message, call skip_reply without it. Anything you meant to come back to, do now.";

/**
* Why a `skip_reply` call is refused, as the model will read it, or undefined
* when it isn't: without an EventGate, any `wake_in_seconds` but absent or
* `null`, since the host offers none; with one, a value that isn't a number of
* seconds. A refused call arms nothing and doesn't end the turn.
*/
export function skipReplyWakeRefusal(wake: SkipReplyWake, gatePresent: boolean): string | undefined {
if (wake.kind === 'none') return undefined;
if (!gatePresent) return SKIP_REPLY_WAKE_NO_GATE_REFUSAL;
if (wake.kind === 'wake') return undefined;
return `Your turn didn't end, and no wake was set: wake_in_seconds takes a number of seconds from ${SKIP_REPLY_WAKE_MIN_SECONDS} to ${SKIP_REPLY_WAKE_MAX_SECONDS}, and ${describeValue(wake.value)} isn't one. Call skip_reply with a number of seconds to come back on your own, or without wake_in_seconds to end the turn and wait for the next message.`;
}

/**
* A value as the model wrote it, as JSON, cut to keep the refusal short,
* never between the halves of a surrogate pair. A tool call's input is parsed
* JSON, so it always has a JSON text, but JSON writes a number that isn't
* finite as `null`, and `-1e400` parses as -Infinity, so a number prints as
* `String()` of it.
*/
function describeValue(value: unknown): string {
const text = typeof value === 'number' ? String(value) : JSON.stringify(value);
return text.length > 80 ? `${safeSlice(text, 0, 80)}…` : text;
}
Loading
Loading