Skip to content
Merged
22 changes: 22 additions & 0 deletions changelog.d/feat-continue-after-prose.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
- **Hosts:** `AgentConfig.autoContinue` (default off): a turn the model ends
itself, with prose or with nothing, is followed by another turn straight
away, until the agent calls `skip_reply` (or another tool that ends the
turn), ends on a `>>skip_reply` / `>>>skip_reply` envelope, or reaches
`maxConsecutive` continuations in a row (default 3, 1 to 20). The count
starts again on any wake with a cause of its own (a silent heartbeat tick
isn't one). As the next turn starts it reads a short `[continue]` row
naming the agent, saying another turn starts, how to stop and that
unmarked words are sent as usual (and, on the last continuation, that it
waits for the next event after it); the row is written only when the
continuation is that turn's whole cause, never for a wake that doesn't run.
The next turn keeps the turn's locus unless a batched message names
another channel. Its wake has reason `auto-continue`. Not continued:
refusals, `max_tokens` cut-offs, the stall guard's ends, silent heartbeat
ticks, a turn whose closing prose bounced, ephemeral runs, conversation forks, an
agent offered no way to stop, and a quiesce (which drops a queued
continuation); a ` !` continuation runs instead. Each continuation is a
full inference: an agent that answers and ends its turn gets at least one
more per answer. A malformed setting is refused when the agent is built,
naming the value's kind, never the value. New exported types
`AutoContinueConfig` and `AutoContinueRun` (`InferenceRequest.autoContinueRun`).
See docs/auto-continue.md.
119 changes: 119 additions & 0 deletions docs/auto-continue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Continuing until skip_reply (`autoContinue`)

`autoContinue` is a default-off per-agent setting. With it on, a turn the
model ends itself is followed by another turn straight away, instead of the
usual pause until the next event (a message, a heartbeat, a timer). The agent
keeps going until it says it's done: by calling `skip_reply`, or another tool
that ends the turn, such as `sleep`.

`true` turns it on with a cap of 3 continuations in a row:

```json
{ "agent": { "autoContinue": true } }
```

An object sets the cap, a whole number from 1 to 20:

```json
{ "agent": { "autoContinue": { "maxConsecutive": 5 } } }
```

## What it costs

An agent that answers and simply ends its turn gets at least one more full
inference per answer: the continuation it then ends with `skip_reply`. One
that never calls `skip_reply` gets up to `maxConsecutive` more, and in locus
and hybrid modes each of those turns' prose is published. Each continuation's
rows also stay in the window. Keep the cap low, and expect roughly one extra
inference per turn for an agent that does skip.

## Which turns continue

A turn continues when the model ended it (stop reason `end_turn` or
`stop_sequence`), whether its last response is prose or empty, unless:

- a tool ended it: `skip_reply`, `sleep`, any endTurn tool;
- its last prose envelope is a skip: `>>skip_reply` in explicit mode, or a
`>>>skip_reply` in hybrid mode whose hold is still standing at the end;
- it asked for a ` !` continuation (`>>#ops !`), which runs instead, with no
row, and counts toward nothing;
- its closing prose bounced (explicit or hybrid mode): the bounce, with the
bounce-wake cap, is that turn's answer, and continuing once the cap is
spent would restart the loop the cap breaks;
- it was a silent heartbeat tick;
- it ended with a refusal, a `max_tokens` cut-off, or the stall guard's
`no_progress` / `round_limit`;
- the agent is an ephemeral run (a subagent) or a conversation fork, or the
framework is stopping;
- the agent has no way to stop that it's offered: in locus or disabled mode,
`skip_reply` must be offered (a channel registry provides it) and allowed
(`allowedTools`). Explicit and hybrid agents can always end with a skip
envelope.

A turn that sent with a send tool and then closed in prose (held, not sent,
because the turn had already sent) still continues: its closing words can't
be told from unfinished work. The row tells the agent those words were held.

## What the agent reads

As the continuation's turn starts, after every gate that could still stop it,
a short system row joins the window as the newest of the turn's inputs (after
the last turn's `[delivered]` receipt, outside explicit mode, whose envelopes
acknowledge themselves and write no receipt):

```
[continue] lena, another turn starts now. If you're done, call skip_reply; anything you write without it is sent as usual.
```

It names the agent, since residents read one shared message slot. For the
same reason it is never written while another resident's turn is alive: the
continuation waits for that turn to end, as a clock row does. When the
last turn's closing words were held because that turn had already sent, it
adds `Your closing words last turn were held, not sent: that turn had already
sent with a send tool.` The last continuation of a run adds `This is the last
continuation in a row: after this turn you wait for the next event either
way.` An agent that can't call `skip_reply` is told to end with a
`>>skip_reply` (explicit) or `>>>skip_reply` (hybrid) envelope instead.

The wording is neutral on purpose: a row that reads as a lapse ("you ended
without…") or as a budget ("2 of 3, one more left") invites an apology or a
"done here" reply, which in locus and hybrid modes would itself be published.
It is short and event-shaped: longer instruction blocks about output, sent as
user-role messages, have tripped provider classifiers.

The row is never written for a turn that doesn't run: a wake the inference
policy drops, or that a quiesce drops, writes none and gives back its count,
as does one a resumed turn (a budget restart) takes in; a row that can't be
stored gives back its count, and withdraws a turn the continuation alone
caused. A write that throws after its row landed counts as written. It is appended before the turn compiles and never
retracted, so the prompt-cache prefix stays stable.

## The next turn

The continuation's wake has source `framework` and reason `auto-continue`. In
locus and hybrid modes it carries the turn's locus, so the next turn's plain
prose lands in the same locus rather than the process-global last-inbound
channel. (A hybrid `>>>` destination is a pin within a turn: the next turn
starts with none, as any turn does.) A turn whose wake named a channel that
resolves to none (it had no channel and took no fallback) passes that channel
on, so its continuation takes no fallback either.

A message that arrives meanwhile joins the same turn, as any batched wake
does, and then that turn is the message's: its channel outranks the
continuation's (ambient included, though the continuation's wake was queued
later), no `[continue]` row is written, and the run's count starts again. A
silent heartbeat tick batched with a continuation is not a cause of its own:
the continuation keeps its row and its count.

A host quiesce drops a queued continuation: at resume, possibly hours later,
"another turn starts now" would no longer be true. The next real event wakes
the agent as usual.

## The cap

At most `maxConsecutive` continuations run in a row (default 3). The count
starts again when a turn starts with a cause of its own (anything but a
framework wake or a silent heartbeat tick), and when a tool ends a turn. A
silent tick batched with a continuation neither restarts the run nor takes
its row, so a resident whose ticks come faster than its turns still reaches
the cap.
49 changes: 47 additions & 2 deletions src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CacheWireReceipt, KvUnifiedRequestHooks } from './kv-unified-wire.
import { ToolResultGuard } from './tool-result-guard.js';
import { withoutTokenEstimates } from './thinking-token-stamp.js';
import { injectionsAsStored } from './text-as-stored.js';
import { assertBooleanSetting, holdsSettings } from './config-checks.js';
import { assertBooleanSetting, holdsSettings, kindOf } from './config-checks.js';
import {
CONTINUE_ROW,
continueTurn,
Expand Down Expand Up @@ -64,6 +64,7 @@ import type {
} from '@animalabs/context-manager';
import type {
AgentConfig,
AutoContinueConfig,
AgentState,
SameRoundThinkTextPolicy,
SameRoundThinkTextPolicySource,
Expand Down Expand Up @@ -102,7 +103,8 @@ const DEFAULT_SAME_ROUND_THINK_TEXT_POLICY: SameRoundThinkTextPolicy = 'public';
* `toolWrapperProseGuard`, `thinking.enabled` or `refusalHandling.autoRewind`
* holds anything but true or false (assertBooleanSetting), or whose `thinking`
* or `refusalHandling` is anything but an object, null or false
* (holdsSettings). The constructor runs it first, so the rule holds wherever
* (holdsSettings), or whose `autoContinue` resolveAutoContinue refuses.
* The constructor runs it first, so the rule holds wherever
* an Agent is built. `create()` also runs it for each of its agents before
* anything opens, and `createEphemeralAgent` before it reserves the name, so a
* refusal there leaves nothing behind. Membrane reads `thinking.enabled`, and
Expand Down Expand Up @@ -142,6 +144,45 @@ export function assertAgentSwitches(config: AgentConfig): void {
'true keeps a response whose whole prose is a tool-call-shaped wrapper from running or entering the agent\'s history, ' +
'and false or leaving it out doesn\'t',
);
resolveAutoContinue(config);
}

/** `AutoContinueConfig.maxConsecutive` when left out. */
export const DEFAULT_AUTO_CONTINUE_MAX = 3;
/** The most continuations in a row a config may ask for. */
const MAX_AUTO_CONTINUE_MAX = 20;

/**
* `AgentConfig.autoContinue` resolved: undefined when off (left out, null or
* false), else its cap. `true` takes the default cap; an object takes
* `maxConsecutive`, a whole number from 1 to 20 (the default when left out).
* Any other value is refused.
*/
export function resolveAutoContinue(config: AgentConfig): { maxConsecutive: number } | undefined {
const setting = `Agent "${config.name}" autoContinue`;
const value: unknown = config.autoContinue;
if (value === true) return { maxConsecutive: DEFAULT_AUTO_CONTINUE_MAX };
if (value !== undefined && value !== null && value !== false && (typeof value !== 'object' || Array.isArray(value))) {
throw new Error(
`${setting} must be true, false or an object such as { maxConsecutive: 3 }, not ${kindOf(value)}` +
(typeof value === 'string' ? ' (true and false go unquoted)' : ''),
);
}
if (!holdsSettings(setting, value as AgentConfig['autoContinue'], '{ maxConsecutive: 3 }')) return undefined;
const max = (value as AutoContinueConfig).maxConsecutive;
if (max === undefined) return { maxConsecutive: DEFAULT_AUTO_CONTINUE_MAX };
if (typeof max !== 'number' || !Number.isInteger(max) || max < 1 || max > MAX_AUTO_CONTINUE_MAX) {
// The value's kind, never the value (config-checks): a `${VAR}` that
// lands here could be a credential.
const kind = typeof max !== 'number' ? kindOf(max)
: !Number.isInteger(max) ? 'a number that isn\'t whole'
: max < 1 ? 'a number below 1' : `a number above ${MAX_AUTO_CONTINUE_MAX}`;
throw new Error(
`${setting}.maxConsecutive must be a whole number from 1 to ${MAX_AUTO_CONTINUE_MAX}, not ${kind}: ` +
'the most turns in a row that continue without skip_reply before the agent waits for the next event',
);
}
Comment thread
antra-tess marked this conversation as resolved.
Resolved
return { maxConsecutive: max };
}

/**
Expand Down Expand Up @@ -174,6 +215,9 @@ export class Agent {
readonly proseRouting: 'locus' | 'explicit' | 'hybrid' | 'disabled';
/** Exact whole-response known-tool wrapper containment (default off). */
readonly toolWrapperProseGuard: boolean;
/** Continuation after a turn the model ends itself (see
* AgentConfig.autoContinue); undefined when off. */
readonly autoContinue: { maxConsecutive: number } | undefined;
/** Clock rows' interval and precision (see AgentConfig.clock); undefined
* when the agent writes none of its own (a resident still reads those
* another resident writes into the shared slot). */
Expand Down Expand Up @@ -307,6 +351,7 @@ export class Agent {
this.toolResultGuard = new ToolResultGuard(config.name, contextManager, config.toolResultGuard === true);
this.proseRouting = config.proseRouting ?? 'locus';
this.toolWrapperProseGuard = config.toolWrapperProseGuard === true;
this.autoContinue = resolveAutoContinue(config);
this.clock = resolveClockConfig(config.name, config.clock);
this.cacheTtl = config.cacheTtl ?? '1h';
this.promptCaching = config.promptCaching !== false;
Expand Down
42 changes: 42 additions & 0 deletions src/auto-continue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* autoContinue (AgentConfig.autoContinue, docs/auto-continue.md): a turn the
* model ends itself is followed by another, unless it ended on a skip. This
* module holds what doesn't need the framework: which ends continue, and the
* row the next turn reads.
*/

/** The reason on a continuation's wake (InferenceRequest.reason). */
export const AUTO_CONTINUE_REASON = 'auto-continue';

/**
* Stop reasons of a turn the model ended itself. The others don't continue:
* a refusal has its own handling (retries, rewind, the reaction), a
* `max_tokens` end was cut off rather than chosen, and `no_progress` and
* `round_limit` are the stall guard's verdict that the turn wasn't moving.
*/
export const AUTO_CONTINUE_STOP_REASONS: ReadonlySet<string> = new Set(['end_turn', 'stop_sequence']);

/**
* The row a continuation's turn reads. It names the agent (residents read
* one shared message slot), says another turn starts, says how to stop
* (`stop`, an imperative such as "call skip_reply") and that anything written
* without it is sent as usual, and, on the last continuation of a run, that
* the agent waits for the next event after it. When the last turn's closing
* words were held because that turn had already sent, it says so. Short,
* neutral and event-shaped on purpose: it rides as a system row in the
* window, where instruction blocks about output have tripped classifiers,
* and wording that reads as a lapse or a budget invites a published reply.
*/
export function autoContinueNotice(opts: {
agentName: string;
stop: string;
last: boolean;
heldAfterSend?: boolean;
}): string {
return `[continue] ${opts.agentName}, another turn starts now.` +
(opts.heldAfterSend
? ' Your closing words last turn were held, not sent: that turn had already sent with a send tool.'
: '') +
` If you're done, ${opts.stop}; anything you write without it is sent as usual.` +
(opts.last ? ' This is the last continuation in a row: after this turn you wait for the next event either way.' : '');
}
Loading
Loading