Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8454df0
fix(streaming): the outgoing stream carries only what delivery publishes
Oct 14, 2026
16ee15c
fix(routing): a code_execution script's calls count as its round's (#…
Oct 14, 2026
9b0b230
fix(streaming): a stop() completes a stream the framework no longer t…
Oct 14, 2026
fd882c7
fix(streaming): name the outgoing stream's traces inference:published
Oct 15, 2026
b5aab9e
fix(routing): a script round waits only when its script can change it…
Oct 15, 2026
00ce087
test(streaming): pin what the PR description says the stream does
Oct 15, 2026
4670979
Merge main (91e70eda) into #360
Oct 16, 2026
2aa99a4
feat(streaming): inference:published carries its publish's messageId
Oct 16, 2026
a113813
fix(routing): a script's mark holds only what a later script can reach
Oct 16, 2026
473ab4c
fix(routing): a kept key is a whole name, and a line stands where it'…
Oct 16, 2026
a115ab1
fix(routing): a kept key's prefix may hold any punctuation, and the d…
Oct 17, 2026
fd964be
test(code-execution): the disposed-ephemeral test no longer races pyt…
Oct 17, 2026
c9d92a2
fix(code-execution): read a def header across backslash joins
Oct 17, 2026
a52c64e
Merge main (3af13bf4, with 0.22.2, #198 and #286) into hold-round-speech
Oct 17, 2026
9fd8b7f
test(skip_reply): a wake refused inside a script, in XML tool mode
Oct 17, 2026
a08574c
Merge main (ce1002b3, with #247 and 54 other PRs) into hold-round-speech
Oct 18, 2026
ce34f8f
docs(changelog): list a kept key with whitespace in its prefix among …
Oct 18, 2026
802af12
test(streaming): an unconfirmed publish streams nothing on every path…
Oct 18, 2026
21d7181
docs(streaming): the published trace's doc and the fragment follow th…
Oct 18, 2026
5d408f7
Merge main (9e3e407c) into hold-round-speech
Oct 18, 2026
7674c9f
docs(changelog): a refused inner send's sentence says its one reading
Oct 18, 2026
203a6a9
Merge main (15aadc96) into hold-round-speech
Oct 19, 2026
d30c8fc
test(streaming): the speech-routed check leaves out the open a first …
Oct 19, 2026
8fe9a1d
docs(changelog): leave the allowlist fragment as 0.23.0 published it
Oct 19, 2026
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
17 changes: 17 additions & 0 deletions changelog.d/hold-round-speech.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
- `inference:published` and `inference:published_complete` traces carry the
outgoing stream to in-process consumers: each chunk as it is sent, with
its `channelId`, `inferenceId`, `index` and `text`, and the `messageId`
the channel's server returned for that publish, when it returned one; and
each channel's `complete`. They are emitted whether or not the channel's
server takes the stream, and carry only what delivery published, so a host
relay that voices them, such as a TTS bridge, never speaks text that
`skip_reply`, a send or a private `think` withheld, or a publish the
server didn't confirm. `inference:tokens` is unchanged: it carries the
model's text as it is written, held, as before, while a tool-result
guard's batch is pending.
`mcpl:speech-routed` also reports speech publishes, with no stream id,
index or end: among them the `sleep` tool's announcement and a
subconscious's `speak_in_channel`. A relay that voices the agent's prose
takes the `inference:published` stream, with its
`inference:published_complete`, and not `mcpl:speech-routed` as well, and
takes each message's id from `inference:published` to edit what it voiced.
19 changes: 19 additions & 0 deletions changelog.d/hold-round-speech.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- **MCPL servers that take the outgoing stream (`channels.streaming`):**
chunks now arrive once each publish succeeds, one per published
message, rather than token by token while the model writes. A round
reaches a voice server once the model has finished writing it, its
routing is decided and its publish succeeds, after any earlier post
of the agent's still in flight. A later message to the same channel
within one stream opens with a paragraph break (`\n\n`), so the
channel's chunks joined in `index` order are its text, which `complete`
carries, with its messages kept apart. A server that read chunks as a
sign the agent is composing, such as eidoverse-worlds' typing dots, now
sees them only as a reply is published; `channels/typing`, which the
host sends to a server granted `channels.typing` while a turn runs on
one of its channels, is that signal.
- Agents with `toolWrapperProseGuard` now stream their delivered prose. The
stream was off for them because it ran ahead of the guard's
classification; it now carries only prose that delivery published, and a
wrapper the guard contains is never published. Wrapper-shaped text the
guard doesn't match, as in a turn that has made a real tool call, was
already posted as ordinary prose, and now streams too.
102 changes: 102 additions & 0 deletions changelog.d/hold-round-speech.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
- The MCPL outgoing stream (`channels/outgoing/chunk` and `complete`) no
longer carries text that delivery withholds or sends elsewhere. It
streamed each round's text as the model wrote it, but whether a round is
delivered is decided only once its tool calls are known. So a streaming
server received, and discord-mcpl's voice spoke:
- a round that called `skip_reply`, a silencing send such as
`send_message` or `channel_publish`, or a `think` under
`same_round_think_text_policy: private`;
- a round its stream never finished, such as one cut off by a stream
error;
- in hybrid mode, in a later stream of the turn, such as a budget
restart's, a framework retry's or a re-inference's, text the turn's
hold kept back or its pin sent to another channel;
- in locus and hybrid mode, after a mid-turn re-pin, text bound for the
new locus, which streamed to the old one.

The stream is now the prose that delivery published: each publish's
text, to its channel, sent once the publish succeeds, a later one to the
same channel opening with a paragraph break. Tool publications, such as
`send_message`, a `drafts` resend or the `sleep` tool's announcement,
aren't part of it, as before. A publish succeeds only when the server
confirms it (`delivered: true`), so one refused before sending, one that
throws, times out or gets an error response, one the server reports
undelivered (`delivered: false`) and one whose reply leaves `delivered`
out are never streamed, and neither is text held as a draft. A
`{{unsent}}` resend streams the draft's words it sends, rather than the
token.
- With native tool calls (`roundContent`), a call made inside a foreground
`code_execution` script now counts toward its round's routing, as the
same call made directly does (#306). In explicit mode an inner
`skip_reply` keeps the round's envelopes in the context. In locus and
hybrid mode an inner `skip_reply` keeps the round's prose private, and an
inner silencing send, a `drafts` resend among them, holds it and the
turn's later prose as drafts, as a direct one does. An inner
`think` under the private same-round policy holds the round back in
every mode. An inner `skip_reply` that's refused, because the agent's
allowedTools doesn't name it or because this host can't set the
`wake_in_seconds` it asks for, counts as a refused direct one: it keeps
only its own round private, and the turn goes on. An inner send the
allowedTools refuses, like a refused direct one, silences nothing. On
the fallback without `roundContent`, as in XML tool mode, an inner
call counts only as a direct one does there. Nothing is routed until the
turn ends, so a silencing send holds the whole turn's prose as drafts,
the rounds before it included. The private `think` hold and an explicit
round's `skip_reply` don't apply there (#307).
- A round whose script can change its routing waits for the script, and its
prose goes out when the script finishes. A script can change it when its
code names a tool whose call changes the round's routing in the turn's
mode: in locus and hybrid mode `skip_reply`, a silencing send, `drafts`
(whose resend is one), `say` or `whisper`; in explicit mode only
`skip_reply`; and in either, `think` under the private same-round
policy. Disabled mode holds nothing. A name
counts in code wherever it stands as a whole word of ASCII letters, digits
and underscores, or after `__` as in `mcpl__world__say`, as a function is
called or a `tools[...]` key is written: `say_hello` doesn't name `say`. A
name in a comment doesn't count, since Python never runs one, while a name
anywhere in a string does, since a string can be a key or code to run. A
script can also change its round's routing when the interpreter it runs in
has run a script that left such a tool reachable, by naming the tool
inside a definition (a function or lambda, a class's methods among them)
or other than in a direct call, or by keeping a string that is the tool's
whole name, optionally namespaced, as a key kept for later is. That holds
until the interpreter is reclaimed or restarted. A direct call outside a
definition, a class's own body included, or a string that only mentions a
tool, as a printed sentence does, leaves nothing reachable. Without code
execution, a `code_execution` call holds nothing. Any other round is
routed at its tool-calls event, as before, so narration beside a script
isn't held for the script's runtime. What goes unseen is routed as it was
before scripts counted, such as a tool name the code doesn't spell out
(built at runtime, written with escapes, or in non-ASCII spellings), code
loaded from a file or module, an enumeration of the tools, a kept key
whose prefix, as the script writes it, contains whitespace, a call written
in a string's text (code run with `exec` or `eval`, or an f-string's
field), inside a definition too, a definition or alias made inside such a
string, a call that outlives its script (one it awaits only in a later
script, schedules as a task, or leaves in a generator expression), and
code a later script runs from a string an earlier one kept. The turn's
locus and an explicit round's `>>` targets are taken at the round's
tool-calls event, when the model has finished writing it, not when its
scripts return; hybrid's `>>>` targets apply as its delivery runs, as for
any queued hybrid delivery. A background script's call returns as soon as
the script has started, and its later calls aren't the round's. A script's
result settles only the round that dispatched it, even when a newer round
reuses its call id. A round whose turn ends or stops waiting on it, or
that a newer round of the same agent replaces, before its scripts return,
as when the turn is stopped, is withheld: it is neither sent nor held as
a draft, a log line names it, and in locus and hybrid mode the turn's
delivery receipt counts it as suppressed.
- Every stream now sends `channels/outgoing/complete` for each channel it
streamed to, on every exit path, as MCPL Spec 14.3 asks: once its
deliveries have run, or, when the host stops, at once, with what it has
sent, so a publish that succeeds after the stop began isn't streamed. A
stream that handed its turn to a budget restart or a tool-result-guard
retry never sent it, so a voice server never learned that stream's
utterance had ended.
- A membrane refusal retry no longer moves the outgoing stream to a new
inference id. Nothing of the refused attempt was delivered, so none of it
streamed; the rounds delivered before the refusal stay in the stream, and
its `complete` carries them. The stream's `inference/lifecycle` `started`
and terminal now carry that one id too. The retry re-minted the id
between them, so a server matching them by `inferenceId` saw the first
inference never end, and a terminal for an id that never started.
4 changes: 2 additions & 2 deletions docs/hybrid-prose-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ names a destination that resolves; if that envelope's body then isn't
delivered, the hold returns. The sticky destination and this hold last for
the whole logical turn, across its later streams, such as a framework
retry's, a budget restart's or the compile a provider-capacity hold defers
the turn to. They govern publication: a stream's live outgoing deltas still
start at the turn's locus.
the turn to. They govern publication, and with it the outgoing stream, which
carries only what delivery publishes, where delivery publishes it.

This is publication routing only. It creates no privacy meaning for `<think>`
or other prose tags, and grants no channel authority beyond the existing
Expand Down
13 changes: 7 additions & 6 deletions docs/tool-result-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,11 @@ wire cost (chars/4 plus a flat per-image cost) in `reserveForResponse`, so
substituting them for the placeholders cannot push a restart's request past
its budget.

While a submitted batch is pending (or a recovery is running), streamed text
and its `inference:tokens` trace events are held at one publication
While a submitted batch is pending (or a recovery is running), the
round's `inference:tokens` trace events are held at one publication
boundary; they are released in order on a clean round and discarded on a
guard refusal.
guard refusal. The outgoing stream needs no hold: it carries only what
delivery publishes, and a refused attempt publishes nothing.

Normal successful rounds admit the preceding results to memory. This works
for framework yielding streams (including ephemeral agents and context-budget
Expand Down Expand Up @@ -271,9 +272,9 @@ retroactive and does not rewrite results already accepted into memory.
- **Token stats.** The context manager's token-stats cache is not
write-through on edit, so a stats read taken while a batch was pending can
keep pricing that message as the placeholder.
- **Held stream text on abort.** Text held while a submitted batch is
pending is dropped (not previewed) if the stream aborts or errors before
the round settles.
- **Held token traces on abort.** `inference:tokens` traces held while a
submitted batch is pending are dropped (not previewed) if the stream
aborts or errors before the round settles.
- **Withheld originals in the workspace.** Every reader of the mount can read
a withheld original's file, and nothing removes it; delete it as any
workspace file.
2 changes: 1 addition & 1 deletion docs/tool-wrapper-prose-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

The guard matches only when the entire visible prose is one wrapper naming a tool registered on that exact inference. Signed thinking may precede the visible wrapper. It does not parse or execute the wrapper body.

Guarded turns buffer prose until completion so outgoing preview/voice surfaces cannot expose a wrapper before classification. Ordinary non-wrapper prose is then delivered normally at turn completion.
The outgoing preview/voice stream carries only prose that delivery has published, so a wrapper the guard contains never reaches it. Prose the guard doesn't match, wrapper-shaped text in a turn that has made a real tool call among it, is ordinary prose: delivery posts it, and it streams once its publish succeeds.

On a match, Agent Framework:

Expand Down
16 changes: 16 additions & 0 deletions src/code-execution/py-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ interface PendingExec {
sent: boolean;
}

/** Interpreters started in this process, by every runner: each one's number. */
let interpretersStarted = 0;

/**
* The interpreter to spawn. A bare name is looked up on PATH. A path with a
* separator is resolved against the host's working directory, where it
Expand Down Expand Up @@ -226,6 +229,8 @@ export class PyRunner {
private pending: PendingExec | null = null;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private execCounter = 0;
/** The number of this runner's latest interpreter (interpretersStarted). */
private interpreterNumber = 0;
private disposed = false;

constructor(options: PyRunnerOptions) {
Expand All @@ -248,6 +253,16 @@ export class PyRunner {
return this.pending !== null;
}

/**
* The live interpreter, whose state (variables, imports, functions) the
* scripts run in it share: a number no other interpreter in this process
* has had, from any runner, or null while none is running, before the
* first script or once it was reclaimed, killed or has exited.
*/
get interpreter(): number | null {
return this.child && this.child.exitCode === null && !this.child.killed ? this.interpreterNumber : null;
}

/**
* Run one script. Tools are (re-)injected before every exec so the
* interpreter always reflects the current tool surface (list_changed etc.).
Expand Down Expand Up @@ -438,6 +453,7 @@ export class PyRunner {
env: { ...buildChildEnv(this.envOptions), PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' },
});
this.child = child;
this.interpreterNumber = ++interpretersStarted;

child.stderr.setEncoding('utf8');
child.stderr.on('data', (chunk: string) => {
Expand Down
Loading
Loading