diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe2913ede..c7b0e3b3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,64 @@ on: permissions: contents: read +# A newer push to a pull request supersedes the run still going on its older +# commit: that run's result says nothing about the code now under review, and +# it holds runners, macOS's above all, which the organization gets few of at +# once, so other pull requests' macOS jobs queue behind it (up to ten minutes +# in October 2026). Pushes to main are never cancelled: each gets its run. +concurrency: + group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: + # Which macOS legs a run takes. macOS runners are what queue (the + # organization gets few at once, shared by every repository), so a pull + # request takes the ones that test it: Build & Test's macOS leg at the + # engines floor only, and code_execution's Bun macOS legs only when it + # touches what they run: any source (the tests import the framework, not + # only code_execution), tsconfig.json, package.json, their own tests and + # helpers, or this workflow. ci-workflow-pins.test.ts checks every file + # those tests import falls inside the filter. The diff is base...head, as + # changelog.yml takes it, not the merge commit, which can carry main's + # newer commits. Pushes to main take every leg. + changes: + name: What the change touches + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + bun-os: ${{ steps.decide.outputs.bun-os }} + build-exclude: ${{ steps.decide.outputs.build-exclude }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Decide + id: decide + env: + EVENT: ${{ github.event_name }} + BASE: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + both='["ubuntu-latest","macos-latest"]' + if [ "$EVENT" != "pull_request" ]; then + echo "bun-os=$both" >> "$GITHUB_OUTPUT" + echo 'build-exclude=[{"os":"macos-latest","node":24}]' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo 'build-exclude=[{"os":"macos-latest","node":24},{"os":"macos-latest","node":"latest"}]' >> "$GITHUB_OUTPUT" + changed=$(git diff --name-only "$BASE"..."$HEAD_SHA") + echo "$changed" + if echo "$changed" | grep -qE '^(src/|test/code-execution|test/helpers/|tsconfig\.json$|package\.json$|\.github/workflows/ci\.yml$)'; then + echo "bun-os=$both" >> "$GITHUB_OUTPUT" + else + echo "bun-os=[\"ubuntu-latest\"]" >> "$GITHUB_OUTPUT" + fi + build-and-test: name: Build & Test (${{ matrix.os }}, node ${{ matrix.node }}) + needs: changes runs-on: ${{ matrix.os }} # Without a limit, a step that hangs leaves the check pending until # GitHub's six-hour limit. Passing runs take under seven minutes. A @@ -28,13 +83,13 @@ jobs: # admits; and, between them, every maintained major line (24), as # membrane and context-manager test theirs. stdio flushing semantics # around process exit differ between versions and platforms. macOS runs - # the two ends only. + # the two ends on a push to main, and the floor alone on a pull request + # (`changes`): its runners queue, and the ends rarely part on one + # platform without the other's ubuntu leg showing it. matrix: os: [ubuntu-latest, macos-latest] node: ['22.12.0', 24, latest] - exclude: - - os: macos-latest - node: 24 + exclude: ${{ fromJSON(needs.changes.outputs.build-exclude) }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -60,8 +115,20 @@ jobs: # Tests run on both platforms: process/stdio behaviour differs (pipes # are asynchronous on macOS), and ubuntu-only runs have missed that. + # + # As many test files at once as the runner has cores, said here rather + # than left to Node's default of one fewer. macOS runners come with 3 + # cores or 5, and on 3 the default ran two files at once: 582 s, where + # a 5-core runner took 290 s for the same tests. At the core count, + # those took 444 s and 236 s, and ubuntu's 4-core runs went from about + # 380 s to 300 s. Most files wait on timers and child processes, not + # the CPU. - name: Test - run: npm test + run: | + AF_TEST_CONCURRENCY=$(node -p "require('node:os').cpus().length") + echo "cores: $AF_TEST_CONCURRENCY (Node's availableParallelism: $(node -p "require('node:os').availableParallelism()")); test files at once: $AF_TEST_CONCURRENCY" + export AF_TEST_CONCURRENCY + npm test # npm test runs with --test-force-exit, which hides async work a test # leaves running, such as a late arrival after stop() ("Queue is @@ -146,14 +213,22 @@ jobs: # are where runtimes and platforms differ. code_execution needs Bun 1.4.0 or # later (create() refuses it on an older one), so they run under 1.4.0, the # oldest release it admits, and under 1.4.3. + # + # On a pull request that touches no source, none of these tests or the + # test helpers, and not tsconfig.json, package.json or this workflow (a + # change to other tests or the docs), the macOS legs are left out + # (`changes`): they take two of the few macOS runners the organization gets + # at once, and nothing they run has changed. Every push to main runs both + # platforms. code-execution-bun: name: code_execution under Bun (${{ matrix.os }}, bun ${{ matrix.bun }}) + needs: changes runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: ${{ fromJSON(needs.changes.outputs.bun-os) }} bun: [1.4.0, 1.4.3] steps: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f56ed0abe..152615e12 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -49,8 +49,15 @@ jobs: - name: Build run: npm run build + # As many test files at once as the runner has cores, as ci.yml's + # Build & Test runs them, so a release's gate runs the suite as a pull + # request's did. - name: Test - run: npm test + run: | + AF_TEST_CONCURRENCY=$(node -p "require('node:os').cpus().length") + echo "cores: $AF_TEST_CONCURRENCY (Node's availableParallelism: $(node -p "require('node:os').availableParallelism()")); test files at once: $AF_TEST_CONCURRENCY" + export AF_TEST_CONCURRENCY + npm test # The tarball npm publish makes from this tree, installed alone into an # empty directory with its dependencies from npm (linked, so each package diff --git a/CHANGELOG.md b/CHANGELOG.md index 73003aad8..b17ea87a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,319 @@ Releases up to and including 0.7.3 predate this file; for their contents see ## Unreleased +## 0.26.2 — 2026-10-24 + +### Fixed + +- A turn whose call failed into a provider hold (a provider's stated wait, a + host's `providerHold`, or an organization-acceleration cooldown), or whose + compile a conversation agent's recorded wait held with nothing sent, now + counts as alive until the hold releases its retry, and so does a turn + whose error-policy retry a provider wait holds at the last admission + boundary before it starts (a conversation agent's own compression can + record one during the policy's delay). The host used to report it + `drained`, and a rollback, suppression or branch switch went through, + after which the retry ran on the rewound store, answering again on history + rewound under it. Now: + - `getHostModeStatus()` lists the agent in `activeTurnAgents` and counts it + in `activeTurns`, and `drained` reads `false`; + - a rollback, suppression or branch switch refuses as `agent-busy`, naming + the hold, its end (or `until released`) and its reason, and how to go on + sooner: a quiesce with `abandon: true`, or, where a stated wait holds the + turn, `release-provider-wait`; + - a quiesce waits for the turn, up to its `timeoutMs` (at least 1 s), + where it used to report drained at once, and lets the retry start when + the hold ends and finish; wakes that arrive meanwhile park, as for any + turn alive; + - a quiesce with `abandon: true` (once its wait for the drain runs out, or + at once on a host already quiesced), `stop()`, the inference policy, the + agent's removal or a conversation agent's disposal drops the retry, and + the turn ends with one `inference:aborted`, as a turn whose continuation + is dropped does; + - a closing conversation agent isn't disposed while a hold keeps its + turn's retry, nor while a continuation of its turn (a budget restart or + a tool-result guard's retry) waits in a hold, a park or a scheduler pass; + - once `stop()` has closed admission, a failure reported then installs no + hold, so no retry outlives the stop, and the turn ends with an + `inference:aborted` after its `inference:failed`, as a retry a stop + cancels does; + - a puppet tool call is refused while the retry waits, as during any live + turn, naming the hold and how to go on sooner; + - `abortInference()` ends the turn, and a turn whose budget restart or + tool-result guard's retry waits held or parked, where it reported + nothing to stop and the turn ran once the hold ended: one + `inference:aborted` with the stop's reason, and it returns `true`. Wakes + that arrived meanwhile still run: those the hold keeps beside the retry, + and those merged into the retry's or the continuation's start before it + began, which the stop puts back in the queue. A hold of such wakes alone + still has nothing to stop. From a listener of a failed stream's + `inference:failed` it reports `false`, as before (a turn's start that + fails still takes a listener's stop at its own check): every failure + site now reports `inference:failed` before a hold takes the turn's + retry, where a stream that throws, and the hold a conversation agent's + compile meets, used to hold it first; + - a quiesce with `abandon: true` puts back the wakes merged into a held + retry's or a continuation's start that hadn't begun, as a caller's stop + through `abortInference()` does: they park under the quiesce and run at + resume. Under a quiesce, a held retry starts alone, and the wakes merged + into its start before the quiesce park until resume. An abandon of a budget + restart's start merged with other wakes used to drop those wakes with + it, so their messages went unanswered until something else woke the + agent. + + A wake a hold keeps at admission, with no turn of its own begun, keeps no + turn alive, as before. + +- What an MCPL peer's content block carries in its other fields now + reaches a script with RFC-005's treatment at any depth, as the block + itself does: an RFC-005 reference there arrives as its stub's facts, + bound to that server and not fetched for the script (its `savedTo` is + null unless an earlier fetch saved that URI), one the classifier + rejects as its notice, and inline data that also claims a URI of any + type, an empty one included, or a disposition, as the withheld notice. + Before, such fields reached a script whole, references' URIs included. + `structuredContent` still reaches a script whole, from every tool, in + any result that can be converted to JSON. + +## 0.26.1 — 2026-10-23 + +### Added + +- `HostModeStatus.activeTurnAgents` lists, sorted, the agents with a turn + alive, the ones `activeTurns` counts: each from its turn's dequeue to its + settled teardown, so a turn in its hooks or compile, one finishing its + deliveries, or one whose continuation is on its way to its start, is + listed. So a host can record which agents are mid-turn before it abandons + their turns or stops. + +### Changed + +- `stop()` ends a turn whose continuation hasn't started, as `shutdown`, + with the continuation dropped. +- A budget restart's `inference:stream_restarted`, and its boundary's usage + report, now come after its continuation is queued and its stream + cancelled. + A restart boundary reached while the framework is stopping emits no + `inference:stream_restarted` and queues no continuation: the turn ends as + `shutdown`. + +### Fixed + +- A coalesced `push/event` or `channels/incoming` message whose receipt the + host couldn't write is no longer admitted again when the producer retries. + The failed write was answered "host could not persist the acceptance; retry" + after the occurrence's effects were in, and no receipt was kept. So each + retry was a fresh admission: a copy the agent had read arrived again, a + deferred notice rendered again, a deletion notice was appended again, and the + answer named the occurrence's own eventId as `priorEventId`. Now the receipt + is kept. A retry writes it again and makes it durable, then answers the first + result without repeating an effect, as RFC-006 §3.1 has a coalescing host + do. While the write keeps failing, each retry gets the same answer and + repeats nothing. A host killed before a later save and commit carried the + receipt still admits the retry as a new occurrence. +- On `channels/incoming`, a coalesced message admitted whose receipt couldn't + be written or made durable was answered `accepted: false`, which says it + wasn't admitted, so a producer could hold it as never delivered. The request + is now answered with a -32000 error carrying the same message, since a + per-message result can only say a message was taken or wasn't, and the + messages after it in the request aren't admitted until they're sent again. + An uncoalesced message before it in the same request is admitted again when + the request is sent again. A retry of an admitted message while the host is + stopping fails the request the same way. A failure before any effect, such + as "host is stopping" for a new message, is still a per-message refusal. +- A save of the coalescing state that fails is kept for the next save or the + host's stop, instead of being dropped until the state next changes. + +- A module's blocks in a script's `content` now get the treatment the + model's lanes give a module's result, as an MCPL peer's already did, + since what a script prints comes back to the model. Such blocks include + an MCPL peer's result that a module got through `ctx.callTool` and + returns whole. An RFC-005 reference anywhere in them, a `disposition: + "never"` one included, arrives without its URI, as the stub's facts an + MCPL peer's reference arrives as (`refId`, the labels the block gave, + `savedTo`). Inline data that also claims a URI, of any type, or a + disposition arrives as the withheld notice; a null or absent URI is + none, and in a module's result so is an empty one, as the model's + history reads it. A reference the classifier + rejects arrives as its notice; that includes an MCP-form embedded + resource, whose URI sits under `resource`, which the model's history + shows the same way, so a script no longer gets its text. Anything else + arrives as the module returned it. Before, a module's structured result + reached a script with its blocks as the module returned them, + references' URIs included. Which results are a module's is decided by + where the dispatch sent the call, not by the tool's name. An MCPL + peer's inline data beside a URI that isn't a string, such as an object, + now also reaches a script as the withheld notice. `structuredContent` + still reaches a script whole, from every tool, and so do fields a server + puts outside its content blocks' reference positions. + +- A turn whose continuation (a budget restart's, or a tool-result guard's + retry) is waiting to start now counts as alive. Between the turn's stream + ending and the continuation's start, a stretch that a provider wait stated + meanwhile makes as long as the wait, the host used to report `drained`, a + `quiesce()` returned drained and the continuation then started while the + host was quiesced, and `quiesce({ abandon: true })` found nothing to end. + Now `activeTurnAgents` lists the agent, a `quiesce()` waits for the + continuation's turn, and an abandon ends the turn with the continuation + dropped. A puppet tool call and a branch switch are refused in that + stretch, as during any live turn. +- A turn whose continuation is dropped by an abandon, `stop()`, the + inference policy, the agent's removal or a conversation agent's disposal + now ends, with one `inference:aborted`. A drop other than an abandon or a + stop also releases the EventGate window held for the continuation, + closes the turn's logical turn and withholds the tool-result guard's + pending batch from later requests, as for a turn that ends before it + sends anything. A provider hold left empty by the drop releases without + queueing a retry turn. An ephemeral run that ends takes such a + continuation with it, its own end having ended the turn. + +## 0.26.0 — 2026-10-23 + +### Breaking + +- **Hosts:** agent-framework now requires `@animalabs/membrane` ^0.7.1 (was + ^0.6.1) and `@animalabs/context-manager` ^0.20.0 (was ^0.19.0), which + admit no membrane 0.6.x. A host whose own membrane range is still ^0.6 gets + two membrane copies, and an error one copy builds isn't an instance of the + other's `MembraneError`, which the framework's error policy reads. + **Migration:** move the host to membrane ^0.7.1 with this release + (connectome-host 0.13.0 does). The framework calls through the `Membrane` + its host passes in, so membrane 0.7's retry changes reach its turns through + the host's retry config: since 0.7, membrane also retries on + `streamYielding()`, the turn path, inside each of the framework's own + attempts, and the framework's error policy retries above it as before. + **Unchanged:** the framework's error policy and what it does with a + failure that reaches it. + +### Fixed + +- A cut tool result's spill notice now survives every reader's per-message + cut while the turn, each result at its least, fits the bound, unless a + mount name past about seventy characters makes the notice longer than its + room, or results' notices differ in length (by call ids of different + lengths) and the turn passes the bound by that difference. Under a turn's + share of the bound, a result is at its least whole where it's no longer + than what its cut would store in its place, and otherwise as its notice + alone. 0.24.1 said the notice survived until a turn's notices alone filled + the bound, which wasn't true for a notice longer than its share: results + stored within their shares could leave it no room, and a result just past + its share was stored as a notice longer than the result. Now, in a turn + stored by its share, such a result is stored whole, and save_recent_image + counts the image slots it holds. A turn whose results fit the bound + together is stored as before, unless a save_recent_image in it had it + stored by its share only because such a result's slots differed: it's now + stored as each result would be alone, as other turns that fit are. +- A spill notice's paging advice in a turn stored by its share is sized to + the inline cap that a later read of the file comes back under, alone in + its own turn, rather than to the share. Under a share below the notice's + room, the advice was to read one character at a time. + +## 0.25.0 — 2026-10-22 + +### Breaking + +- **Agents, and whoever writes their prompts, where a mount holds a + directory named like the mount itself:** a `workspace--glob` pattern, or a + `workspace--grep` `glob` filter, whose first segment is that mount's name + is now read mount-prefixed in that mount, from its root and within + `path`, and no longer as written there. So a pattern written relative to + the mount, such as `work/z/*.ts` for the file the tools show as + `work/work/z/a.ts`, no longer finds that file. With a `path`, the same + goes for such a directory anywhere beneath it: `glob` with `path: + "notes/diary"` and `pattern: "notes/*.md"`, written relative to the path + for `notes/diary/notes/x.md`, no longer finds it. Where nothing else + matches, the agent gets an answer with no matches (`glob`'s + `"matches": []`, `grep`'s `"results": []`), not an error. + - **Migration:** write the path as the tools show it: `work/work/z/*.ts` + finds `work/work/z/a.ts`, and `notes/diary/notes/*.md` finds + `notes/diary/notes/x.md`, with that `path` or without one. + - **Unchanged:** in every other mount, and for a pattern whose first + segment names no mount, the pattern is still matched relative to the + mount (or to `path`). + +- **Script authors (`code_execution`):** a tool's text result now reaches a + script as that text, in its result's `content[0].text`, where it was the + text JSON-encoded. That covers every MCP tool whose result is text only, + and every module tool whose `data` is a string, without structured + content. A script saved before the upgrade meets the change too. Drop a + `JSON.parse` that only unquoted such a result. Where the text is itself + JSON, one `JSON.parse` now returns the data, so drop the second. A script + that read every result with `JSON.parse` can parse inside a `try` and + keep the text when it throws; a string whose text is itself JSON, such as + the text `123` or `true`, then reads as the value it spells. A result + whose data isn't a string, and a result with structured content, reach a + script as before. + +### Changed + +- Requires `@animalabs/context-manager` ^0.19.0 (was ^0.18.0) and + `@animalabs/membrane` ^0.6.1 (was ^0.6.0). context-manager 0.19.0 stores a + lone UTF-16 surrogate half in edits and state writes as U+FFFD, where it + left state the store couldn't read back, and fixes image-strip pricing; + its subclass-facing breaking changes are in its own notes. membrane 0.6.1 + runs complete tool calls a provider returns under `stop_reason: end_turn`, + where 0.6.0 ended the turn without running them. + +- A tool result whose `data` is a string now reaches the model as that text. + It was JSON-encoded on the way, quoted with every newline, tab and quote + escaped, so a multi-line answer, such as a module tool's line-per-item + listing, arrived as one escaped line: in context, on the live wire, and in + its spill file under `tool-results/`, which `workspace--read` could then + page only by character and `workspace--grep` matched as a single line. A + spill file now holds the string as the tool returned it. Data that isn't a + string reaches the model as before. + + Results stored before the upgrade keep the quoted text they were stored + with, so a conversation shows both forms until its older results leave + its context. A string is measured as its own text, so one whose JSON form + went over the inline cap while its text doesn't, such as a long listing + of short lines, now stays inline where it used to spill to a file. A lone + UTF-16 surrogate in the string, which JSON escaped, reads as U+FFFD. For + a result without an error, `data: ''` reads as an empty result, as + `data: undefined` already did. + +### Fixed + +- A tool presentation file holding `{}`, an object with no keys, is read as the empty document, as a missing presentation file already is. A description defaults file holding `{}` is read as one too, where a missing defaults file is still reported. Before, `{}` was refused for its missing `"version"` and `"tools"`, so every `set_tool_visibility` and `set_tool_description` call refused and the resident was told to ask whoever runs the host to repair the file, though `{}` is what an operator writes by hand to start one. A 0-byte file, and a document with only one of the two keys, are still refused. `parsePresentation('{}')` returns the empty document too, where it threw. + +- `workspace--glob` finds files from a mount-prefixed pattern, such as + `notes/diary/2026-10-13.md` or `project/src/**/*.ts`, as every other + workspace path is written and as its own results are returned. The pattern + was matched only against paths relative to the mount (or to `path`), so a + mount-prefixed one answered "no matches" for files that were there. When a + pattern's first segment names a mount, that mount now matches its rest from + the mount's root, in place of the pattern as written. Every other mount + still matches the pattern relative to the mount (or to `path`), so + `notes/*.md` still finds `project/notes/todo.md` beside the `notes` mount's + own files. In a mount holding a directory named like itself, this narrows + what a pattern written relative to the mount finds (see Breaking). + `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same + way. In both, a `**/` at the start of a path segment now matches zero or + more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and + `**/*.md` also finds the `.md` files at a mount's root; it used to need at + least one directory there. After a brace group whose alternatives each end a + directory, a `**/` matches zero or more directories, and so does a `**` + that ends an alternative before a `/`, so `{src/,lib/}**/*.ts` and + `{src/**,lib}/*.ts` find `src/main.ts`, as `src/**/*.ts` does. `glob`'s description and its `pattern` + parameter's description changed, and so did `grep`'s `glob` parameter's + description, so the first request after the upgrade that carries the + workspace's tools misses the prompt cache from its tool block on. + +- **The prose bounce-wake cap no longer latches.** A bounce wakes the resident + so it can resend, at most twice in a row, so a bounce → wake → bounce loop + ends. The count reset only when a prose envelope was delivered, so a + resident that recovered from bounces another way (a send tool, setting the + draft aside) kept the count across turns: from its third bounce on, a reply + to a fresh message bounced with no wake, and sat unseen until something + else woke the resident. The count now also resets when a turn starts with a + cause of its own (a channel message, a server event, a gate flush, a timer, + an operator nudge), in explicit and hybrid modes. A loop of bounce wakes + with no other cause is still capped at two. + +- What reaches a request without passing through the context store now carries a lone UTF-16 surrogate as U+FFFD, as the store keeps it (#695). That's a context injection's text and namespace, a hook's or a module's, and what a tool boundary injects into the live round: the messages it heard, with their senders' names, and its own notices. Before, every request an injection was in carried the lone half raw, and the API refuses such a request with a 400 ("The request body is not valid JSON: unexpected end of hex escape"). A namespace is still compared with channel ids as given when an injection is kept to its channel's conversation. + +- A lone UTF-16 surrogate in a tool result's text now reaches membrane as U+FFFD, as the context stores it (#695). Before, a text block's text, a reference's stub (from its `name`), an error result's text and the error text of a failure the tool doesn't flag went to membrane with the lone half raw, while the stored copy held U+FFFD. With the tool-result guard on (`toolResultGuard`), such a result left a stored message the context couldn't read back; with a text block or an error result, the agent's next turn sent no request and `stop()` threw. This change doesn't repair a store that already holds such a message. + ## 0.24.1 — 2026-10-22 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b2570f7a..397a71dd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -197,7 +197,7 @@ never do. ```bash npm install npm run build # tsc; tests run from dist/, so build first -npm test # node --test --test-force-exit --test-timeout=600000 dist/test/*.test.js, with test/helpers/force-exit-blocking-stdout loaded first +npm test # node --test --test-force-exit [--test-concurrency=$AF_TEST_CONCURRENCY] --test-timeout=600000 dist/test/*.test.js, with test/helpers/force-exit-blocking-stdout loaded first npx tsc --noEmit # typecheck ``` @@ -211,8 +211,19 @@ minutes; the slowest file, `mcpl-runtime-lifecycle`, takes about three. Each job in `ci.yml` also has a `timeout-minutes` limit, which ends a hang the test timeout doesn't reach. +CI (`ci.yml`'s Build & Test, and `publish.yml`) runs as many test files at +once as the runner has cores, by setting `AF_TEST_CONCURRENCY`; `npm test` +passes it on as `--test-concurrency` only when it's set. Unset, as it is +locally, Node's default of one file fewer than the cores applies. To run the +suite as CI schedules it, for example to chase a failure that shows only +under load, set it: `AF_TEST_CONCURRENCY=$(node -p "require('node:os').cpus().length") npm test`. + Push-time CI (`ci.yml`) builds, typechecks and tests every push and PR on -ubuntu and macos. Lockfiles are deliberately gitignored in this repo, so CI +ubuntu and macos. macOS runners queue, so a PR runs macOS's Build & Test at +the engines floor (Node 22.12.0) only, and code_execution's Bun macOS legs +only when it changes source, `tsconfig.json`, `package.json`, their tests, +the test helpers or `ci.yml` (the `changes` job decides). A push to main runs +every leg. Lockfiles are deliberately gitignored in this repo, so CI installs with `npm install` rather than `npm ci`, and there is no lock to keep in sync. It also runs the pack check the publish workflow runs before publishing (`node scripts/pack-installs-alone.mjs`, on ubuntu, with bun set @@ -226,4 +237,7 @@ root links the four packages together. CI check contexts follow the workflow matrices. When a matrix changes, a maintainer should check that the repository's required-status list matches it; -workflow edits do not change branch-protection settings themselves. +workflow edits do not change branch-protection settings themselves. The macOS +legs a PR may leave out (Build & Test on Node 24 and the newest Node, the Bun +macOS legs) report no check on that PR, so a required check must not name +them, or such a PR waits on a check that never comes. diff --git a/README.md b/README.md index f9fbfb1d4..a2731d36e 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ interface Module { } ``` -An `EventResponse` can add/edit/remove messages, request inference, signal tool changes, and atomically update module state: +An `EventResponse` can add/edit/remove messages, update module state, request inference, and signal tool changes. The framework applies its message changes, then its state update, then its inference request; when one of these throws, what came before it stays applied, that step may have taken effect in part, and the rest of that response isn't: ```typescript return { @@ -453,7 +453,7 @@ The non-streaming path uses `AbortSignal` forwarded to `membrane.stream()`, whic A turn whose stream is cancelled this way, or by `agent.cancelStream()`, ends as a stop, whether the stream reports the cancel as `aborted`, as an `error`, or by just closing: one `inference:aborted` carrying the caller's reason (`user` when none was given), a `[turn-interrupted]` note in the agent's context, and never `inference:exhausted`, which is for failed inferences. A stream that membrane aborts on its own, at a deadline or on an error (reason `timeout` or `error`), is a failed inference. `stop()` and an operator's quiesce abandon end a turn as a stop too, with their own reasons, `shutdown` and `quiesce_abandoned`, and write no note. A stopped stream still writes its inference-log row, with `success: false`. -A caller's stop and an operator's quiesce abandon also reach a turn that has no live stream: one running its modules' `gatherContext`, its MCPL hooks or its compile, one waiting to retry a failed inference or compile (`abortInference()` and an operator's abandon end the wait, and a stop through `Agent.cancelStream()` waits for it to run out), and one whose tool-result guard is waiting to retry after a refusal, which holds only the round's finished stream. The turn ends at its next check, before it sends anything more, as a stop on a live stream ends it, and a turn whose compile fails after the stop ends at the failure, before a provider hold, the error policy's retry or `inference:exhausted`. A caller's stop leaves the note saying the turn was stopped before it sent anything or before it finished, and an operator's abandon leaves none. `abortInference()` returns `true` for such a turn. In the guard's wait, `stop()` ends the turn as a stop with reason `shutdown`, and an operator's abandon ends it as the abandon, even when a caller's stop came first; with both, it ends as `stop()`'s. `stop()` drops a turn in its hooks, its compile or a wait to retry before it sends anything, as before, and drops one whose compile fails while it runs, once a caller's stop has reached it, rather than ending it as that stop. While the failure of a turn's stream is reported (`inference:failed`), the agent no longer holds that stream, so `abortInference()` from a listener reports `false`. A failure to build a turn's request is reported while the turn's check is still ahead, so there `abortInference()` reports `true`, and the turn ends at that check. A stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, while a budget restart or a non-streaming turn's tool results wait to start the turn's next call, and while a provider-capacity hold or a provider's stated wait has parked a turn until the hold ends. +A caller's stop and an operator's quiesce abandon also reach a turn that has no live stream: one running its modules' `gatherContext`, its MCPL hooks or its compile, one waiting to retry a failed inference or compile (`abortInference()` and an operator's abandon end the wait, and a stop through `Agent.cancelStream()` waits for it to run out), and one whose tool-result guard is waiting to retry after a refusal, which holds only the round's finished stream. The turn ends at its next check, before it sends anything more, as a stop on a live stream ends it, and a turn whose compile fails after the stop ends at the failure, before a provider hold, the error policy's retry or `inference:exhausted`. A caller's stop leaves the note saying the turn was stopped before it sent anything or before it finished, and an operator's abandon leaves none. `abortInference()` returns `true` for such a turn. In the guard's wait, `stop()` ends the turn as a stop with reason `shutdown`, and an operator's abandon ends it as the abandon, even when a caller's stop came first; with both, it ends as `stop()`'s. `stop()` drops a turn in its hooks, its compile or a wait to retry before it sends anything, as before, and drops one whose compile fails while it runs, once a caller's stop has reached it, rather than ending it as that stop. While the failure of a turn's stream is reported (`inference:failed`), the agent no longer holds that stream, so `abortInference()` from a listener reports `false`. A failure to build a turn's request is reported while the turn's check is still ahead, so there `abortInference()` reports `true`, and the turn ends at that check. A caller's stop made where no check of a turn is ahead stops nothing, and doesn't end the agent's next turn: while the agent is idle, while a turn finishes its last delivery, and while a non-streaming turn's tool results wait to start the turn's next call. A turn whose continuation (a budget restart's, or a tool-result guard's retry) waits to start, or whose failed call a provider-capacity hold or a provider's stated wait keeps for its retry, is ended by `abortInference()`, by an operator's quiesce abandon and by `stop()`: the continuation or retry is dropped, and the turn ends with the caller's reason, `quiesce_abandoned` or `shutdown`. After `abortInference()` or an abandon, wakes that arrived meanwhile still run (after the abandon, once the host resumes): those a hold keeps beside the retry, and those the scheduler merged into the retry's or the continuation's start before that start began, which the stop or the abandon puts back in the queue. A stop through `Agent.cancelStream()` stops nothing there. ## API Server diff --git a/changelog.d/694-grep-glob-by-name.fixed.md b/changelog.d/694-grep-glob-by-name.fixed.md new file mode 100644 index 000000000..58cfea0b3 --- /dev/null +++ b/changelog.d/694-grep-glob-by-name.fixed.md @@ -0,0 +1,22 @@ +- `workspace--grep`'s `glob` filter matches a file's name at any depth when + it holds no `/`, as ripgrep's `--glob` does, so `*.ts` or `*.{ts,tsx}` + finds those files wherever they lie in what's searched (#694). It was + tested only against the path from the mount's root, so with no `path` it + let through only the files at a mount's root, and with a `path` below the + root, a directory or a file, it let through none, answering "no matches" + for files that were there. The path is still tried too, so every file a + filter let through before still passes. A filter with a `/` is still + matched from the mount's root, whatever the `path`, or read mount-prefixed + in the mount its first segment names. +- Where a `path` and the directory a `workspace--grep` filter with a `/` + names before its first wildcard or brace lie apart, neither holding the + other, as `path: "project/src"` and `glob: "components/*.tsx"` do, the + grep is now refused, saying how the filter is read, where it answered + "no matches". `workspace--glob` is refused the same way where its + pattern is read mount-prefixed, as `path: "project/src"` and + `pattern: "project/lib/*.ts"` are, unless the `path` lies in another + mount's directory, which is said to be that mount's, as grep says it. + Where the `path` isn't a file, the refusal also names the filter or + pattern that reaches beneath it, mount-prefixed + (`project/src/components/*.tsx`), unless it has an empty, `.` or `..` + segment. diff --git a/changelog.d/702-gate-channel-ids.breaking.md b/changelog.d/702-gate-channel-ids.breaking.md new file mode 100644 index 000000000..8b0783665 --- /dev/null +++ b/changelog.d/702-gate-channel-ids.breaking.md @@ -0,0 +1,71 @@ +- **Hosts and residents with gate policies that name a channel:** a + `match.channel` in the registered form, such as + `discord::` or `discord::*`, now meets every event + in that channel, not only the messages that arrive while it's open + (#702). + + **Check your config for:** + - any `match.channel` in the registered form (for Discord, starting + `discord:`) without `scope: ["mcpl:channel-incoming"]`; + - any `keyBy: "channelId"`, whatever its policy matches. + + **What such a policy now does:** + - **Quieter:** a `defer` channel policy stops the channel's push events + waking the resident, including an @-mention in that channel while it's + closed, which a later addressed rule used to take. A `passive_sample` + one wakes on its every-Nth only (with `passthrough`, the others go on to + later rules). A `debounce` one batches and delays them, and a + `rate_limit` one can deny them. + - **Louder:** an `always` channel policy ahead of a `defer` rule wakes on + every reaction, edit and voice report in that channel. + - **A later policy can lose events:** a later policy, such as one naming + the channel's raw id, no longer receives the pushes an earlier + registered-form policy now matches, though no pattern matches fewer + events than before. Neither the shadow lint, which compares patterns as + written, nor `wake_add_rule`'s before/after table, whose probes name no + channel, shows this. + - **Counting:** every `rate_limit` and `passive_sample` with `keyBy: + "channelId"` counts a channel's push events with its messages, under its + registered id. A push was counted under its raw id, or in the shared + bucket when it carried none, as a voice report doesn't. So a channel's + messages and pushes now share one bucket or counter, and voice reports + in different channels no longer share one. + - **A host that builds its own `EventGate` with `resolveRouteChannel`:** + its resolver, which routed batched wakes, now also decides which channel + policies a push meets and how `keyBy: "channelId"` counts it. + `EventGate`'s default resolver gives no push a registered id, so with it + a push matches and counts by its own id, as before. + + **Migration:** to keep a registered-form channel policy to the channel's + messages, closed channels' addressed ones included, add `tagsAny: + ["chat:ambient", "chat:addressed"]` to it. Those are Discord's message + tags, and its reactions, edits, deletes, voice reports and catch-ups carry + neither; another adapter's markers may carry `chat:addressed`. To keep it + to exactly what it caught before, add `scope: ["mcpl:channel-incoming"]`, + since a registered-form pattern matched no push before. Neither changes + how `keyBy: "channelId"` now counts. A pattern written as `*`, + which matched both paths before, matches as it did, and now also matches a + push that names its channel only by `mcplChannelId`, such as a voice + report. + + **Unchanged:** which policy an event meets stays as it was in a config + whose rules for the push events it receives (addressed messages, + reactions, voice reports and the rest) come before its channel policies + and aren't `passthrough`, and for a channel policy scoped to + `mcpl:channel-incoming`. Counting with `keyBy: "channelId"` changes even + there, as above. + + **Why:** a message in an open channel arrives through `channels/incoming` + with the registered id, but every other event arrives as a push: an + addressed message in a closed channel, a message in a channel the adapter + subscribes to without opening, and in any channel a reaction, an edit, a + delete, a voice report or a missed-message catch-up. A push's own channel + id is the adapter's raw one (a bare Discord snowflake), or none, as on a + voice report, so a registered-form pattern missed all of those, and each + fell through to a later policy. The pattern is now tested against the + event's own id and, when that misses, against the registered id the + framework resolves for the push: its origin's `mcplChannelId`, or, for a + Discord origin without one, its guild and channel (`discord:dm:` + when it names no guild, as a DM's doesn't). A push whose origin names its + channel neither way, from another adapter, still matches by its raw id + alone. diff --git a/changelog.d/758-presentation-bom.fixed.md b/changelog.d/758-presentation-bom.fixed.md new file mode 100644 index 000000000..61368126d --- /dev/null +++ b/changelog.d/758-presentation-bom.fixed.md @@ -0,0 +1,8 @@ +- A tool presentation file or a description defaults file saved as UTF-8 with + a leading byte order mark, as some Windows editors save a hand-edited file, + is read like the same file without one. Each was refused as "isn't valid + JSON". For a presentation file, none of its settings applied, and + `set_tool_visibility` and `set_tool_description` refused to write until it + was repaired (#758). For a defaults file, the descriptions it sets didn't + apply. Exactly one leading U+FEFF is skipped, as `gate.json` already does + (#395). diff --git a/changelog.d/af-owns-turn-retries.breaking.md b/changelog.d/af-owns-turn-retries.breaking.md new file mode 100644 index 000000000..f4b1ef67b --- /dev/null +++ b/changelog.d/af-owns-turn-retries.breaking.md @@ -0,0 +1,30 @@ +- **Hosts and custom `errorPolicy` authors:** the framework is now the one + retry layer for its turns. A turn passes `retry: false` to membrane, so + membrane sends a failed round once and the error reaches the framework, + where since membrane 0.7 membrane's own schedules (up to ten minutes at a + 529) ran inside each of the framework's attempts, out of its sight. Each + retry is now a fresh compile, its wait is traced, and a stop cancels it. + The default error policy decides on membrane's schedules + (`@animalabs/membrane/retry`; `MEMBRANE_RETRY_SETTINGS` unless a host + passes `new DefaultErrorPolicy(settings)` as `errorPolicy`), where it + retried any retryable failure three times, 1, 2 then 4 s apart: + - a 529, or an overloaded error mid-stream, gets the overloaded schedule: + seven tries, jittered waits from 10 s doubling to five minutes; + - a 429 gets at least five tries on the base schedule; + - any other retryable failure, a stream deadline included (membrane itself + leaves a deadline to its caller), gets three tries, 1 s then 2 s, and so + does an error that isn't a MembraneError; + - a provider's stated wait is waited when it is longer than the backoff, up + to ten minutes (the framework's provider-hold cap), as before; + - a failure that isn't retryable ends at once, as before. + A custom `ErrorPolicy` now sees failures membrane used to retry first (a + 5xx, a 529, a dropped connection, a 429), so it decides those itself. + **Migration:** decide with membrane's own helpers (`classifyFailure`, + `retryDecision`), as the default policy does, or wrap the now exported + `DefaultErrorPolicy`. **Unchanged:** the provider holds before the policy + (a resident's stated waits and acceleration 429s), refusal re-issues + (`refusalRetries`), a direct caller of `Agent.startStream` (it keeps + membrane's retries; `ActivationRequestOptions.membraneRetries: false` is + what the framework passes), and calls that aren't turns (the MCPL + inference router, compression and other host calls keep membrane's + retries). diff --git a/changelog.d/calibration-request-overhead.fixed.md b/changelog.d/calibration-request-overhead.fixed.md new file mode 100644 index 000000000..80eb97941 --- /dev/null +++ b/changelog.d/calibration-request-overhead.fixed.md @@ -0,0 +1,9 @@ +- The estimator calibration sample hands the context strategy the system + prompt and tool definitions the request sent, beside the provider's input + total, so the strategy compares that total with an estimate of the whole + request, not of its compiled window alone (context-manager #323). Before, + a tool-heavy agent's calibration multiplier learned its tool schemas as a + per-token rate, or sat at 1.00 rejecting every sample as out of band. A + context-manager without the second argument ignores it, 0.20.0 included, + so the multiplier learns as before until the host runs a context-manager + release with context-manager #324. diff --git a/changelog.d/cedric-f55-workspace-text.breaking.md b/changelog.d/cedric-f55-workspace-text.breaking.md new file mode 100644 index 000000000..b2efa7aef --- /dev/null +++ b/changelog.d/cedric-f55-workspace-text.breaking.md @@ -0,0 +1,49 @@ +- **Scripts, hosts and modules that read the workspace's `read`, `grep`, + `edit` or `edit_many` results:** each now answers as the text the model + reads, with its fields beside it in `structured`. The model read each as + one line of JSON, a page's lines escaped inside it. It now reads a header + of the result's fields, one `key: value` to a line (`path: …`, + `totalLines: …`, `nextOffsetChars: null`), then labelled sections: a + page's `--- content ---`, grep's matches by file and version (`N:` for a + match, `N-` for a line of its context, as grep writes them), what grep + skipped, and a receipt's `--- files ---` and `--- changed ---`. A value + outside a page's content and a receipt's changed lines that holds a line + break (LF, CR, VT, FF, NEL, U+2028 or U+2029) or starts with `"`, such as + a path or a line grep matched, is written as a JSON string, so a value + shown as it is never starts with `"`. A control character in a file + reaches the model as itself, where JSON wrote an escape such as `\u001b`. + + **Who needs to act:** + - a `code_execution` script that parses one of these results' text as + JSON (`JSON.parse(result.content[0].text)`): read + `result.structuredContent`, which holds the fields the JSON held; + - a host or module that reads these results' fields from `result.data`, + through `executeToolCall` or `ctx.callTool`: read `result.structured`. + connectome-host reads either since 0.11.0 + (anima-research/connectome-host#324), and needs that version or later: + before it, its file viewers show such a file as empty. + + **What a page holds:** a page is held to the call's result limit as its + text measures it, where it was measured as JSON, and so is a receipt. + - A model's page holds more of a file whose lines JSON escapes: at the + default 24,000 characters, a page of this repository's + `src/modules/workspace/index.ts` now ends at line 475, where it ended at + line 454. A spilled result's notice sizes the read-back page it suggests + the same way, so below a cap of about 12,300 characters it suggests a + longer one than before. + - A script's page is never shorter than before, since a page's text is + shorter than its JSON, unless the text quotes its path or a note. A script's answer carries the result's fields, a + page's content among them, beside the text, and the limit counts the + text alone. A script's limit counts each character at the six bytes it + takes at most in the answer, and only a control character takes more + than three, so the two together can pass what the script takes only on + a page of more than half that limit (about 3.7 million characters at + the default 128 MiB of script memory) that holds control characters. + Such a page answers `ResultTooLarge`, which cuts nothing, and the script + reads a smaller page. + + **Unchanged:** the tools' descriptions and inputs; the words of their + notes; their errors, which were text already; the results of `write`, + `delete`, `ls`, `glob`, `status`, `materialize`, `sync` and the other + workspace tools, whose fields stay in `data`; and a page for a call made + in process without a result limit, which holds to its line limit alone. diff --git a/changelog.d/deferral-found-later.fixed.md b/changelog.d/deferral-found-later.fixed.md new file mode 100644 index 000000000..873fdb364 --- /dev/null +++ b/changelog.d/deferral-found-later.fixed.md @@ -0,0 +1,4 @@ +- A deferred write that throws after it's stored, whose store can't be read back to tell whether it landed, is now looked up by its id at the boundary that wrote it, so it counts as written there, where it landed, and at a tool boundary it's injected with what that boundary stores, before the turn's later replies. Before, it was queued as a doubt and counted at a later flush where it first landed, ahead of later replies whose requests never held it. That's still so, as a known limit, for a write that can be neither read back nor looked up there: on an account where the provider enforces its binding of signed thinking to the prefix it was made with, a later request replaying the signed thinking of a reply stored after it, whose request didn't carry it, is refused with a 400 (context-manager #155). A recompile before the next tool boundary, such as a retry, carries such a copy where it was stored, and the boundary that then finds it injects it again, as before, so a later request replaying the signed thinking of a reply made after that boundary is refused the same way. +- Looking a held write up by its id no longer fails because another message's blob is missing: where context-manager's query throws, the branch is scanned without blobs, and only the rows that match are read with theirs. Before, every such write stayed a doubt, holding what was queued behind it for its agent, and each read logged it again. The lines saying a store can't be searched, and that a drain holds what's queued behind such a write, are now said once per write until a lookup succeeds. The first is said again when its failure changes, and names the query's own error, which names the missing blob. +- A module's `onProcess` response that throws as the framework applies it (at an edit of a held write that may already be stored while its store can't be searched, an edit of a message the store doesn't hold, or any store error) no longer ends the event's processing. The response stops where it threw, as before: what came before the throw stays applied, the operation that threw may have taken effect in part or whole, and the rest of it, its state update and inference request included, isn't. A line now names the module and where it threw, `process:completed` and the process log name the response with where it threw (`ModuleProcessResponse.threw`), and the other modules' responses, a `tool-call` event's dispatch, the event's trace and its process log go on. Before, the throw escaped the event's processing and all of these were lost. `EventResponse`'s docs, the README and two comments no longer say a response is applied atomically, which it wasn't: they say it's applied in order, up to a throw. +- The log line for a held write settled through a stored row a module names says when that row is one shard of a body, rather than that the whole write was stored. diff --git a/changelog.d/dendrite-main-merge.changed.md b/changelog.d/dendrite-main-merge.changed.md index 81cab89fa..865d3b1e9 100644 --- a/changelog.d/dendrite-main-merge.changed.md +++ b/changelog.d/dendrite-main-merge.changed.md @@ -57,3 +57,44 @@ refused in main's words. Under the author rule a placeholder for content the host can't read is stored under its author too, `User: [Content not shown: …]`, which main's parity test now counts as such. +- Merged `main` at `8ec1de5a` (0.26.2's changelog and #720). At a tool + boundary the branch still releases each stored message's landed mail and + skips one held from the residents, and what it injects is then #720's: a + message the render gives null goes in as stored. +- Merged `main` at `a4a74b48` (0.26.0 and after), where #710's + server-qualified channels meet the branch. A conversation fork's home + stays the agent registry's `homeChannel`, which the framework reads + wherever main read its own map of homes. `tune_out` and `focus` resolve + their channel as main's channel tools do: with a `serverId`, that server's + channel; inside the calling turn, the turn's own channel by its id; + otherwise the one registered channel a name answers to, by its id or as + `serverId:channelId`, and a name two channels answer to is refused with + them as candidates; `check`'s `peek` takes the same names and refuses what + they refuse, a channel no server registers now included. Their answers, + and the all-except announcement, name and reach the channel on its server. + The subconscious's `deliver_summary`, `cancel_tuneout` and + `speak_in_channel` resolve a channel as main's do, from a reader fork too. + A `cancel_tuneout` that names the all-except scope ends that hold, as + before, whatever channel its `channelId` picks or fails to pick (a name two + servers' channels answer to, or a turn's own channel its server no longer + registers); only a channel with a tune-out of its own is cancelled instead. + The latest delivered + inbound message, which admission records, keeps its server, and a message + whose server no longer registers its channel sets none. A + `channel_publish` naming no channel takes the turn's locus only when the + call is made inside the turn; outside it, a conversation fork's goes to + its home, and anyone else's takes the registry's default. So main's + shared-channel-ids entry, which says such a publish posts to the latest + message's channel, holds here only under `speech.locus: + 'latest-inbound'`, for a call made outside a turn or in a turn with + neither a locus nor a trigger channel. Every way a live non-ephemeral agent ends drops a continuation + of its turn that is on its way (#722), and a conversation agent stopped by + a listener of the failure its hold after the compile reports is held by + nothing. + `AgentSpec.homeChannel` and the registry record's `homeChannel` are a + channel with its server, `{ serverId, channelId }`, and + `conversationForkSpec` takes it as `homeChannel` in place of `channelId`. + A record that keeps a bare id, as one written before does, is read as a + wake that names its channel without its server is: the one registered + channel with that id, or, where none or several are, a home on no + server. diff --git a/changelog.d/empty-presentation-object.fixed.md b/changelog.d/empty-presentation-object.fixed.md deleted file mode 100644 index 4047e14e4..000000000 --- a/changelog.d/empty-presentation-object.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A tool presentation file holding `{}`, an object with no keys, is read as the empty document, as a missing presentation file already is. A description defaults file holding `{}` is read as one too, where a missing defaults file is still reported. Before, `{}` was refused for its missing `"version"` and `"tools"`, so every `set_tool_visibility` and `set_tool_description` call refused and the resident was told to ask whoever runs the host to repair the file, though `{}` is what an operator writes by hand to start one. A 0-byte file, and a document with only one of the two keys, are still refused. `parsePresentation('{}')` returns the empty document too, where it threw. diff --git a/changelog.d/feat-continue-after-prose.added.md b/changelog.d/feat-continue-after-prose.added.md new file mode 100644 index 000000000..914c80c7c --- /dev/null +++ b/changelog.d/feat-continue-after-prose.added.md @@ -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. diff --git a/changelog.d/feat-workspace-read.changed.md b/changelog.d/feat-workspace-read.changed.md new file mode 100644 index 000000000..137233c84 --- /dev/null +++ b/changelog.d/feat-workspace-read.changed.md @@ -0,0 +1,6 @@ +- The workspace's `read` shows a file's text as it is, and says so. A line page shows a CRLF file's lines without the CR before each line feed, and leaves out a BOM. A line whose own text ends in a CR shows its line end's CR too, since `edit` reads a CR before a line feed as part of the line end, so text copied from the line still matches it. Its note says when the file has CRLF ends or a BOM, counting a mix of CRLF and LF ends and the lines that show both CRs. It also says when the file isn't valid UTF-8: each invalid byte sequence shows as U+FFFD, `edit` refuses the file, and `write` replaces it whole. Before, each line of a CRLF file ended in a CR, a BOM came first in the first line as U+FEFF, and invalid bytes showed as U+FFFD with no note. A character page still reads the stored text as it is, BOM and CRs included. +- On a page for a call that carries a `resultMaxChars`, such as a model's or a running script's, a line over 2,000 characters is cut, with a marker that gives the `offsetChars` and `limitChars` that read its rest. Before, a line page showed every line whole, however long. +- A `read` page stops before its result would pass `resultMaxChars`, the most of it that can reach its caller whole. For a model's call that's its agent's inline cap, 24,000 characters by default, within which a result is stored whole unless its round's results together pass the readers' bound; for a running script's call it's what the script's memory takes, 7,456,527 characters at the default 128 MiB. Its note says where to continue: the `offset` after its last line, or for a character page the `offsetChars` it also gives as `nextOffsetChars`, with the `limitChars` left of what it was asked for, so a reader that follows the notes ends where it asked to. Where even a page's first line would pass the limit, that line is cut shorter, at the most the page has room for. Where a page's last line is cut, its note names the `offsetChars` where that line's rest starts and the `limitChars` that reads it to the line's end, before the next line's `offset` if the file goes on. A page always moves on, so where its own fields (the path and its notes) and the least it can show pass a small limit, it passes the limit. At 1,000 characters, a page with no notes does that only for a path of about 690 characters or more. Its notes take room from that: with all three notes on a file's text and a conflict's, a path of 101 to 137 characters does it, and a note that names a symlink's target, as one saying disk couldn't be checked can, takes as much room as the target is long. Before, a page of up to 2,000 lines, or of `limitChars` characters, went out whole, and for a result that passed the limit the framework kept only its start, cut mid-JSON, writing the whole of it to a workspace file where the agent has one. A caller that pages 2,000 lines at a time and starts the next page at line 2,001 now starts where the note says. A running `code_execution` script's call has that larger limit, so its line pages still stop at the line limit, 2,000 by default, and its character pages at `limitChars`, unless a page would pass that. Its line pages cut a line over 2,000 characters and leave out CRs and a BOM, as a model's do, and a character page gives it the stored text exactly. A call made in process, through `handleToolCall`, `executeToolCall` or `ModuleContext.callTool`, carries no `resultMaxChars` unless its caller sets one. Its line pages stop at the line limit alone and show every line whole, as they did before, though now without the CRs and the BOM. +- The workspace's `grep` leaves out a CRLF end's CR, for matching as well as display, as `read` does, so `foo$` finds a CRLF file's line too, as it does under ripgrep's `--crlf`. A match's text and context are its lines as `read` shows them, but each line whole, where a `read` page for a call with a result limit cuts a line over 2,000 characters. A BOM isn't part of the first line, and a file with CRLF ends says so in its result's `lineEndings` (`CRLF`, or `mixed`). Before, a CRLF file's lines ended in a CR, so `foo$` missed them and a match's text copied into an edit carried the CR. A pattern written to find the CR of a CRLF line end no longer matches, except on a line whose own text ends in a CR, which keeps its line end's CR as `read` shows it. When a pattern names a CR, as a literal one or as `\r`, `\x0d`, `\u000d`, `\cM` or `\015`, and a file it searched has CRLF ends, the result's `note` says how many files searched have them and names them, each as `CRLF` or `mixed`, up to 20 and then how many more. It also says that a character `read` shows every CR in the workspace's copy, or in disk's file on a direct mount where the host has one. A pattern that names U+FEFF, over a file that starts with a BOM, gets a note that names the files that start with one and says a BOM isn't part of the first line. So a `grep` for `\r` or `^\uFEFF` still finds which of the files it searches have CRLF ends or a BOM. A CR that doesn't come before a line feed is still part of its line. +- The workspace's `read` first takes in a change on disk to a file the workspace holds, as a listing and an `edit` do, and shows that text. Its note says so when disk's copy had changed since the workspace last agreed with it. It also says when disk's copy couldn't be checked, or disk has none and nothing records whether it was deleted there, so it shows the workspace's copy; where the check gave up because the selected branch kept changing, it says to read again. A file disk deleted answers that disk deleted it and the deletion was taken in, and a file disk made binary or too large answers that it's on disk but not stored. Where the path's stored copy is a draft and disk changed or deleted it too, the read shows the draft, and its note names the conflict and the ways to settle it, a sync, or a forced materialize on a mount that isn't read-only: for a deletion, a sync of the path takes it and removes the workspace's copy. Before, a read of a path the workspace held didn't look at disk, so it could show a copy another writer had since changed; a path it didn't hold was observed, as now. +- A direct mount's `read` and `grep` show a file's text as a store-backed mount's do, with the same pages, cut lines, notes and `lineEndings`. Only the disk check is a store-backed mount's alone, since a direct mount reads disk as it is and keeps no copy of its own. diff --git a/changelog.d/fix-inject-render-null-as-stored.fixed.md b/changelog.d/fix-inject-render-null-as-stored.fixed.md new file mode 100644 index 000000000..455c1c369 --- /dev/null +++ b/changelog.d/fix-inject-render-null-as-stored.fixed.md @@ -0,0 +1,16 @@ +- A message that arrives during a turn's tool call, and that the strategy + renders as `null`, is injected into the live stream as stored again. The + memory strategy gives `null` for a message before its recent window (a + batch bigger than that window), and for every message while a head + window a topic change reset hasn't filled yet. Since 0.24.1, such a + message was left out of the live round, and a `[not shown]` system line + was stored in its place. But the next compile sends it raw, in order, + after the tool result, so the agent didn't hear it mid-turn, the line was + false, and the next request differed from the live rounds anyway. The + `[not shown]` line, and the log line naming what was left out, are gone. + A clock row the render gives `null` goes in as stored too. As stored + still differs from the next request where its per-message cap or an + image limit reaches such a message, since the render neither cuts it nor + counts its images: a batch with more images than `maxLiveImages`, for + one. It differs too for a single message too big for the recent window, + which the strategy stores in parts. diff --git a/changelog.d/gate-wake-line-handed-to-the-wake.added.md b/changelog.d/gate-wake-line-handed-to-the-wake.added.md new file mode 100644 index 000000000..a025c1a0e --- /dev/null +++ b/changelog.d/gate-wake-line-handed-to-the-wake.added.md @@ -0,0 +1,8 @@ +- `WakeProvenance.line` (optional) carries a batched wake's `[Gate: …]` line + when the gate hands it to the wake rather than writing it (`WakeLine`, now + exported: its event's entry, its policies, the channel it names and when + the event arrived; `wakeLineText` renders it as of a time), and + `InferenceRequest.wakeLines` carries it on the wake's request for the + turn's start to write or leave out. A host that builds its + own `EventGate` with `isNewestEntry` and handles `requestInference` itself + receives the line there instead of in the window. diff --git a/changelog.d/gate-wake-line-only-when-it-says-something.changed.md b/changelog.d/gate-wake-line-only-when-it-says-something.changed.md new file mode 100644 index 000000000..a18898034 --- /dev/null +++ b/changelog.d/gate-wake-line-only-when-it-says-something.changed.md @@ -0,0 +1,36 @@ +- A batched (debounced) wake for one MCPL message that is still the newest + entry in the agent's window runs without its `[Gate: 1 event matched] … + already shown above at arrival; this is a batched wake, not new content` + line. The line says which messages a wake is for, and for that one message, + right above it, it said nothing new: on a resident whose addressed messages + wake through a 5 s debounce, a third of the window was these lines, four in + five of them right under the one message they named. The wake itself, its + provenance and the reply's channel are unchanged. The line stays whenever it + says something: several messages in the batch, anything stored after the + message (the agent's own turn, another message), a turn running or a write + waiting, a message a tune-out holds, and content the line quotes (events not + stored at arrival). The gate asks the framework (`isNewestEntry`, by the + message's server, channel and message id, or a push event's event id); a + gate built without it writes every line, as before. A clock row stored after + the message doesn't count against it. The gate asks when the batch fires, + and the turn compiles later: a provider hold or a compression in flight can + leave room for another message to be stored in between. So when the message + is still the newest at the fire, the gate hands the line to the wake, and + the turn's start decides, after its own inputs land: if anything is stored + after the message by then, the line is written there, after what arrived and + before the start's own notices, with the same words and its age counted to + then, once, at the start of the agent the window belongs to, before its + answer. Another resident reading that window places none (every resident + gets every batched wake), so its request shows what the window holds, as + before. No start writes one while the host is quiesced or another resident + reading that window has a turn alive, where it would land inside that turn, + as a clock row waits then too; that start's request goes without it, and it + is written only if another fresh start of the wake comes. A line left out is + decided again if the provider holds the turn and its fresh start runs later; + if the first start's own notices then follow the message, as a routing + notice does when the turn's channel changed, the line is written. A retry of + the same start decides nothing. Each fresh start that leaves a line out + traces `gate:wake-line-omitted`, with its reason (`newest` or `held`); a + line whose write fails is logged and left for a later start. A turn that + never starts for the wake, or one that starts as the continuation of a turn + already running, writes no line and traces none. diff --git a/changelog.d/glob-mount-prefixed-patterns.breaking.md b/changelog.d/glob-mount-prefixed-patterns.breaking.md deleted file mode 100644 index 52706ea4c..000000000 --- a/changelog.d/glob-mount-prefixed-patterns.breaking.md +++ /dev/null @@ -1,18 +0,0 @@ -- **Agents, and whoever writes their prompts, where a mount holds a - directory named like the mount itself:** a `workspace--glob` pattern, or a - `workspace--grep` `glob` filter, whose first segment is that mount's name - is now read mount-prefixed in that mount, from its root and within - `path`, and no longer as written there. So a pattern written relative to - the mount, such as `work/z/*.ts` for the file the tools show as - `work/work/z/a.ts`, no longer finds that file. With a `path`, the same - goes for such a directory anywhere beneath it: `glob` with `path: - "notes/diary"` and `pattern: "notes/*.md"`, written relative to the path - for `notes/diary/notes/x.md`, no longer finds it. Where nothing else - matches, the agent gets an answer with no matches (`glob`'s - `"matches": []`, `grep`'s `"results": []`), not an error. - - **Migration:** write the path as the tools show it: `work/work/z/*.ts` - finds `work/work/z/a.ts`, and `notes/diary/notes/*.md` finds - `notes/diary/notes/x.md`, with that `path` or without one. - - **Unchanged:** in every other mount, and for a pattern whose first - segment names no mount, the pattern is still matched relative to the - mount (or to `path`). diff --git a/changelog.d/glob-mount-prefixed-patterns.fixed.md b/changelog.d/glob-mount-prefixed-patterns.fixed.md deleted file mode 100644 index b6ec9587a..000000000 --- a/changelog.d/glob-mount-prefixed-patterns.fixed.md +++ /dev/null @@ -1,22 +0,0 @@ -- `workspace--glob` finds files from a mount-prefixed pattern, such as - `notes/diary/2026-10-13.md` or `project/src/**/*.ts`, as every other - workspace path is written and as its own results are returned. The pattern - was matched only against paths relative to the mount (or to `path`), so a - mount-prefixed one answered "no matches" for files that were there. When a - pattern's first segment names a mount, that mount now matches its rest from - the mount's root, in place of the pattern as written. Every other mount - still matches the pattern relative to the mount (or to `path`), so - `notes/*.md` still finds `project/notes/todo.md` beside the `notes` mount's - own files. In a mount holding a directory named like itself, this narrows - what a pattern written relative to the mount finds (see Breaking). - `workspace--grep`'s `glob` filter takes a mount-prefixed pattern the same - way. In both, a `**/` at the start of a path segment now matches zero or - more directories, as in other glob tools, so `project/src/**/*.ts` also finds `project/src/main.ts` and - `**/*.md` also finds the `.md` files at a mount's root; it used to need at - least one directory there. After a brace group whose alternatives each end a - directory, a `**/` matches zero or more directories, and so does a `**` - that ends an alternative before a `/`, so `{src/,lib/}**/*.ts` and - `{src/**,lib}/*.ts` find `src/main.ts`, as `src/**/*.ts` does. `glob`'s description and its `pattern` - parameter's description changed, and so did `grep`'s `glob` parameter's - description, so the first request after the upgrade that carries the - workspace's tools misses the prompt cache from its tool block on. diff --git a/changelog.d/heard-mid-turn-wake.fixed.md b/changelog.d/heard-mid-turn-wake.fixed.md new file mode 100644 index 000000000..000884988 --- /dev/null +++ b/changelog.d/heard-mid-turn-wake.fixed.md @@ -0,0 +1,28 @@ +- A message that arrives while an agent's turn is alive, and that the turn + hears at a tool boundary, no longer also starts a turn of its own after + that turn when nothing is left for it (#264). When the scheduler comes to + start its wake, the wake is dropped if a request that carried the message + has been answered and, for a wake that carries a channel, unless its + server marked the message ambient, the agent has since published into that + channel: in its plain speech, with a `>>` or `>>>` address, in a sleep's + announcement, with `channel_publish`, by resending a draft, or with a send + tool whose channel the framework records (`send_message`, `reply_message`, + `send_dm`, `send_file`, `send_files`). A script's publish counts only in + the turn that started the script, and as made having read what the agent + had read when it started. Such a wake whose channel the agent hasn't + spoken in since the message was read still starts its turn, and so does + one whose message reached the agent only in a request that failed. A + message its server marked ambient asked nothing of the agent, so its wake + is dropped once a request that carried it has been answered; one with + neither tag is judged as addressed. This covers channel messages, push + events, a module event's one message, background-script wakes and the + gate's self-wake notices. Console and API input, a module event that adds + several messages or whose event or message names a channel, a prose bounce + notice and a tune-out cancellation keep their wakes. A render's wake to + read it counts the render as read on the same terms: one that carries a + channel now also waits for the agent to publish there, unless every notice + the render folds was marked ambient, and then it's dropped once read, as + before. `EventGate`'s `addMessage` and `requestInference` hooks take an + optional `placement` and `after` (`WritePlacement`), which a host that + builds its own gate passes on to have the self-wake notice's wake dropped + too. diff --git a/changelog.d/inference-routing-no-policy.fixed.md b/changelog.d/inference-routing-no-policy.fixed.md new file mode 100644 index 000000000..6a607461b --- /dev/null +++ b/changelog.d/inference-routing-no-policy.fixed.md @@ -0,0 +1,24 @@ +- An MCPL server's `inference/request` on a host that sets no + `inferenceRouting` policy is refused before it reaches the provider, with + `-32603` and a message naming `inferenceRouting`. Before, it went to the + provider with the model `default`, a name the Anthropic API doesn't + recognize (its token-counting endpoint answers 404 `not_found_error` for + it), and a failed inference comes back to the server as `-32603` with the + provider's message. The framework doesn't choose a model for the server: a + model it picked would run, on whatever credentials and at whatever price + it carries, for a server the operator never chose it for. A host whose + provider served a model named `default`, through a membrane + `beforeRequest` hook, a proxy or another adapter, loses server inference + until it sets `inferenceRouting`. The capability grant is unchanged. +- A policy that names no model for a request's feature set refuses that + request the same way, naming the feature set, where it sent an empty + model on to the provider. A policy entry that is empty or not a string + names no model: an empty `wildcards` entry is passed over, as empty + `byFeature` and `overrides` entries already were, and an override or + feature set named like an `Object.prototype` member (such as + `constructor`) no longer reads one. +- Each refusal is traced as `mcpl:inference_fail`, with `model: null` and + no `mcpl:inference_start`, so a trace watcher still sees the failure it saw + when the request failed at the provider. The first refusal for each server + is logged, naming what to set; with a policy, so is the first for each + feature set it names no model for. diff --git a/changelog.d/prose-bounce-streak-latch.fixed.md b/changelog.d/prose-bounce-streak-latch.fixed.md deleted file mode 100644 index b5ea4cc89..000000000 --- a/changelog.d/prose-bounce-streak-latch.fixed.md +++ /dev/null @@ -1,10 +0,0 @@ -- **The prose bounce-wake cap no longer latches.** A bounce wakes the resident - so it can resend, at most twice in a row, so a bounce → wake → bounce loop - ends. The count reset only when a prose envelope was delivered, so a - resident that recovered from bounces another way (a send tool, setting the - draft aside) kept the count across turns: from its third bounce on, a reply - to a fresh message bounced with no wake, and sat unseen until something - else woke the resident. The count now also resets when a turn starts with a - cause of its own (a channel message, a server event, a gate flush, a timer, - an operator nudge), in explicit and hybrid modes. A loop of bounce wakes - with no other cause is still capped at two. diff --git a/changelog.d/publish-for-agent-shared-id.added.md b/changelog.d/publish-for-agent-shared-id.added.md new file mode 100644 index 000000000..10f5f9af6 --- /dev/null +++ b/changelog.d/publish-for-agent-shared-id.added.md @@ -0,0 +1,4 @@ +- `PublishRefusalReason` gains `shared-id`: `publishForAgent` refuses a name + that means more than one registered channel, before anything goes out. A + ref picks one, as a server-qualified name does where it names one channel + alone. diff --git a/changelog.d/request-compile-stats.added.md b/changelog.d/request-compile-stats.added.md new file mode 100644 index 000000000..dce9d2216 --- /dev/null +++ b/changelog.d/request-compile-stats.added.md @@ -0,0 +1,8 @@ +- `AgentFramework.getRequestCompileStats(request)` gives the render stats of the dry compile that + built a request `previewActivation` returned: context-manager's `CompileResult.renderStats`, what + that compile selected, by kind, without the injections merged in after it. So a host can show + the makeup of an agent's next request without sending one + (anima-research/connectome-host#425). It's null for a request that is sent, when the agent's + strategy records no stats, and with context-manager before 0.20.1, whose compiles carry none: + agent-framework's own range stays `^0.20.0`, so a host that reads the stats moves its + context-manager to 0.20.1. diff --git a/changelog.d/request-text-as-stored.fixed.md b/changelog.d/request-text-as-stored.fixed.md deleted file mode 100644 index 500fd6d26..000000000 --- a/changelog.d/request-text-as-stored.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- What reaches a request without passing through the context store now carries a lone UTF-16 surrogate as U+FFFD, as the store keeps it (#695). That's a context injection's text and namespace, a hook's or a module's, and what a tool boundary injects into the live round: the messages it heard, with their senders' names, and its own notices. Before, every request an injection was in carried the lone half raw, and the API refuses such a request with a 400 ("The request body is not valid JSON: unexpected end of hex escape"). A namespace is still compared with channel ids as given when an injection is kept to its channel's conversation. diff --git a/changelog.d/shared-channel-ids.breaking.md b/changelog.d/shared-channel-ids.breaking.md new file mode 100644 index 000000000..39325f508 --- /dev/null +++ b/changelog.d/shared-channel-ids.breaking.md @@ -0,0 +1,38 @@ +- **Code that calls `ChannelRegistry` or `ConversationRouter` directly:** a + channel the registry acts on is a `ChannelRef`, `{ serverId, channelId }` + (exported as a type), where it was a bare channel id, since two MCPL + servers can register the same id. + - `routeSpeech` and `deliverSpeech` take the locus as a `ChannelRef` (or + `null`), and `resolveLocus` and `getDefaultPublishChannel` return one. + `getDescriptor`, `isChannelOpen`, `inboundOnlyServer` and + `proseTargetFor` take one. `getChannelServerId` is removed: a ref names + its server. + - `buildChannelContext(agentName, serverId)` builds the channel context one + server is told, and `incomingFor(agentName)` gives the message the agent + is replying to, with its channel. + - `ChannelRegistryOptions.homeChannelResolver` and `activeChannelResolver` + return a `ChannelRef`. + - `sendOutgoingChunk` and `sendOutgoingComplete` take only the + destination the publish went to. A bare channel id, which they resolved + again at each call and dropped where two servers register it, is no + longer taken. + - `ConversationRouter`'s `bind`, `touch`, `unbind` and `getBinding` take a + `ChannelRef`, `channelForAgent` returns one, and a `ConversationBinding` + carries `serverId`. + - **Migration:** pass `{ serverId, channelId }`. An inbound event, a + publish outcome's `destination` and `channel_list` each carry the server. + `resolveProseTarget`'s answer, `openIfClosedForSend`'s, `channel_open`'s + result data, a gate wake's `WakeProvenance` (as `routeServerId`) and + `onRouteFailure`'s info now carry it too, and `InferenceRequest` carries + it as `channelServerId`, beside `channelId`. A stream's chunk and + `complete` take the publish outcome's `destination`. For a name given as a + string, `channelsNamed(name)` gives the channels it means, by its id or + its `serverId:channelId` form, and `nameShown(ref)` the name an agent is + shown for a channel. + - **Unchanged:** `startTyping`, `stopTyping` and `publishForAgent` still + accept a bare id, as connectome-host's activity and notices modules pass + one, and also take a ref or a `serverId:channelId` name. Where two + servers register the id, what a bare id does changes, as Fixed says: + typing shows in each server's channel, and `publishForAgent` refuses it. + `InferenceRequest.channelId` stays. No tool's description or schema + changes. diff --git a/changelog.d/shared-channel-ids.changed.md b/changelog.d/shared-channel-ids.changed.md new file mode 100644 index 000000000..1755a1862 --- /dev/null +++ b/changelog.d/shared-channel-ids.changed.md @@ -0,0 +1,41 @@ +- Each MCPL server is told only of its own channels in + `context/beforeInference`'s channel context (SPEC §14.4): `incoming` and + `defaultOutgoing` only when that channel is the server's, and `candidates` + only its own open channels. Every capable server was told the same + context, other servers' channels included, by their ids alone, which a + server registering the same id would read as its own. A server that read + other servers' channels from it no longer hears of them. +- A `[prose-routing]` bounce that lists the channels its target could mean + gives its resend examples with the first of them written as one token, + such as `>>a:general {{unsent}}` and `destination: "a:general"`, where it + gave `>>#channel` and `"#channel"` whatever it listed. +- The `mcpl:conversation-spawned`, `-spawn-failed`, `-unrouted`, + `-binding-orphaned`, `-closed` and `-disposed` traces carry the channel's + `serverId`, and a refusal's failure log names its locus as the agent is + shown it. +- `inference:started` carries the server of its `channelId` as `serverId`, + so a host that posts back to the turn's channel can name it where two + servers register the id. `inference:tokens` and `inference:content_block` + carry the id alone. +- `channel_list` gives a channel whose id names another channel too, another + server's channel with that id or another channel's server-qualified id, a + `name`: its server-qualified id, where that names it alone, which + `channelId` takes, and a `>>` target too where it is one token. Other rows + are as they were. +- The results of `channel_open`, `channel_close` and `channel_decline` carry + the channel's `serverId` in their `data`. +- An open that a tune-out overtook ends as `channel_open`'s refusal of a + tuned-out channel does, saying how the tune-out ends: by cancelling it, + which delivers the diverted backlog, or, for a channel no name picks + alone, by itself. +- A fork's channel refusals quote its channel and what the call asked for. +- A `serverId` given to `channel_publish`, whose schema has none, is read + beside a `channelId` as that exact pair, and with no `channelId` as the + default channel's id on that server, where it was ignored. +- A drafts resend whose destination names a channel that takes no posts is + refused before anything is sent, as a `>>` target naming one is; before, + the send refused its first draft. +- In explicit mode, a start whose wake names no channel, such as a budget + restart or a re-inference, keeps typing in the channel the turn shows, + starting it again where a stop or a failed start ended it, and its token + traces carry that channel's id, where they carried none. diff --git a/changelog.d/shared-channel-ids.fixed.md b/changelog.d/shared-channel-ids.fixed.md new file mode 100644 index 000000000..96d0e9ae4 --- /dev/null +++ b/changelog.d/shared-channel-ids.fixed.md @@ -0,0 +1,99 @@ +- Two MCPL servers can register the same channel id (MCPL requires one to be + unique only within a server's connection), and the framework now tells + their channels apart wherever it acts on one, instead of taking whichever + server registered the id first, or refusing the id outright. + - A turn's plain speech, its typing indicator and its outgoing stream go to + the channel on the server the turn's message came from. Speech to a + shared id was refused as ambiguous, with a failure marker, and typing + showed on the server that registered the id first. The `[routing]` and + `[channels]` notices, the turn's receipt, a bounce, the drafts' + destinations and the channel tools' results and refusals name such a + channel by its server-qualified id, `serverId:channelId`. + - `channel_publish` and the subconscious's `speak_in_channel` refuse a + shared id, naming each candidate by its server-qualified id, and take + that form in `channelId` to name one server's channel. Both posted on the + server that registered the id first. `tune_out` does the same, where it + tuned out the channel on the server that registered the id first, and its + result names the channel as the agent can name it back. + `channel_open`, `channel_close` and `channel_decline` take the form too, + alone, and a `serverId` beside a `channelId` names that server's channel + with that exact id. The refusal names each candidate so it can be told + apart, without "not found". Ids hold colons, so two channels' + server-qualified ids can be one string, as `a`'s `b:room` and `a:b`'s + `room` are: such a name is refused as a shared id is, and a channel no + string picks alone is named by its id and server, `"b:room" on server + "a"`. No tool's description or schema changes. + - The subconscious's `cancel_tuneout` reads a name as `tune_out` does: it + refuses a name two registered channels answer to, naming both, and takes + the server-qualified form, where it cancelled the one on the server that + registered the id first. Its `deliver_summary` refuses such a name too, + and tags the summary with the channel's server, where it stored the id + alone. Tune-out's notices, and the reasons its wakes + give, name such a channel by its server-qualified id, and `tune_out` + refuses a channel because a conversation is bound to it only when that + conversation is bound to that server's channel. + - A `>>` or `>>>` target takes the server-qualified id, and a shared id or + label is refused with its candidates named so each can be told apart. + `>>b:general` matched nothing, and a shared label's candidates read as + the same id twice. `prose_help`'s reference shows the form. A `<@id>` + mention or an `@name` that one person's DMs on two servers answer to is + refused with both named apart, where a mention was refused as matching + no DM. A closed-channel invitation for a channel whose id another server + shares offers a `>>` reply naming its server. + - A host's bare id to `startTyping` or `stopTyping` types in each server's + channel with that id, so connectome-host's activity indicator shows in + both, rather than on the first registered server. Where two servers reach + one platform channel, two bots can show typing there. + - `publishForAgent` refuses a shared bare id, where it posted on the first + registered server. A host that passes the server as a ref is answered + there, as one that passes `serverId:channelId` is where that names one + channel alone. + - A push's channel is registered for the server that pushed it, whatever + another server registers, and its reply goes back through that server. + A push leaves the entry of another server's channel with that id as it + was. + - Within a turn, a `>>` or `>>>` target, a drafts resend's destination, + and the `channelId` of `channel_publish`, `channel_open`, + `channel_close`, `channel_decline`, `tune_out` and the subconscious's + `cancel_tuneout`, `speak_in_channel` and `deliver_summary`, that name + the id of a channel the turn has been in (the channel of the message it + answers, and any a mid-turn message or `channel_open` moved it to) mean + that channel on its server, and, where two of the turn's channels on + two servers have that id, neither, with each named. Where another + server registers the same id, such a reply + goes back where the message came from, where it was refused as a shared + id; once that channel's server no longer registers it, the call is + refused, where it went out through, or acted on, another server's + channel with the id. A tool call takes the turn's channels only + when it is made inside the turn, and a script's call only when the turn + that started the script is the one waiting on it, so a script left + running after its turn takes none, and a later turn's channels aren't + lent to it. + - `channel_publish` with no `channelId` posts to its default channel, the + latest message's, on the server that message came from, and is refused + once that server no longer registers the channel, where it went out + through another server that registers the id. The refusal names no other + channel to post to, since the one with that id that remains is another + server's. + - A wake that names its channel without the server, such as a host's + inference request, by an id two servers share or that no registered + channel has, gives the turn no channel and no fallback. Its speech isn't + published, as before, and the agent is no longer told its speech lands in + the first registered server's channel. + - The deprecated conversation routing gives each server's channel its own + fork, where a second server's channel with the same id reached the first + one's fork. A fork's `channel_publish` and `channel_close` of its home's + id act on its home, on its server, where they acted on the first + registered server's channel, and a home its server no longer registers is + refused as such. An addressed wake's route applies to the fork homed on + that server's channel only. + - Coalesced work for a channel is admitted only while its own server + registers the channel, not another server's channel with the same id. + - A batched gate wake's summary lists two servers' channels with one id + apart, each with its count, where it merged them into one line under + the first one's label. + - Two servers' wakes on one channel id at one moment are two wakes to a + provider hold, where one was dropped as the other's duplicate. + - The registry keeps each channel under its server and id together, so + server `a`'s channel `b:room` and server `a:b`'s channel `room`, which a + key joined with a colon gave one entry, are two. diff --git a/changelog.d/strict-gate-validation.fixed.md b/changelog.d/strict-gate-validation.fixed.md new file mode 100644 index 000000000..12a6cad4a --- /dev/null +++ b/changelog.d/strict-gate-validation.fixed.md @@ -0,0 +1,28 @@ +- Every problem a wake rule has is named, where these used to change what a rule does without a word: + - a `behavior` that isn't an object, `"always"`, `"defer"` or `"skip"`, which was saved as `"always"`, waking on every event the rule matches. That covered a typo such as `"deffer"`, and in membrane's XML tool mode the object form `wake_add_rule`'s description showed, since there `behavior` arrives as text; + - a match field of the wrong type, such as `tagsAny: "chat:ambient"`, `channel: 12345`, `match: "discord"` or a `filter` whose `type` isn't `text` or `regex`, and an entry of the wrong type, or `""`, in a list, which were dropped, so the rule matched more than it named; + - a field name that's a slip for one the gate reads at that level: the same ignoring case; one edit away, or more for a long name, a quarter of what follows the part the two names share (`pasthrough`, `keyby`, `polices`); or, at three characters or more, a part of it (`tags`, `interval` for `refillIntervalMs`); or a name holding the whole name of a field its object doesn't give, which is that field under another name (`matchTags` with no `match`, `debounceMs` in a `behavior`), but for gate.json's own fields, where such a name can be a label of the file. Beside the field it holds, such a name does nothing, as before (`channelNote` beside `channel`). A slip was ignored, so the field meant was missing: `pasthrough: true` made a governor consume the events it should let fall through. Each field the name could be a slip for is named (`tags` for `tagsAny`, `tagsAll` or `tagsNone`); + - a field the gate reads at another level of the rule, such as a match's `tagsAny` beside `name`, or a filter's `pattern` straight in `match`, which was ignored, so the rule matched more than it named. It's named with where the gate reads it: `Policy "quiet": it has "tagsAny", a field of "match", which the gate doesn't read here, so the rule matches every event.`; + - a `rate_limit` or `passive_sample` `keyBy`, or a `resets`, of the wrong type, which was dropped; + - a behavior object holding two kinds, of which `debounce`, then `rate_limit`, then `passive_sample` was used, whatever their order. +- `wake_add_rule` and `addGatePolicy` refuse a rule with any problem, naming every one, those of each kind a behavior object holds included, and save nothing, since their caller is there to fix it. `wake_add_rule`'s own parameters are checked the same way: a slip of one is refused, such as `matches` with no `match`, which saved a rule matching every event, and so is a field of `match`, `rateLimit` or `passiveSample` given as a parameter of its own, such as `tagsAny`, which used to be saved without it, naming the parameter it belongs in, and showing it there where it belongs in only one and its value can be shown whole. Any refusal of `wake_add_rule`'s also names each other parameter it doesn't take, which a saved rule's `ignoredFields` names. +- gate.json and the host's starting rules (`FrameworkConfig.gate.config`) are what a deployment already runs, so there a rule with a problem is read the way the gate read it before, and each problem is named in `gate_status`'s `errors` and a `[gate]` log line; the rest of the file loads. A rule that skips or defers is read past its problem the same way, holding back what it held back before, and its note says so: `Read so, it skips every event it now matches, as earlier releases did.` A rule is left out only where the gate refused it before: a rule that isn't an object or has no `name`; a behavior that's a list or holds none of the three kinds; a `rate_limit` or `passive_sample` that isn't an object or lacks `tokens`, `refillIntervalMs` or `every`, or has `tokens` at or under 0; a `debounce`, `tokens`, `refillIntervalMs` or `every` that isn't a finite number; an invalid regular expression in a `filter`; and a `passthrough` that isn't a boolean, or is `true` on a behavior that doesn't count. Under a default that holds events back, `"skip"` or `"defer"`, a gate.json or starting config that leaves out a rule that could wake the agent isn't used, as before, since that would hold back what the rule was written to wake on: a start runs the host's starting rules, or none, which hears every event, and a reload keeps the rules in force. A later start doesn't add starting rules that would leave gate.json unused that way, and says so. So, wherever the gate loaded the file before, no event that woke the agent before goes quiet. An empty entry in `scope`, which only narrows a rule, since no event's type is `""`, is read as written whatever the rule does. +- A number outside the gate's range, which the gate refused whole in gate.json before and ran as given from the starting rules, is read in both with a note, and `wake_add_rule` and `addGatePolicy` refuse each. Read so, no event the starting rules woke the agent on is held back, though a batch can go out later or sooner: + - a `debounce` under 100 ms is read as 100 ms, so events further apart than the debounce given and closer than 100 ms, each its own batch before, share one: the agent wakes fewer times, and the batch goes out 100 ms after the last of its burst, or later where it's held, as any batch is, while the agent's turn runs; + - a `debounce` over 300000 ms is read as 300000 ms, so its batch goes out sooner, but for one over 2147483647 ms, the most a timer takes, which Node and Bun 1.4.3 ran after 1 ms: its batch now goes out after five minutes; + - a `rate_limit` `refillIntervalMs` of 0 refills at once, so the rule limits nothing, where it refilled the bucket once, through a division by 0, and then never; one under 0 is read as never refilling, as it ran, so the rule wakes the agent on its first `tokens` events and none after; + - a `passive_sample` `every` at or under 0 is read as 1, and a fractional one as its next whole number, as they ran. +- gate.json's own fields are read as the gate read them before, with a note, wherever it loaded the file. One that's a list is read as no rules under the default `"always"`. A slip of `policies`, such as `"polices"`, a name the gate doesn't read, a list of rules parked under one, such as `"disabled"` or `"rules"`, included, a field of a rule at the file's top, such as `"channel"`, and a `policies` that isn't a list each do nothing: a note names each, a slip with what the file's default then does (`gate.json has "polices" (did you mean "policies"?), which the gate doesn't read, and it has no rules, so under its default, "skip", every event is held back.`), and where `policies` writes no rules the file has none, so under `"skip"` or `"defer"` it holds back every event, as it did. A `policies` holding nothing, such as `"policies": {}`, writes no rules, as `[]` does. A change keeps each such name as written and adds a `policies` list beside it, or writes the list in place of a `policies` holding nothing. +- A change through `wake_add_rule`, `wake_remove_rule`, `addGatePolicy` or `removeGatePolicy` edits gate.json's own list of rules by name: every rule it doesn't change is written back as it was written, with its problems and the fields the gate doesn't read, and the rules in force are read from what was written, so what gate.json still holds that the gate reads past or leaves out stays named until someone fixes it. A `"comment"` survives a write now, where a write used to write the rules as the gate held them. A rule a change leaves out of force, or gives another behavior, loses its counters, buckets and timers, as at a reload, so a rule that comes back, or that a change gives another behavior, starts afresh. Its pending debounce batch is then sent as its timer would have sent it, held while an agent is inferring or the host is quiesced, and so it is at a reload, which delivered it at once. A batch whose delivery throws is named in a `[gate]` log line and dropped, and the change or reload goes on as made; so is one its timer sends, or one held for an agent's turn, which before threw uncaught, stopping a host with no handler for that. Rules that share a name share its state, and a change or reload keeps it only where those rules keep their behaviors, in order: a reload compared each with the last of them, so with two rules of a name, a change can reset their state, or keep it where it changed. A rule the gate leaves out can be replaced, or removed, by its name. A gate.json that's JSON the gate doesn't use is edited the same way, and only what loads is written: a change that doesn't mend what keeps it out is refused, naming that and what would bring it into force, and the file stays as its operator wrote it, where a change used to write the rules in force over it, losing its other rules, its own fields and its default; a change that replaces or removes by name the rule keeping it out brings it into force. When gate.json is missing, a change edits the list of the text whose rules are in force the same way, so a rule it leaves out keeps its name there too. A gate.json with no list of rules a change could edit without writing over what it holds (a list, a `policies` that isn't one and holds something, or JSON that isn't an object) refuses a change, an addition or a removal, and saves nothing; a removal there used to answer that no rule had the name, and an addition used to write the rules in force over the file. That refusal, like one over a gate.json that can't be read or isn't JSON, and one at a start that didn't use gate.json, says what the gate runs meanwhile, and what has to change first. A write of gate.json that fails says whether gate.json was changed: one that fails at its open leaves it as it was, and one that fails after may leave it holding the whole change, part of it or nothing. A change over a gate.json the gate doesn't use that would leave it with no rules in force is refused, naming what runs meanwhile, since such a file holds events back by default, so every event would be held back. A removal by a name that isn't a non-empty string, from JavaScript, finds no rule, so it can't remove the rules gate.json holds with no name. +- `gate_status`'s `errors` says what the start did with the host's starting rules, apart from gate.json's load error, until the next start: how it read them, rules it couldn't use, and rules it couldn't add to gate.json. When adding the starting rules to a gate.json that loaded failed, the load that followed cleared the error, so the missing rules went unmentioned. When the start's own write of gate.json fails and leaves the rules in force unsaved, another entry says so until gate.json is written or loads; if that write leaves gate.json unable to load, the gate runs the rules it read and the ones it was adding, where it used to drop them, and that entry names what they hold that the gate reads past or leaves out. The entry for a gate.json that isn't used, or can't be read or isn't JSON, now says what's in force instead: the rules of the last gate.json the gate used, the rules a start read from gate.json and was adding when its write left gate.json unable to load, the host's starting rules, or none. `gate_status`'s `configSource` is `"initial"` while the starting rules are in force, where it said `"default"` while they ran in place of a gate.json that didn't load. +- A `null` is a field left out, and so is `""`, which is named in a note, with what leaving it out does where that's more than no constraint: a `match` left out matches every event, and a `behavior` is `"always"`. In a rule the gate reads, a match left with nothing that narrows it, once something in it was dropped, left out as `""` or unread, matches every event, and a note says so; in a refusal of such a rule, where nothing was saved, the note gives that as the reason its problems matter: read without what's wrong in its match, as earlier releases read it, the rule would match every event. A gate.json holding `"channel": ""`, as `wake_add_rule` could save before, loads as it did, with the field left out. Where the gate refused a rule or a gate.json for a `null` or `""`, in a `passthrough`, a behavior's kind or gate.json's `default`, it now loads without it. A `filter` whose `pattern` is `""` narrows nothing and is left out, with a note. For `wake_add_rule`, a `null` or `""` parameter is left out too, so a call whose only behavior is `null` is refused as one with none, where it was saved as `"always"`. A field name the gate reads nowhere that's no slip, such as `"comment"`, changes nothing, so it loads, and `gate_status`'s `errors` names it with the fields its place holds. `wake_add_rule` leaves it out of the rule it saves and names it in the result's `ignoredFields`, and `addGatePolicy` tells a new `onIgnoredFields` callback, or the log. +- `wake_add_rule` refuses a `position` other than `"append"` or `"prepend"`, and an `insertBefore` or `insertAfter` that isn't a string, which were ignored, so the rule was appended. A refused `behavior` points at `debounceMs`, `rateLimit` and `passiveSample`. Its description's example now gives `debounceMs: 60000` in place of `behavior: { debounce: 60000 }`, which its schema, a string enum, didn't admit. +- **What a deployment will notice:** + - A gate.json holding a rule the gate refused before now loads, with that rule left out and named, under `"always"`, or where the rule skips, defers or has no tokens. A reload or a start applies its other rules, where a reload kept the rules the gate had and a start ran the host's starting rules, or none. So under `"always"`, a rule after the one left out that skips, defers, rate-limits or samples can now hold back an event those rules woke on, and one that debounces can delay it: that's the case where an event that woke the agent before can go quiet. + - A rule that skips or defers, with a problem the gate read past before, is read as before, holding back every event it then matched, and now says so. + - A starting rule with a `debounce` under 100 ms or over 300000 ms, or a `refillIntervalMs` of 0, runs as read above, where it ran as given: a burst under a short debounce wakes the agent fewer times, a debounce over 2147483647 ms waits five minutes where it ran after 1 ms, and a refill interval of 0 no longer stops the rule after its one refill. + - A batch a timer or an agent's turn sends, whose delivery throws, as `addMessage` does with no agent configured, is named in the log and dropped, where it was thrown uncaught. + - `wake_add_rule` and `addGatePolicy` refuse a rule holding a field at another level than its own, and `wake_add_rule` a field of `match`, `rateLimit` or `passiveSample` given on its own; such a rule used to be saved without it. A gate.json written with such a field loads as before, with the field named. + - A starting rule the gate refused before is named at start. One used to make the gate run all of the starting rules as given, from memory, so these ran and woke the agent: a `rate_limit` without `refillIntervalMs`, a `debounce` given as text such as `"60000"`, a `passthrough` that isn't a boolean, which ran as its truthiness, `passthrough: true` on `"always"`, and a rule with no `name` that wakes, such as on `"always"`. Now, under a starting default of `"always"`, such a rule is left out and the rest run; under `"skip"` or `"defer"`, none of the starting rules is used, and the start runs gate.json's rules, or none, which hears every event. connectome-host's recipes hold none of them. + - A first start writes the host's starting rules to gate.json as given, a rule left out included, as before, and runs each as read, but writes none the gate refuses whole, and then starts with no rules, as a host without them does; gate.json, read the same way, names each problem at every load until someone fixes it. A later start adds, as given, the starting rules whose names gate.json lacks; a rule gate.json holds keeps its name even where the gate leaves it out, so the host's rule of that name doesn't replace the operator's. Where gate.json has no list of rules to add them to, a list or a `policies` that isn't one and holds something, a later start writes nothing over it and runs them under gate.json's default, unsaved, until gate.json changes and loads, and `gate_status` says so, with `configSource` `"initial"`, where it used to write them over the file, losing what it held. Where gate.json has no rules of its own, with no list, an empty one or one holding only rules the gate leaves out, and, under its default, one of the starting rules that could wake the agent can't be put in force, left out, with no name, or kept out by a rule of gate.json's of its name that the gate leaves out, or they're refused whole and that default holds events back, gate.json isn't used at that start, which runs as one without it does: the starting rules under their own default, as earlier releases ran them once what they wrote over it or into it wouldn't load, or once they refused gate.json's rule, or no rules where the starting rules are refused whole. `gate_status` names the rule left out and the way back, and a change through the gate is refused until gate.json changes. + - A rule a slip already saved in a valid form, such as `"always"` saved from a typo or `match: {}` from a dropped field, loads as it was saved, since the slip is no longer in it. diff --git a/changelog.d/thinking-binding-drop-block.added.md b/changelog.d/thinking-binding-drop-block.added.md new file mode 100644 index 000000000..3d73bdd98 --- /dev/null +++ b/changelog.d/thinking-binding-drop-block.added.md @@ -0,0 +1,30 @@ +- `thinkingBinding.dropBlock` in an agent's config asks the provider to drop + a signed thinking block whose earlier conversation has changed, as a fold + changes it, rather than refuse the request. An account that enforces + thinking-signature binding answers such a request with a 400; with the + setting, it drops the blocks that no longer match and answers. Where an + account doesn't enforce it, the provider decides for itself what to do with + such a block, and the setting has it drop them too. The setting is off by + default. + - **What's sent:** `thinking.block_binding.prefix_mismatch_behavior: + "drop_block"` with the `thinking-binding-controls-2026-08-01` beta, which + membrane's Anthropic and Bedrock adapters carry. It has no effect through + other adapters, or where the host sends a thinking config of its own, + through `request.extra` or `providerParams`, which replaces membrane's. + Any other model carries it only while thinking is on. A model whose + thinking is always on (Fable, Mythos) gets an adaptive thinking config + to carry it, with the agent's `thinking.display` when that's set, so on + such a model `display` takes effect only with this setting. membrane + notes that the Mythos family has refused thinking configs with a 400 + before, so on a Mythos model the setting may fail every request. + - **What's reported:** on a framework turn, each call's dropped blocks, and + any shown to the model though they failed the check, appear in its + `inference:usage` trace as `thinking`. A call that dropped blocks, and + every later call of its stream, isn't used to calibrate the context + strategy's token estimates, since the provider doesn't bill a block it + dropped and the strategy still counts it. Where the strategy still sends + the blocks a fold left bound to a conversation the request no longer + holds, the provider drops them on every call until a later fold takes + them out of the context, and while they're sent, no stream calibrates and + the estimates keep the value they had. The direct API carries the setting + too, but reports nothing of it. diff --git a/changelog.d/tool-result-lone-surrogate.fixed.md b/changelog.d/tool-result-lone-surrogate.fixed.md deleted file mode 100644 index b239360eb..000000000 --- a/changelog.d/tool-result-lone-surrogate.fixed.md +++ /dev/null @@ -1 +0,0 @@ -- A lone UTF-16 surrogate in a tool result's text now reaches membrane as U+FFFD, as the context stores it (#695). Before, a text block's text, a reference's stub (from its `name`), an error result's text and the error text of a failure the tool doesn't flag went to membrane with the lone half raw, while the stored copy held U+FFFD. With the tool-result guard on (`toolResultGuard`), such a result left a stored message the context couldn't read back; with a text block or an error result, the agent's next turn sent no request and `stop()` threw. This change doesn't repair a store that already holds such a message. diff --git a/changelog.d/unreadable-gate-files.fixed.md b/changelog.d/unreadable-gate-files.fixed.md new file mode 100644 index 000000000..06bc5d2c8 --- /dev/null +++ b/changelog.d/unreadable-gate-files.fixed.md @@ -0,0 +1,5 @@ +- A wake-rule change no longer destroys a gate.json that won't load. `wake_add_rule` and `wake_remove_rule` (and `addGatePolicy` and `removeGatePolicy`) started from the rules in force when gate.json was there but wasn't JSON or held a policy the gate refused, and they wrote those rules straight over the file, so whatever it held, such as an operator's unfinished edit, was lost. A gate.json that holds a policy the gate refuses is now edited in place instead, with that policy kept as written. A change over one that isn't JSON, or that the gate can't read at all, such as one it lacks permission for, is refused, naming why and what the gate runs meanwhile, and nothing is written over it, so what it holds stays as it is, until it's JSON the gate can read. +- The gate's files are reloaded when their text changes. At most once a second, gate.json is read again and applied when its text differs from the text whose rules are in force, and the privileged-users file is read again and loaded when its text differs from the text last read. A gate.json that holds the text in force again after failing to load, as after a `chmod` that makes it readable, has its error cleared, and nothing is applied again. gate.json's reload compared only its mtime, so two writes in one clock tick, a write between its read and its stat, or a read that only looked (`getGatePolicyNames`, or a change that wrote nothing) left a new gate.json unapplied until it next changed, and a `chmod` that made it readable again wasn't noticed. `isPrivilegedUser`, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while it couldn't be read each call added another copy of its error to `gate_status`'s `errors`. A clock stepped backwards now makes the next check run, rather than holding off both reloads until the clock catches up. A missing file is told apart from one that's there but can't be reached, such as behind a directory the process can't search: only a missing gate.json keeps the rules in force without an error, and only a missing privileged-users file means no one is privileged. +- A privileged-users file loads entry by entry. An entry of its list that's a non-empty string, or a safe integer (a whole number within 2^53 - 1 of zero), is a user id. Any other entry is skipped and named in the file's error, including a number too large for JSON to read exactly, whose rounded value could be another user's id. A file that isn't JSON, or isn't a list or `{"userIds": [...]}`, is that file's error, and the list that last loaded stays in effect, as for a file that can't be read (at startup, that's no one). Such a file used to load as no one privileged, without a word, and an entry that wasn't an id was loaded as text. An empty author id is never privileged, while the agent sleeps as for tune-out. +- `gate_status` reads both files again, at most once a second, before it answers, and its `errors` holds each problem once: gate.json's, what the start did with the host's starting rules, and the privileged-users file's. A gate.json load no longer clears the start's notice, which stays until the next start, or the privileged-users file's error, and a privileged-users file that loads again, or is removed, clears its own. The starting rules the start couldn't save stay named until gate.json holds what's in force, as it does once it loads or a change is written. When gate.json's error changes, a `[gate]` log line says it couldn't be read and why, and the `gate:config-error` trace is emitted, not at every read that fails the same way. A gate.json that stopped loading used to reach only the trace. +- `wake_remove_rule` and `removeGatePolicy` clear the removed rule's state (delivering its pending debounce batch, and dropping its rate-limit and sampling counters) only once gate.json is written. A write that failed used to leave the rule in force with its state already cleared. diff --git a/changelog.d/verbatim-string-tool-results-scripts.breaking.md b/changelog.d/verbatim-string-tool-results-scripts.breaking.md deleted file mode 100644 index 1e55acab7..000000000 --- a/changelog.d/verbatim-string-tool-results-scripts.breaking.md +++ /dev/null @@ -1,12 +0,0 @@ -- **Script authors (`code_execution`):** a tool's text result now reaches a - script as that text, in its result's `content[0].text`, where it was the - text JSON-encoded. That covers every MCP tool whose result is text only, - and every module tool whose `data` is a string, without structured - content. A script saved before the upgrade meets the change too. Drop a - `JSON.parse` that only unquoted such a result. Where the text is itself - JSON, one `JSON.parse` now returns the data, so drop the second. A script - that read every result with `JSON.parse` can parse inside a `try` and - keep the text when it throws; a string whose text is itself JSON, such as - the text `123` or `true`, then reads as the value it spells. A result - whose data isn't a string, and a result with structured content, reach a - script as before. diff --git a/changelog.d/verbatim-string-tool-results.changed.md b/changelog.d/verbatim-string-tool-results.changed.md deleted file mode 100644 index 65354337f..000000000 --- a/changelog.d/verbatim-string-tool-results.changed.md +++ /dev/null @@ -1,17 +0,0 @@ -- A tool result whose `data` is a string now reaches the model as that text. - It was JSON-encoded on the way, quoted with every newline, tab and quote - escaped, so a multi-line answer, such as a module tool's line-per-item - listing, arrived as one escaped line: in context, on the live wire, and in - its spill file under `tool-results/`, which `workspace--read` could then - page only by character and `workspace--grep` matched as a single line. A - spill file now holds the string as the tool returned it. Data that isn't a - string reaches the model as before. - - Results stored before the upgrade keep the quoted text they were stored - with, so a conversation shows both forms until its older results leave - its context. A string is measured as its own text, so one whose JSON form - went over the inline cap while its text doesn't, such as a long listing - of short lines, now stays inline where it used to spill to a file. A lone - UTF-16 surrogate in the string, which JSON escaped, reads as U+FFFD. For - a result without an error, `data: ''` reads as an empty result, as - `data: undefined` already did. diff --git a/changelog.d/writable-direct-mounts.added.md b/changelog.d/writable-direct-mounts.added.md new file mode 100644 index 000000000..fcc1f7883 --- /dev/null +++ b/changelog.d/writable-direct-mounts.added.md @@ -0,0 +1,9 @@ +- **A direct workspace mount can be written:** the workspace no longer refuses a mount with `backend: 'direct'` whose `mode` isn't `'read-only'`, and one that names no `mode` is writable, as any mount with none is. Its `write`, `edit`, `edit_many` and `delete` land on disk when they answer, or don't happen, and none of its files go into the store. The settings a direct mount refuses are otherwise as before. + - **A write is whole:** the new bytes are staged beside the file, in `.workspace-staged-`, at the file's mode, special bits included as far as the kernel lets the process set them, and renamed over it, so a reader beside the agent never sees half a file, an edited `0755` script stays `0755`, and a process killed mid-write leaves the old file whole. A process that dies between staging and its rename leaves the staged file, which `ls` and `git status` show by that name and nothing sweeps; so does a refused write whose removal of its staged file fails, or whose directory another writer moved meanwhile. A new file takes `0666` less the process's umask, as a shell's would. The file's owner and group become the process's and the ones its directory gives a new file, and an ACL or extended attribute on it isn't carried over, as with any write that renames over a file. A file the process may not write is refused, as a store-backed push's write is, and so is a file in a directory it may not write, where the staged file is made. A file with other hard links gets a file of its own at its path, the other links keep the old text, and the answer's `note` says so. Nothing is synced, so a power loss or a kernel crash can lose a write its answer said had landed. + - **An edit is planned against disk's text as it is now,** by the store-backed kind's planner, and its answer is a store-backed edit's, changed region included. Just before the rename, the file is checked unchanged since the edit read it, by its bytes, read again, and by its device, inode, size, mode, owner, group and change times. A file another writer changed meanwhile is read and planned again, three attempts in all, so the edit lands on top of that change, and it's refused where its match is gone or now occurs a different number of times, where a file changed at every attempt, or where the next read refuses it as any read would, with nothing written. The answer's `note` says when a file was read again. The bytes are compared because a kernel that stamps files from a coarse clock, as Linux before 6.13 does, can leave a same-size change made within one tick unstamped. + - **`edit_many`** checks every edit in every file, stages every file and checks each unchanged before it renames any, so a full disk while staging, or another writer's change, is met before any file is replaced. If a rename then fails partway, the files before it are written and the rest are as they were, the answer names each file's outcome, and nothing puts the written ones back. A refusal says the call wrote none of its edits. Two paths that reach one file, through a link or as hard links, are refused, so the file is named one way. + - **`delete`** unlinks the file through the mount's boundary, as a store-backed push's deletion does, a followed link's entry included. One that fails after reaching the file says the file may be gone. + - **Links:** on a mount that doesn't follow links, a write, edit or deletion at a link is refused. On one that follows them, a write or edit goes to the file the link leads to, where that stays inside the mount, and the link stays. + - **After an undo or another move of the store's head,** the files stay as the departed turns left them. At the agent's next start, opening, continuing or resuming, the framework stores one notice in its history, before that start's compile, for each mount the agent wrote direct whose latest such write its history no longer holds, whether the mount runs direct now or as `'chronicle'`: "Writes to `` from turns the history no longer holds are still on disk. If it's a git checkout, `git status` and `git diff` show them; a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds." Each agent is told only of its own writes. Whether the notice was given is judged by its own presence in the history: an undo that takes it away gives it again, a redo back to the latest write needs none, and going back to a branch told earlier, after another branch's notice, gives it again. The latest write's position and the notice are saved at a clean stop, in a global record the store keeps whatever branch it's on, with the mount's configured root, so a restart on another branch, such as an offline rollback's, still gives the notice, a mount left out of one session's config keeps its positions for the next, and a mount whose path changed gets none at its new path, while its old path's are kept for when it's back; a crash loses the positions of writes made since the last one, and a later move away from those writes isn't told; it can also repeat a notice given since then. A record the store can't read leaves a session with no positions to restore, and the record as it was; one that reads but isn't of the shape saved is replaced at that session's stop. A head that moves away under a running turn and comes back before the agent's next start loses the notice for the writes made while it was away. + - **Tool descriptions:** `write`, `edit`, `edit_many` and `delete` gain a sentence about the direct kind only on a host with a writable direct mount. `ls` without a path, and `status`, give such a mount's `mode` as `'read-write'` where its config names none. +- **The framework's own files pass over a mount configured direct:** tool-result spill files, saved MCP payloads and fetched references go to the first writable mount without an include that isn't direct, so none lands in a checkout, where `git status` would show it and an undo would leave it. A host whose only writable mount is direct gets no spill file and no stored reference, and the truncation note, and the stub for an MCP payload it can't show, say no mount that keeps its files in the store can hold it. `getMounts()` gives `backend: 'direct'` for a mount configured direct, whether it runs direct this session or, while the store holds work there that disk lacks, as `'chronicle'`. While a mount configured direct runs as `'chronicle'`, it takes no `write`, `edit`, `edit_many`, `delete` or `writeBinary`, which would stay in the store: each is refused, saying why and that a `sync` of a held path or a `materialize` naming the mount settles what's held, after which a start runs it direct. Its start alert says the same, and no longer says to configure it `'chronicle'` to materialize it, and `getMounts()` gives it as `'read-only'` for that session. diff --git a/docs/auto-continue.md b/docs/auto-continue.md new file mode 100644 index 000000000..44c65c459 --- /dev/null +++ b/docs/auto-continue.md @@ -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. diff --git a/docs/dendrite.md b/docs/dendrite.md index ccc339018..6610272e4 100644 --- a/docs/dendrite.md +++ b/docs/dendrite.md @@ -267,7 +267,7 @@ What it closes from issue #232 §2: gaps 1 (a DM can be tuned out: the push lane `FrameworkConfig.speech.locus` chooses where a turn's plain prose goes when nothing names a destination. -- `'delivery'` (the default) is the §3 carve-out the residents accepted in the architecture channel on 2026-10-06: the locus comes only from the delivery that woke the turn, a conversation fork's home channel, or a channel the agent opened during the turn. Incoming traffic never moves it — not between turns, and not mid-turn either. An addressed message injected during a turn from another channel is announced in the same injection batch — `[routing] addressed you from while you were busy. Your plain speech still lands in this turn; reaching them takes an explicit send.` — once per channel per turn, because a neutral message carries nothing that names its channel (first live run, 2026-10-12, finding C). A turn with no such locus keeps its prose private and is told so in a `[routing]` notice. The same holds for a message that lands between a turn's streams — during a provider-capacity hold, before a re-inference on tool results, or drained at an over-budget boundary (an *arrival*, in the turn-routing sense of 0.22.1): it moves nothing, and if it addressed the agent from another channel the next start of the turn announces it with the same notice, naming which start it is — `while your turn waited on a provider hold`, `before this re-inference on your tool results`, `at your budget restart`, `while your turn was retried` — since from inside they look alike and the reason says whether the earlier prose landed (Linn, 2026-10-14). `channel_publish` without a `channelId` goes where the turn's plain speech would — the frozen turn locus, so the waking delivery's channel or one opened this turn — and is refused only when there is none. +- `'delivery'` (the default) is the §3 carve-out the residents accepted in the architecture channel on 2026-10-06: the locus comes only from the delivery that woke the turn, a conversation fork's home channel, or a channel the agent opened during the turn. Incoming traffic never moves it — not between turns, and not mid-turn either. An addressed message injected during a turn from another channel is announced in the same injection batch — `[routing] addressed you from while you were busy. Your plain speech still lands in this turn; reaching them takes an explicit send.` — once per channel per turn, because a neutral message carries nothing that names its channel (first live run, 2026-10-12, finding C). A turn with no such locus keeps its prose private and is told so in a `[routing]` notice. The same holds for a message that lands between a turn's streams — during a provider-capacity hold, before a re-inference on tool results, or drained at an over-budget boundary (an *arrival*, in the turn-routing sense of 0.22.1): it moves nothing, and if it addressed the agent from another channel the next start of the turn announces it with the same notice, naming which start it is — `while your turn waited on a provider hold`, `before this re-inference on your tool results`, `at your budget restart`, `while your turn was retried` — since from inside they look alike and the reason says whether the earlier prose landed (Linn, 2026-10-14). `channel_publish` without a `channelId`, made inside the turn, goes where the turn's plain speech would — the frozen turn locus, so the waking delivery's channel or one opened this turn — and is refused when its server no longer registers it. A `serverId` given with no `channelId` names the locus's id on that server, as the registry reads one given beside its own default. A turn with no locus takes its trigger channel where it has one, as a turn under explicit or disabled prose routing does, since it never resolves a locus, and otherwise the registry's default, as a call outside the turn does. One made outside the turn, as a script left running after it makes, takes none of the turn's channels: a conversation fork's goes to its home channel, and anyone else's takes the registry's default, which under this rule is the all-except hold's excepted channel while one is open, and none otherwise. - `'latest-inbound'` restores the previous rule: the most recent delivered inbound message is a global fallback locus, an addressed mid-turn injection re-pins the turn, and so does an arrival at the turn's next start (the 0.22.1 rule, kept whole under this flag). Under both rules a held message is never "the latest inbound". @@ -290,7 +290,7 @@ Decisions carried from issue #232 and the discussion: a per-channel epoch takes ### Not here -The per-recipient exposure model of nissa's household's proposal (an inbox outside recipient slots, `attention`/`inline` per channel per recipient, overlays), a `ChannelRef` value type, and the mention autoreply (#232 §3.4) are not built. Admission here decides per *slot*: residents share one message slot, so a hold applies to all of them, and a reader of the slot sees held traffic through its own view. The structure is the one those designs need — one decision, consumed downstream — so they are additions to the policy inputs, not another path. +The per-recipient exposure model of nissa's household's proposal (an inbox outside recipient slots, `attention`/`inline` per channel per recipient, overlays) and the mention autoreply (#232 §3.4) are not built. Admission here decides per *slot*: residents share one message slot, so a hold applies to all of them, and a reader of the slot sees held traffic through its own view. The structure is the one those designs need — one decision, consumed downstream — so they are additions to the policy inputs, not another path. ## The rendering contract @@ -389,7 +389,7 @@ The framework detects these at run time. The registry, lifecycle, attributed mai ## What is not implemented -- **Per-recipient exposure.** Admission decides per shared slot, not per recipient (see [Admission](#admission)); the inbox, the `attention`/`inline` settings and overlays of nissa's household's proposal, and a `ChannelRef` value type, are not built. +- **Per-recipient exposure.** Admission decides per shared slot, not per recipient (see [Admission](#admission)); the inbox, the `attention`/`inline` settings and overlays of nissa's household's proposal are not built. - **The mention autoreply** (#232 §3.4). The tune-out coordinator still owns epochs and the wake budget; Dendrite supplies the admission decision, the reader and the result route. - **Live-follow inheritance.** Shared inheritance takes a snapshot. The persistent reader (`reader: 'persistent'`, the default) keeps its live view of the shared slot; the fork reader does not need one, since each fork is derived at the head and handed what arrived since the last look. - **Conversation forks on shared inheritance.** They still copy. Moving them needs a decision about their existing stores. diff --git a/docs/explicit-prose-routing.md b/docs/explicit-prose-routing.md index 8db5f6f99..b8e742880 100644 --- a/docs/explicit-prose-routing.md +++ b/docs/explicit-prose-routing.md @@ -17,10 +17,28 @@ A prose segment's FIRST line may carry a routing prefix: ``` >>#channel-name send to the channel with this label >>@person send to the DM with this person ->>discord:guild:id send by exact channel id (always unambiguous) +>>discord:guild:id send by exact channel id +>>srv:discord:guild:id the same channel id on the MCPL server `srv`, where more than one server registers it >>skip_reply do not send; text stays in context only (mirrors the skip_reply tool) ``` +A channel id is unique only within one MCPL server's connection, so two +servers can register the same one. A target that more than one channel +answers to is refused, with its candidates named so each can be told apart: +by its id, or, where its id is shared, in the server-qualified form, or, where +that is shared too (ids hold colons), by its id and server, which no target +takes. The server-qualified form is looked up among the registered pairs, +never split at a colon. Within a turn, an id that one of the turn's own +channels has, a channel the turn has been in (the channel of the message it +answers, and any a mid-turn message or channel_open moved it to), means that +channel, on its server, even where another server registers the id, and is +refused once that server no longer registers it: the words go only where the +message came from, never through another server that has the id. Where two of +the turn's channels have that id, on two servers, it means neither, and is +refused with each named. The channel tools, tune_out and the subconscious's +cancel_tuneout, speak_in_channel and deliver_summary read a name the turn gives +them the same way. + - Body may start on the same line (`>>#ops deploy done`) or the next. - **Sticky within the turn:** the latest resolved target applies to later unprefixed segments of the SAME turn, including those a later stream of it diff --git a/docs/tool-result-guard.md b/docs/tool-result-guard.md index cc163728a..2562d2f31 100644 --- a/docs/tool-result-guard.md +++ b/docs/tool-result-guard.md @@ -159,7 +159,9 @@ exchange, its unsubmitted payload is not admitted to memory. Each withheld result's original is written, as the result was stored (its text; for a result over the inline cap, its capped text, whose own note names the file with the whole output), to a file under the `tool-results/` -directory of the first writable mount without an include, named +directory of the first writable mount without an include that isn't +configured direct (a direct mount's directory is a checkout, whether it runs +direct or as 'chronicle'), named `-withheld---.txt` with any character outside `A-Za-z0-9._-` replaced and the name cut to 80 characters. Its stub names it: @@ -168,7 +170,7 @@ directory of the first writable mount without an include, named Otherwise the stub names the guard's audit record, for an operator: -- with no writable mount without an include, or once the framework is +- with no writable mount without an include that isn't configured direct, or once the framework is stopping: "Its result is kept in the guard's audit record (framework/tool-result-guard), for an operator."; - when the write failed: "Writing its result to workspace file … failed, so it @@ -224,7 +226,7 @@ Two audit records describe it, each with `batchId`, `messageId` and `{ toolUseId, path }` is a written file; with `error`, a failed write; with `unfinished: true`, a write still under way at the deadline. `{ toolUseId, error }` is a write that threw. `{ toolUseId, skipped }` is no write: - `no_workspace_file` (no writable mount without an include, or the framework + `no_workspace_file` (no writable mount without an include that isn't configured direct, or the framework was stopping), `workspace_unanswered` (an earlier write was still under way past its deadline), `batch_deadline` (the batch had no time left to start another write), or `branch_changed` (the workspace declined the write before its tree entry was set). diff --git a/package.json b/package.json index 0d7da9ea0..f9ce8da86 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@animalabs/agent-framework", - "version": "0.24.1", + "version": "0.26.0", "description": "Multi-agent framework with pluggable modules, persistent state, and concurrent tool execution", "repository": { "type": "git", @@ -32,7 +32,7 @@ "clean": "rm -rf dist", "typecheck": "tsc --noEmit", "pretest": "mkdir -p dist/test/fixtures && cp test/fixtures/*.mjs dist/test/fixtures/", - "test": "node --import ./dist/test/helpers/force-exit-blocking-stdout.js --test --test-force-exit --test-timeout=600000 dist/test/*.test.js", + "test": "node --import ./dist/test/helpers/force-exit-blocking-stdout.js --test --test-force-exit ${AF_TEST_CONCURRENCY:+--test-concurrency=$AF_TEST_CONCURRENCY} --test-timeout=600000 dist/test/*.test.js", "preversion": "node scripts/release-bump.mjs && node scripts/release-holds.mjs", "version": "node scripts/release-changelog.mjs && git add CHANGELOG.md changelog.d", "prepublishOnly": "node scripts/release-holds.mjs && npm run build" @@ -49,8 +49,8 @@ "license": "MIT", "dependencies": { "@animalabs/chronicle": "^0.4.0", - "@animalabs/context-manager": "^0.18.0", - "@animalabs/membrane": "^0.6.0", + "@animalabs/context-manager": "^0.20.0", + "@animalabs/membrane": "^0.7.1", "@modelcontextprotocol/client": "^2.3.1", "chokidar": "^4.0.3", "discord.js": "^14.25.1", diff --git a/src/agent.ts b/src/agent.ts index 35202b78d..4f03f32fa 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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, @@ -33,6 +33,28 @@ export interface ActivationRequestOptions { /** A request that will never be sent (a preview): compile it as a dry * run (see `compileWithInjections` for what that leaves alone). */ dryRun?: boolean; + /** `false`: the caller retries this stream's failures itself, so membrane + * sends a failed round once and hands the error up. The framework's turns + * pass it (its errorPolicy is their retry layer); a direct caller of + * startStream keeps membrane's retries. */ + membraneRetries?: boolean; +} + +/** A compile's render stats, as context-manager's `getRenderStats()` types + * them; context-manager 0.20.1 gives each compile its own, where the + * strategy records them, in `CompileResult.renderStats`. */ +export type RenderStats = NonNullable>; + +/** The render stats of the dry compile behind each request a dry run + * assembled (`assembleActivationRequest` with `dryRun`), by that request. + * A request that is sent has none here. Read through the framework's + * `getRequestCompileStats`. */ +const dryCompileStats = new WeakMap(); + +/** The render stats of the dry compile that built `request`, or null: see + * `AgentFramework.getRequestCompileStats`. */ +export function dryCompileStatsOf(request: object): RenderStats | null { + return dryCompileStats.get(request) ?? null; } export interface StartStreamResult { @@ -59,6 +81,7 @@ import type { } from '@animalabs/context-manager'; import type { AgentConfig, + AutoContinueConfig, AgentState, SameRoundThinkTextPolicy, SameRoundThinkTextPolicySource, @@ -94,14 +117,16 @@ const DEFAULT_SAME_ROUND_THINK_TEXT_POLICY: SameRoundThinkTextPolicy = 'public'; /** * Refuse an agent config whose `promptCaching`, `toolResultGuard`, - * `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 - * 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 - * the refusal is here because the setting is AgentConfig's. + * `toolWrapperProseGuard`, `thinking.enabled`, `thinkingBinding.dropBlock` or + * `refusalHandling.autoRewind` holds anything but true or false + * (assertBooleanSetting), or whose `thinking`, `thinkingBinding` or + * `refusalHandling` is anything but an object, null or false (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 the refusal is here because the + * setting is AgentConfig's. */ export function assertAgentSwitches(config: AgentConfig): void { const agent = `Agent "${config.name}"`; @@ -117,6 +142,14 @@ export function assertAgentSwitches(config: AgentConfig): void { 'true asks the provider for extended thinking, and false or leaving it out doesn\'t', ); } + if (holdsSettings(`${agent} thinkingBinding`, config.thinkingBinding, '{ dropBlock: true }')) { + assertBooleanSetting( + `${agent} thinkingBinding.dropBlock`, + config.thinkingBinding.dropBlock, + 'true asks the provider to drop a signed thinking block whose earlier conversation changed rather than refuse the request, ' + + 'and false or leaving it out sends nothing, so the account\'s default applies', + ); + } if (holdsSettings(`${agent} refusalHandling`, config.refusalHandling, '{ autoRewind: true }')) { assertBooleanSetting( `${agent} refusalHandling.autoRewind`, @@ -137,6 +170,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', + ); + } + return { maxConsecutive: max }; } /** @@ -162,6 +234,9 @@ export class Agent { * blocks are preserved through Chronicle. */ readonly thinking: AgentConfig['thinking']; + /** Whether requests ask the provider to drop signed thinking whose earlier + * conversation changed (see AgentConfig.thinkingBinding.dropBlock). */ + private readonly thinkingDropBlock: boolean; /** Refusal auto-rewind policy (see AgentConfig.refusalHandling). */ readonly refusalHandling: AgentConfig['refusalHandling']; readonly toolResultGuard: ToolResultGuard; @@ -169,6 +244,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). */ @@ -303,10 +381,12 @@ export class Agent { this.maxTokens = config.maxTokens ?? 4096; this.temperature = config.temperature; this.thinking = config.thinking; + this.thinkingDropBlock = config.thinkingBinding?.dropBlock === true; this.refusalHandling = config.refusalHandling; 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; @@ -1016,6 +1096,57 @@ export class Agent { return this.shownWatermark; } + /** + * The newest consumed watermark a request carried that was answered: its + * round's tool calls, or its completion, came back. A message a boundary + * injected counts as heard only once a request carrying it is answered; + * the consumed watermark moves when the stream is handed it, before its + * request runs, so a request that fails, ending its stream with an error, + * leaves this as it was. + */ + private answeredWatermark: { branch: string; sequence: number } | null = null; + + getAnsweredWatermark(): { branch: string; sequence: number } | null { + return this.answeredWatermark; + } + + /** A request that carried `watermark` was answered. Requests are answered + * in the order they're sent, and the consumed watermark only advances + * within a branch, so the latest is the newest. */ + markAnswered(watermark: { branch: string; sequence: number } | null): void { + if (watermark) this.answeredWatermark = watermark; + } + + /** + * Per destination (a server's channel, by its key), the newest consumed + * watermark the agent had read when it published there by a route the + * framework sees (its prose, a sleep's announcement, a channel_publish, a + * draft resend, or a send tool whose channel the framework records), on + * the branch it was taken on. A wake that carries a channel, unless it's + * marked ambient (InferenceRequest.ambient), keeps its turn unless the + * agent spoke there after reading the write the wake is for. It + * lives as long as this Agent: an agent made again starts it empty, and + * such a wake then stays. + */ + private publishedThrough = new Map(); + + /** Note a publish into `destination` (its key), made having read through + * `readThrough`: the consumed watermark when its words were written, or + * its call made. */ + notePublished(destination: string, readThrough: { branch: string; sequence: number } | null): void { + if (!readThrough) return; + const noted = this.publishedThrough.get(destination); + if (noted && noted.branch === readThrough.branch && noted.sequence >= readThrough.sequence) return; + this.publishedThrough.set(destination, readThrough); + } + + /** Whether the agent published into `destination` (its key) having read + * the row stored at `sequence` on `branch`. */ + publishedAfterReading(destination: string, branch: string, sequence: number): boolean { + const noted = this.publishedThrough.get(destination); + return !!noted && noted.branch === branch && noted.sequence >= sequence; + } + /** Mark everything currently stored as consumed (the agent's creation, at * boot or later, and a tool boundary that injected messages into a live * stream declaring it carries them; see consumedWatermark). */ @@ -1023,7 +1154,8 @@ export class Agent { // Tolerates partial context-manager doubles (tests build Agents over // stubs). Without a watermark, a reader that looks for one takes its own // side: the coalescer counts the message as read, and the framework's - // deferredWriteReadOrGone and unreadNoticesForPrimary count it as unread. + // unreadNoticesForPrimary counts it as unread. A deferred write's wake + // reads the answered watermark instead (deferredWriteDone). this.consumedWatermark = this.pendingWatermark(); this.watermarkMarks++; this.shownWatermark = this.consumedWatermark; @@ -1403,7 +1535,7 @@ export class Agent { system: this.buildSystemPrompt(prospectiveSystemInjections), }) : undefined; - let { messages, systemInjections } = await this.compileWithInjections( + const compiled = await this.compileWithInjections( budget, injections, immutablePrefixHash || options.dryRun @@ -1413,6 +1545,10 @@ export class Agent { } : undefined, ); + let { messages, systemInjections } = compiled; + // context-manager 0.20.1's field, read structurally so this compiles + // against 0.20.0 too, whose compiles carry none. + const { renderStats } = compiled as typeof compiled & { renderStats?: RenderStats }; // Sanitize: strip empty/whitespace text blocks and drop messages left with // no content. The Anthropic API rejects empty text blocks with 400 @@ -1485,25 +1621,27 @@ export class Agent { ...this.requestSettings(), ...(kvUnified ? { cacheMarkers: 'cm-owned' as const } : {}), }; + if (options.dryRun) dryCompileStats.set(request, renderStats); return { request, ...(continuePrompt ? { continuePrompt } : {}) }; } /** * The request fields that come from this agent's own settings: its model - * config, prompt caching and cache TTL, prefill message, provider - * parameters and participant name. Every request the agent sends takes - * them from here, a framework turn's (assembleActivationRequest) and the - * direct API's (runInferenceWithInjections), so a setting reaches both or - * neither. + * config, thinking binding, prompt caching and cache TTL, prefill message, + * provider parameters and participant name. Every request the agent sends + * takes them from here, a framework turn's (assembleActivationRequest) and + * the direct API's (runInferenceWithInjections), so a setting reaches both + * or neither. */ private requestSettings(): Pick { + const thinking = this.requestThinking(); return { config: { model: this.model, maxTokens: this.maxTokens, ...(this.temperature !== undefined && { temperature: this.temperature }), - ...(this.thinking !== undefined && { thinking: this.thinking }), + ...(thinking !== undefined && { thinking }), }, promptCaching: this.promptCaching, cacheTtl: this.cacheTtl, @@ -1513,6 +1651,20 @@ export class Agent { }; } + /** + * The thinking config a request carries: the agent's own, with the binding + * control added when `thinkingBinding.dropBlock` asks for it. A model whose + * thinking is always on takes the control whatever `enabled` says, so an + * agent with no thinking config of its own still sends one, holding only + * the control and `enabled: false`. Membrane sends nothing from it to any + * other model, since thinking is off there. + */ + private requestThinking(): NonNullable | undefined { + if (!this.thinkingDropBlock) return this.thinking; + const own = this.thinking !== null && typeof this.thinking === 'object' ? this.thinking : { enabled: false }; + return { ...own, blockBinding: { prefixMismatchBehavior: 'drop_block' } }; + } + /** The request `assembleActivationRequest` builds (see there), for preview * and debug tooling: what this agent's next activation would send. */ async buildActivationRequest( @@ -1637,6 +1789,12 @@ export class Agent { emitTokens: true, emitBlocks: false, emitUsage: true, + // A caller that retries itself (the framework's turns: errorPolicy, + // provider holds, stated waits, each attempt a fresh compile a stop can + // cancel) has membrane send a failed round once and hand the error up: + // since membrane 0.7 its own schedules, ten minutes at a 529, would + // otherwise run inside each of the caller's attempts, out of its sight. + ...(options.membraneRetries === false ? { retry: false } : {}), // Retry a content-policy refusal at the provider seam rather than at // the framework level: membrane replays the SAME request immediately // (cache-warm), where a framework-level requeue would recompile and diff --git a/src/auto-continue.ts b/src/auto-continue.ts new file mode 100644 index 000000000..2eef74dd3 --- /dev/null +++ b/src/auto-continue.ts @@ -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 = 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.' : ''); +} diff --git a/src/code-execution/isolate/scripts.ts b/src/code-execution/isolate/scripts.ts index 467743885..f39c10685 100644 --- a/src/code-execution/isolate/scripts.ts +++ b/src/code-execution/isolate/scripts.ts @@ -46,6 +46,10 @@ export interface ScriptCallContext { watch?: ScriptWatch; /** The call watching the script now, if one is: a result that ends the turn ends it at that call's return. Without it, `watch`. */ watching?(): ScriptWatch | undefined; + /** The turn the script's run call was made in, when one was: the turn its words were written in, which a later turn's wait doesn't change. */ + startTurnId?: number; + /** What its agent had read when the run call started the script (the host's consumed watermark), given at run: what the script's words were written having read, which a later round's wait doesn't change. */ + startReadThrough?: { branch: string; sequence: number } | null; } export interface ScriptsHost { @@ -113,7 +117,7 @@ interface Script { /** The code it runs. */ code: string; run: ScriptRun; - context: { origin?: unknown }; + context: { origin?: unknown; startReadThrough?: { branch: string; sequence: number } | null }; /** The turn its run call was made in, when one was: its agent's turns end it by that. */ turnId: number | undefined; startedAt: number; @@ -176,7 +180,7 @@ export class AgentScripts { agentName: string, code: string, askedMs?: number, - options: { origin?: unknown; watch?: ScriptWatch } = {}, + options: { origin?: unknown; watch?: ScriptWatch; startReadThrough?: { branch: string; sequence: number } | null } = {}, ): Promise { // Listed first, so a module whose tool list throws refuses the run with nothing taken. let tools: readonly PreludeTool[]; @@ -202,7 +206,10 @@ export class AgentScripts { agentName, code, run: null as unknown as ScriptRun, - context: options.origin === undefined ? {} : { origin: options.origin }, + context: { + ...(options.origin === undefined ? {} : { origin: options.origin }), + ...(options.startReadThrough === undefined ? {} : { startReadThrough: options.startReadThrough }), + }, turnId: options.watch?.turnId, startedAt: Date.now(), endedAt: null, @@ -231,6 +238,7 @@ export class AgentScripts { ...script.context, ...(script.watch ? { watch: script.watch } : {}), watching: () => script.watch ?? undefined, + ...(script.turnId !== undefined ? { startTurnId: script.turnId } : {}), }), onWake: (payload, n) => this.wake(script, payload, n), onEnd: (ending) => this.ended(script, ending), diff --git a/src/dendrite/presets.ts b/src/dendrite/presets.ts index 64bd1049d..974e8ba65 100644 --- a/src/dendrite/presets.ts +++ b/src/dendrite/presets.ts @@ -63,7 +63,13 @@ export function subconsciousSpec(name: AgentName, serves: AgentName): AgentSpec */ export function conversationForkSpec( name: AgentName, - options: { template: AgentName; channelId: string; idleTtlMs: number; inherit?: Partial }, + options: { + template: AgentName; + /** The channel it's bound to, on its server. */ + homeChannel: { serverId: string; channelId: string }; + idleTtlMs: number; + inherit?: Partial; + }, ): AgentSpec { return { name, @@ -82,7 +88,7 @@ export function conversationForkSpec( // A copy renames the template's turns as it copies them. A shared // inheritance has no copy step, so the mapping is declared instead. ...(options.inherit?.mode === 'shared' ? { selfParticipants: [options.template] } : {}), - homeChannel: options.channelId, + homeChannel: { serverId: options.homeChannel.serverId, channelId: options.homeChannel.channelId }, namespace: `conversations/${name}`, }; } diff --git a/src/dendrite/registry.ts b/src/dendrite/registry.ts index d93355390..1ece5a560 100644 --- a/src/dendrite/registry.ts +++ b/src/dendrite/registry.ts @@ -141,7 +141,7 @@ export class AgentRegistry { ...(spec.inherit ? { inherit: clone(spec.inherit) } : {}), ...(spec.selfParticipants?.length ? { selfParticipants: [...spec.selfParticipants] } : {}), ...(spec.presentAs ? { presentAs: spec.presentAs } : {}), - ...(spec.homeChannel ? { homeChannel: spec.homeChannel } : {}), + ...(spec.homeChannel ? { homeChannel: { serverId: spec.homeChannel.serverId, channelId: spec.homeChannel.channelId } } : {}), readsSharedSlot: spec.readsSharedSlot ?? false, ...(spec.namespace ? { namespace: spec.namespace } : {}), ...(spec.metadata ? { metadata: clone(spec.metadata) } : {}), @@ -264,7 +264,7 @@ export class AgentRegistry { } /** The channel an agent is bound to, if it has one. */ - homeChannel(name: AgentName): string | undefined { + homeChannel(name: AgentName): AgentRecord['homeChannel'] { return this.live.get(name)?.homeChannel; } diff --git a/src/dendrite/types.ts b/src/dendrite/types.ts index d5eb29df6..f27baa16a 100644 --- a/src/dendrite/types.ts +++ b/src/dendrite/types.ts @@ -232,8 +232,13 @@ export interface AgentSpec { selfParticipants?: string[]; /** The participant name its turns are presented under, when not its own name. */ presentAs?: string; - /** A channel this agent is bound to (its speech home and inbound scope). */ - homeChannel?: string; + /** + * A channel this agent is bound to (its speech home and inbound scope), on + * its server: channel ids are unique only within one server's connection, + * so two servers' channels with one id are two homes (the channel + * registry's `ChannelRef`). + */ + homeChannel?: { serverId: string; channelId: string }; /** * Whether the agent reads the residents' shared un-namespaced message * slot (as its own slot or through an observe edge). @@ -298,7 +303,13 @@ export interface AgentRecord { inherit?: ContextInheritance; selfParticipants?: string[]; presentAs?: string; - homeChannel?: string; + /** + * The spec's home, on its server. A bare channel id is a record written + * before homes carried their server: such a record is an ended one, since + * a conversation fork ends at a restart, and the framework reads its id + * as a wake that names a channel without its server is. + */ + homeChannel?: { serverId: string; channelId: string } | string; readsSharedSlot: boolean; namespace?: string; metadata?: Record; diff --git a/src/framework.ts b/src/framework.ts index cae0fdccb..81e46d5e8 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -11,6 +11,7 @@ import { import { JsStore } from '@animalabs/chronicle'; import type { Membrane, ContentBlock, NormalizedRequest, NormalizedResponse, YieldingStream, DetailedUsage, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane'; import { MembraneError } from '@animalabs/membrane'; +import { classifyFailure, retryDecision, MEMBRANE_RETRY_SETTINGS, type RetrySettings } from '@animalabs/membrane/retry'; import { ContextManager, PassthroughStrategy, WindowedPassthroughStrategy, OverBudgetError, UncoveredDropError, stripUnpairedToolBlocks, continuesBody, MalformedContentError, malformedContentPlaces, CompressionWithdrawn } from '@animalabs/context-manager'; import type { CacheWireReceipt } from './kv-unified-wire.js'; import { SILENT_HEARTBEAT_SEPARATOR, type RequestOnlyPrompt, type RequestOnlyStamps } from './silent-heartbeat.js'; @@ -79,13 +80,12 @@ import type { AgentRuntimeSettingsSnapshot, AgentSettingsExtension, SameRoundThinkTextPolicy, - ModuleMessage, -} from './types/index.js'; + ModuleMessage, AutoContinueRun } from './types/index.js'; import { asModuleRow, heldHandle, heldWriteId, matchesMessageQuery } from './held-writes.js'; import { ProcessQueueImpl } from './queue.js'; import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, shutdownErrorMessage, waitForShutdown, ShutdownTimeoutError } from './shutdown.js'; import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK, REFUSAL_REACTION_BASELINE } from './refusal-reactions.js'; -import { Agent, assertAgentSwitches } from './agent.js'; +import { Agent, assertAgentSwitches, dryCompileStatsOf, type RenderStats } from './agent.js'; import { assertBooleanSetting, holdsSettings } from './config-checks.js'; import { ModuleRegistry, checkedToolResult, ensureStateRegistered, storableToolResult, thrownResult } from './module-registry.js'; import { McplServerRegistry } from './mcpl/server-registry.js'; @@ -109,10 +109,10 @@ import { } from './mcpl/push-coalescer.js'; import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, McpToolResultContent, PushEventResult } from './mcpl/types.js'; import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHOLDER } from './mcpl/visible-content.js'; -import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; +import { isProseTargetToken, parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js'; import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js'; import { InferenceRouter } from './mcpl/inference-router.js'; -import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; +import { ChannelRegistry, TUNE_OUT_REPLACED, connectorText, desiredStateInLog, destinationKey, journalToolDescription, NON_HOLDER_CHANNEL_DESCRIPTIONS, qualifiedChannelName, sameChannel, type ChannelRef, type ChannelToolOrigin, type PublishDestination, type PublishOutcome, type SelectedForOpening } from './mcpl/channel-registry.js'; import { KEEP_IDLE_MS, KEEP_NEWEST, ProseDraftStore, draftState, uncertainAttempt, type Draft, type DraftAttempt, type DraftReason, type DraftState, type InheritedRisk } from './prose-drafts.js'; import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js'; import { @@ -143,6 +143,8 @@ import type { WorkspaceModule } from './modules/workspace/index.js'; import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js'; import { nonErrorResultText, + shownToScript, + shownToScriptFromServer, toolResultDataToHistoryString, truncateForHistory, DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, @@ -182,6 +184,7 @@ import { DEFAULT_OUTPUT_BOUNDS } from './code-execution/isolate/output.js'; import type { PreludeTool } from './code-execution/isolate/prelude.js'; import { bounded, renderList, renderReturn, VALUE_CHARS } from './code-execution/isolate/render.js'; import { splitProseRuns, withoutRoundsProse } from './prose-segments.js'; +import { AUTO_CONTINUE_REASON, AUTO_CONTINUE_STOP_REASONS, autoContinueNotice } from './auto-continue.js'; import { cumulativeDelta } from './usage-accounting.js'; import { stampThinkingTokenEstimates } from './thinking-token-stamp.js'; import { contentAsStored, nameAsStored } from './text-as-stored.js'; @@ -610,6 +613,11 @@ const isAddressedMessage = (tags?: string[]): boolean => const authorName = (metadata: Record | undefined): string => (metadata?.author as { name?: string } | undefined)?.name ?? 'Someone'; +/** A wake's `ambient` mark: only a message its server tagged `chat:ambient` + * (RFC-001) is known to ask nothing of the agent. */ +const ambientMark = (tags?: string[]): { ambient?: true } => + Array.isArray(tags) && tags.includes('chat:ambient') ? { ambient: true } : {}; + // parseProsePrefix lives in ./mcpl/prose-grammar.ts. The outgoing stream // (Spec 14.3) needs no grammar of its own: it carries what delivery // published. @@ -650,6 +658,11 @@ function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled', hel const PROSE_ROUTING_HELP = 'Plain-text output routing (explicit mode):\n' + ' >>#channel-name … or >>@person … (DM) or >>service:guild:id …\n' + + ' Where two servers register one channel id, the server goes first:\n' + + ' >>server:service:guild:id … (a bounce lists each such name).\n' + + ' Within a turn, the id of a channel the turn has been in means that channel,\n' + + ' on its server, and bounces once that server no longer registers it; where\n' + + ' two of the turn\'s channels have the id, it means neither and bounces.\n' + ' One message may hold several sections: each line starting with ">>" begins\n' + ' a new envelope, routed to its own destination independently.\n' + ' A destination applies to the rest of that turn, until another is named.\n' + @@ -704,7 +717,8 @@ const utilityOneLiner = (description: string): string => { import { CheckpointManager } from './mcpl/checkpoint-manager.js'; import { isToolAllowed } from './mcpl/tool-policy.js'; import { toolArgsProblems } from './tool-args.js'; -import { EventGate, formatShadowWarning } from './gate/event-gate.js'; +import { EventGate, checkToolInput, formatShadowWarning, shownValue, wakeLineText } from './gate/event-gate.js'; +import type { GateEntryRef, WakeLine } from './gate/types.js'; import { EPHEMERAL_USAGE_NAME, UsageTracker, persistedUsageProblem, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import { McplProtocolVersionError, McplRequestError, McplUnreapedLaunchError, stderrTailOf, withStderr, type McplConnectionCloseInfo, type McplServerConnection } from './mcpl/server-connection.js'; @@ -718,6 +732,7 @@ import type { McplHostCapabilities, FeatureSetsChangedParams, BeforeInferenceParams, + ChannelContext, PushEventParams, McplInferenceRequestParams, InferenceChunkParams, @@ -882,13 +897,10 @@ type InboundResponder = { * For an entry whose write threw after storing it, found in the store and * counted as written, it is what landed, joined the same way, which for a * sharded body can be its first shards alone. A store that can't be read - * back (storedCopiesOf logs it) leaves a sharded body as given. `parts` is - * how many store messages hold it, as read back: a sharded body's shards, - * consecutive from `id`, and 1 where it couldn't be read back. */ + * back (storedCopiesOf logs it) leaves a sharded body as given. */ interface DrainedWrite { id: MessageId; content: ContentBlock[]; - parts: number; } /** What later compiles render from the store for the copies of one write @@ -905,28 +917,24 @@ function asCompiled(landed: ReadonlyArray<{ content: ContentBlock[] }>): Content return [...blocks.filter((b) => b.type !== 'text'), { type: 'text', text } as ContentBlock]; } -/** A message's size as a notice gives it: its text's characters and its - * images, each left out when there are none, so empty when it has - * neither. */ -function contentSize(content: readonly ContentBlock[]): string { - let chars = 0; - let images = 0; - for (const b of content) { - if (b.type === 'text') chars += b.text.length; - else if (b.type === 'image' || b.type === 'generated_image') images++; - } - const parts: string[] = []; - if (chars > 0) parts.push(`${chars.toLocaleString('en-US')} character${chars === 1 ? '' : 's'}`); - if (images > 0) parts.push(`${images} image${images === 1 ? '' : 's'}`); - return parts.join(' and '); -} - /** One stored copy of a deferred write, as the drain reads it back: a * sharded body's copies carry its `bodyGroupId`. */ type StoredCopy = { id: MessageId; content: ContentBlock[]; bodyGroupId?: string }; const storedCopy = (m: { id: MessageId; content: ContentBlock[]; bodyGroupId?: string }): StoredCopy => ({ id: m.id, content: m.content, ...(m.bodyGroupId !== undefined ? { bodyGroupId: m.bodyGroupId } : {}) }); +/** Messages per window when a store is scanned for deferred writes by their + * ids (landedPositions). */ +const LANDED_SCAN_WINDOW = 500; + +/** The message at `position` on `cm`'s current branch, with its blobs + * resolved, as a request carries it. Throws when there's none there. */ +function messageAt(cm: ContextManager, position: number): StoredMessage { + const message = cm.getMessageWindow(position, 1).messages[0]; + if (!message) throw new Error(`the store has no message at position ${position}`); + return message; +} + /** A tool result's spill file as writeToolResultFile left it: its path, with * an error if the write failed, or null with no writable workspace. */ type SpillWrite = { path: string; error?: string; branchLeft?: true } | null; @@ -1002,7 +1010,7 @@ function splitExplicitEnvelopes(rawText: string): string[] { */ interface AdmittedEnvelope { text: string; - resolved?: { channelId: string } | { error: string; candidates?: string[]; refusedChannelId?: string }; + resolved?: ChannelRef | { error: string; candidates?: string[]; refusedChannelId?: string }; } /** The domains an mcpl/manifestChanged announcement names, for its receipt: @@ -1025,6 +1033,71 @@ function resumesLogicalTurn(reason: string | undefined): boolean { return reason === 'context_budget_restart' || reason === 'tool_result_guard_retry'; } +/** + * Marks a held turn's retry: the request a provider hold keeps for a turn + * whose call failed into it, or whose compile it held with nothing sent. + * That turn has begun and not ended: it starts again when the hold releases + * (a new start of its open turn, as 0.26.0 started it). So, as a + * continuation on its way does (continuesTurn), it keeps its agent's turn + * alive meanwhile, passes a quiesce, and goes with an abandon, a stop() or + * its agent's removal. A symbol, not a field: hosts build requests too, and + * only the framework may say one is a turn's own retry. Spreads and rests + * copy it, as every copy of a request the scheduler makes is one. + */ +const TURN_RETRY: unique symbol = Symbol('turn-retry'); +/** + * The batch a start that carries a turn on (continuesTurn) was merged from, + * when the scheduler merged wakes that aren't the turn's into it: every + * request it took in, as each was. Until the start takes its turn token it + * stands for them all. A scheduler pass that takes it back puts the batch in + * its place, each request with its own identity and flags; a quiesce at the + * auxiliary park sends the batch back to the scheduler; and a caller's stop + * or an abandon that drops the start puts back the batch's wakes, the + * requests that don't carry the turn on (dropContinuations), where they run + * as a turn of their own, as wakes queued while a turn streams do. Once the + * start takes its token the turn reads them, and beginAgentTurn drops the + * record. A symbol, as TURN_RETRY is, so spreads and rests copy it. + */ +const MERGED_BATCH: unique symbol = Symbol('merged-batch'); +type MarkedRequest = InferenceRequest & { [TURN_RETRY]?: true; [MERGED_BATCH]?: InferenceRequest[] }; + +function mergedBatch(request: InferenceRequest): InferenceRequest[] | undefined { + return (request as MarkedRequest)[MERGED_BATCH]; +} + +/** The wakes of a merged start's batch (MERGED_BATCH): its requests that + * don't carry the turn on. */ +function mergedWakes(request: InferenceRequest): InferenceRequest[] { + return (mergedBatch(request) ?? []).filter((r) => !continuesTurn(r)); +} + +function isTurnRetry(request: InferenceRequest): boolean { + return (request as MarkedRequest)[TURN_RETRY] === true; +} + +/** How to go on sooner than a hold keeping a turn's retry lets the turn go + * on: what surgery's and the puppet's refusals advise. */ +function heldTurnWayOn(statedWait: boolean): string { + return 'a quiesce lets a held turn start when its hold ends and finish, so to go on sooner, end it with quiesce ' + + 'with abandon: true, which ends it once the quiesce\'s wait for the drain runs out (at once on a host ' + + 'already quiesced)' + + (statedWait + ? ", or lift a stated wait with release-provider-wait, so the turn tries again once no host's or " + + 'acceleration hold binds it' + : ''); +} + +/** A copy of a held turn's trigger, marked as its retry (TURN_RETRY). */ +function asTurnRetry(trigger: InferenceRequest): InferenceRequest { + return { ...trigger, [TURN_RETRY]: true } as MarkedRequest; +} + +/** A request that carries its agent's turn on: a continuation of it, or the + * retry a provider hold keeps for it (TURN_RETRY). */ +function continuesTurn(request: InferenceRequest): boolean { + return resumesLogicalTurn(request.reason) || isTurnRetry(request); +} + /** * The answers dispatchScriptToolCall's safety timer made, when no result came * for a script's call: the tool may still be running, or its result was lost, @@ -1033,6 +1106,20 @@ function resumesLogicalTurn(reason: string | undefined): boolean { */ const SCRIPT_CALL_NO_RESULT = new WeakSet(); +/** + * An MCPL peer's results whose blocks couldn't be given RFC-005's treatment + * for a script, because JSON couldn't hold them (dispatchMcplToolCall's + * reading), each with why. The failure is the script lane's alone: the + * model's lane reads the same answer with no such round trip. So such a + * result keeps the peer's own outcome, its blocks dropped, and the trace and + * the call's terminal report what the peer answered, as on v0.26.1, while + * the script lane's exit answers it as a result it can't convert to JSON + * (scriptResultAnswer). A module's result that JSON can't hold differs: no + * route can store or send it, so its dispatch makes it an error result for + * everyone (checkedToolResult). + */ +const SCRIPT_RESULT_UNCONVERTIBLE = new WeakMap(); + /** * One logical turn's routing state and receipt tallies * (AgentFramework.turnRouting): everything the turn has decided about where @@ -1068,7 +1155,15 @@ interface TurnRouting { * a budget restart's trigger carries no channel, and dropping it there * sent Sol's DM reply to a stale guild channel (2026-07-22). */ - triggerChannel?: string; + triggerChannel?: ChannelRef; + /** + * The turn was woken for a channel that names no one registered channel (a + * host's id, given without its server, that two servers' channels answer + * to, or that none does), so it has no channel and takes no fallback, in + * its locus or in what servers are told (activeChannelResolver's null). + * A fork's home still applies. + */ + noChannel?: true; /** * Locus and hybrid mode: where the turn's unpinned plain prose lands. * Frozen when the turn opens (home channel for a fork, else the trigger @@ -1079,7 +1174,14 @@ interface TurnRouting { * moves it too. Unset in explicit and disabled mode, and for a turn with * nowhere to speak. */ - locus?: string; + locus?: ChannelRef; + /** + * Every channel that has been the turn's trigger or its locus, in the + * order each became so: a channel_open or a re-pin moves those two, and + * the channels they leave stay here. A channel the turn has been in keeps + * its server for the names the turn gives (ownChannels). + */ + wasIn: ChannelRef[]; /** * The channel the turn's typing indicator was last shown in, by any of its * streams (showTurnTyping). A stream that hands the turn to a successor (a @@ -1087,16 +1189,17 @@ interface TurnRouting { * start, or a move, that shows it elsewhere stops this one first, as after * a re-pin. */ - typing?: string; + typing?: ChannelRef; /** * Channels the turn sent into with a send tool (SEND_ENGAGEMENT_TOOLS): a * conversational follow-up in one of them is addressed by context, so it * re-pins the locus (2026-07-31 n=7: q's #portables reply, with no - * mention, followed the stale pin into repligate's DM). + * mention, followed the stale pin into repligate's DM). Keyed by + * destinationKey. */ engaged: Set; /** The sticky destination, set by each `>>` (hybrid: `>>>`) prefix that resolves. */ - pin?: string; + pin?: ChannelRef; /** * Hybrid: the router is fail-closed until a later `>>>` names a target that * resolves. After a `>>>skip_reply` envelope ('private') the rest stays @@ -1106,8 +1209,22 @@ interface TurnRouting { hybridHold?: 'private' | 'failed'; /** The turn asked for a ` !` continuation; taken once. */ continuation: boolean; + /** + * Explicit mode: the turn's latest envelope was `>>skip_reply`, so it + * ended on a deliberate skip and autoContinue leaves it paused. (Hybrid + * reads `hybridHold === 'private'`, which a `>>>skip_reply` leaves until a + * destination is named.) + */ + lastEnvelopeSkipped?: boolean; + /** + * Explicit or hybrid mode: the turn's latest envelope bounced (no + * destination, an unresolved or refused one, a failed send). Its bounce, + * with the bounce-wake cap, is that turn's answer, so autoContinue leaves + * it to the bounce. + */ + lastEnvelopeBounced?: boolean; /** Successful plain-prose destinations in delivery order; deduped by the receipt. */ - deliveries: string[]; + deliveries: ChannelRef[]; /** Plain-prose segments the turn suppressed without drafts (proseRouting=disabled, or a round withheld before its scripts returned: ScriptRound.withhold), reported by its receipt. */ suppressed: number; /** @@ -1142,8 +1259,9 @@ interface TurnRouting { arrivals: Arrival[]; /** * Under `speech.locus: 'delivery'`, the channels the turn has already told - * the agent it was addressed from while busy (one notice per channel per - * turn; a retry or restart continues the turn and doesn't repeat it). + * the agent it was addressed from while busy, by destinationKey (one + * notice per channel per turn; a retry or restart continues the turn and + * doesn't repeat it). */ addressedElsewhere: Set; } @@ -1152,7 +1270,7 @@ interface TurnRouting { interface Arrival { messageId: MessageId; /** The channel it re-pins the locus to. */ - channelId: string; + channel: ChannelRef; /** It addressed the agent (`chat:addressed`), rather than following up in an engaged channel. */ addressed: boolean; } @@ -1160,10 +1278,16 @@ interface Arrival { /** The last TurnRouting.turnId given out: ids increase, and are unique within the process. */ let lastTurnId = 0; +/** Record that a channel has been the turn's trigger or its locus (TurnRouting.wasIn). */ +function noteTurnChannel(routing: TurnRouting, channel: ChannelRef | undefined): void { + if (channel && !routing.wasIn.some((c) => sameChannel(c, channel))) routing.wasIn.push(channel); +} + function freshTurnRouting(): TurnRouting { return { turnId: ++lastTurnId, engaged: new Set(), + wasIn: [], continuation: false, deliveries: [], suppressed: 0, drafts: [], keptPrivate: 0, arrivals: [], addressedElsewhere: new Set(), }; @@ -1275,8 +1399,10 @@ function ephemeralRunStopped(name: string, observed?: string): Error { /** The reason on the inference:aborted that ends a turn whose retry stop() * cancelled, whichever frame was waiting to retry: beginAgentTurn's, when - * the stream couldn't start, or driveStream's, when it failed once started. - * Its inference:failed came first, and this says no retry follows it. */ + * the stream couldn't start, or driveStream's, when it failed once started; + * or whose failure a provider hold would have kept for its retry once stop() + * had closed admission (endFailureAtStop). Its inference:failed came first, + * and this says no retry follows it. */ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stopping is dropped before it sends anything'; /** The reason on the inference:aborted that ends a turn whose start waited to @@ -1284,6 +1410,10 @@ const RETRY_CANCELLED_BY_STOP = 'a retry waiting while the framework was stoppin * revive the earlier lifecycle's turn in the next one. */ const RETRY_ACROSS_RESTART = 'a retry waiting while the framework was stopped and started again is dropped before it sends anything'; +/** The reason on the inference:aborted that ends a turn whose continuation + * was dropped before it started, other than by an abandon or a stop. */ +const CONTINUATION_DROPPED = "a turn's continuation was dropped before it started"; + /** One frame's window while it holds a turn with no stream and one of its * checks is ahead (AgentFramework.checkAhead). `abandoned` is an operator's * quiesce abandon that reached the turn there, which the frame's check @@ -1300,11 +1430,32 @@ function isTurnContinuation(reason: string): boolean { || reason === 'tool_result_guard_retry'; } +/** A request a quiesce lets through, so a turn already alive can finish: + * a continuation of the turn, or a held turn's retry (TURN_RETRY). */ +function passesQuiesce(request: InferenceRequest | undefined): boolean { + return request !== undefined && (isTurnContinuation(request.reason) || isTurnRetry(request)); +} + /** Wake sources the framework generates for itself (bounce wakes, * continuations, retries, unsticks, admission requeues). A wake from any * other source — a channel message, a heartbeat or other server event, a * gate flush, a timer, an operator nudge — is a cause of its own. */ const FRAMEWORK_WAKE_SOURCES = new Set(['framework', 'scheduler']); + +/** Whether a wake is a cause of its own for an autoContinue run: one from + * outside the framework that isn't a silent heartbeat tick. A batch holding + * one is that cause's turn: the run starts again, and no `[continue]` row is + * written for the continuation it carries. A silent tick is neither: batched + * with a continuation it neither restarts the run nor takes its notice. */ +function hasOwnCauseForRun(r: InferenceRequest): boolean { + return !FRAMEWORK_WAKE_SOURCES.has(r.source) && r.suppressProse !== true; +} + +/** Whether a wake carries an autoContinue continuation: its own wake, or a + * requeued turn trigger that kept the mark (InferenceRequest.autoContinueRun). */ +function carriesContinuation(r: InferenceRequest): boolean { + return r.autoContinueRun !== undefined; +} const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router'; const INFERENCE_LOG_ID = 'framework/inference-log'; const PROCESS_LOG_ID = 'framework/process-log'; @@ -1648,19 +1799,35 @@ export interface HostModeStatus { quiesced: boolean; reason?: string; since?: number; - /** True when no turn is alive (activeTurnTokens empty — the token spans - * dequeue → settled teardown, strictly wider than activeStreams). */ + /** True when no turn is alive: no turn token (the token spans dequeue → + * settled teardown, strictly wider than activeStreams), no continuation + * of a turn on its way to its start (a budget restart's or a tool-result + * guard's retry, queued, held by a provider hold, parked or in a scheduler + * pass), no turn whose call failed into a provider hold waiting for the + * hold to release its retry (wherever that retry then waits), and no wake + * parked on provider admission. It covers the + * framework's own turns: a caller of the direct Agent API + * (`Agent.runInference`) owns its turns' timing. */ drained: boolean; activeTurns: number; + /** The agents with a turn alive, the ones `activeTurns` counts, sorted: + * each from its turn's dequeue to its settled teardown, so a turn in its + * hooks or compile, one finishing its deliveries, one whose continuation + * is on its way to its start, or one a provider hold keeps for its retry, + * is listed. These are the agents an abandon or + * a stop() made now would find mid-turn. */ + activeTurnAgents: string[]; /** Wakes that own provider admission while waiting for an in-flight * auxiliary call — no turn token yet, but a turn is one settle away. The * admission continuation rechecks quiesce and requeues them; `drained` - * is false until it has. */ + * is false until it has. A parked continuation of a turn is counted here + * and, as a turn alive, in `activeTurns` and `activeTurnAgents`. */ parkedAdmissions: number; /** Inference requests parked by the quiesce wake gate (while quiesced), or * simply queued (while serving — non-zero right after a resume until the * scheduler's next pass consumes them). Continuations of a held turn - * (budget restart, tool results) are never counted. */ + * (budget restart, tool results) and a held turn's retry, which a quiesce + * lets through, are never counted. */ gatedRequests: number; /** code_execution scripts still running that no call is waiting on: left * running after a yield, outside any turn. Quiesce does NOT stop them — @@ -1806,31 +1973,69 @@ function hostCommandRequester(serverId: string, params: HostCommandParams): Oper /** - * Default error policy - retry with exponential backoff. + * Default error policy: membrane's own retry schedules, decided here. * - * Respects MembraneError.retryable: non-retryable errors (400 invalid_request, - * 401 auth, context_length, safety) are terminal on attempt 0. Retrying them - * burns API quota without any chance of success — the payload doesn't change - * between attempts. Production traces showed clerk + reviewer wasting 4 - * inferences on each 400 due to blind retries. - * - * For retryable errors, honors retryAfterMs when present (rate limits), - * otherwise falls back to exponential backoff capped at maxRetries. + * The framework's turns pass `retry: false` to membrane, so a failure reaches + * this policy after one send, and the framework is the one retry layer: each + * retry is a fresh compile, a stop cancels its wait, and the wait is seen. + * The decision is membrane's (`@animalabs/membrane/retry`), on the settings + * given (membrane's defaults unless a host passes its own, e.g. from a + * recipe's retry config): + * - not retryable (400 invalid_request, 401 auth, context_length, safety, + * a caller-code error): terminal on the first failure. Retrying them burns + * API quota without any chance of success; production traces showed clerk + + * reviewer wasting 4 inferences on each 400 due to blind retries. + * - overloaded (a 529, or an overloaded error mid-stream): the overloaded + * schedule, jittered (by default 7 tries, 10 s doubling to 5 min, ~10 min). + * - rate limited (429): the base schedule with at least 5 tries. + * - any other retryable failure, a stream deadline on a sent request + * included (membrane leaves those to its caller): the base schedule (by + * default 3 tries, 1 s then 2 s). + * A provider's stated wait (`retryAfterMs`) is waited when it is longer than + * the backoff, up to the framework's own cap (PROVIDER_ACCELERATION_MAX_ + * COOLDOWN_MS, ten minutes) where a schedule's ceiling is lower; past it the + * turn ends here. (Before this policy runs, the framework holds a resident + * for a stated wait or an acceleration 429 itself, and ends an ephemeral run + * on a wait past that cap; see holdProviderAcceleration and + * nonOwningProviderWait.) + * An error that isn't a MembraneError is retried as a retryable failure, on + * the base schedule. */ -class DefaultErrorPolicy implements ErrorPolicy { - maxRetries = 3; +export class DefaultErrorPolicy implements ErrorPolicy { + /** The most retries any class allows (ErrorPolicy's bound, for readers). */ + readonly maxRetries: number; + private readonly settings: RetrySettings; + + constructor(settings: RetrySettings = MEMBRANE_RETRY_SETTINGS as RetrySettings) { + // A stated wait up to the framework's own cap is slept and retried, as it + // was before: nonOwningProviderWait ends an ephemeral run on a longer one, + // and a resident's waits are held before this policy runs. Membrane's + // schedules alone would end the turn on one past a minute. + this.settings = Object.fromEntries(Object.entries(settings).map(([cls, s]) => [cls, s && { + ...s, + statedWaitCeilingMs: s.statedWaitCeilingMs === null ? null : Math.max(s.statedWaitCeilingMs, PROVIDER_ACCELERATION_MAX_COOLDOWN_MS), + }])) as RetrySettings; + this.maxRetries = Math.max(0, ...Object.values(settings).map((s) => (s?.maxAttempts ?? 1) - 1)); + } onInferenceError(error: Error, _agentName: string, attempt: number): ErrorAction { - if (isMembraneError(error) && error.retryable === false) { - return { retry: false }; - } - if (attempt < this.maxRetries) { - const delayMs = isMembraneError(error) && typeof error.retryAfterMs === 'number' - ? error.retryAfterMs - : Math.pow(2, attempt) * 1000; - return { retry: true, delayMs }; - } - return { retry: false }; + if (!isMembraneError(error)) { + // Not a provider failure membrane classified: retried as any other + // retryable failure, on the base schedule. + const decision = retryDecision('retryable', attempt + 1, undefined, this.settings); + return decision.retry ? { retry: true, delayMs: decision.delayMs } : { retry: false }; + } + if (error.retryable === false) return { retry: false }; + // The facts membrane classifies by, read off the error itself (from either + // copy of membrane, as isMembraneError takes one). No `deadline` fact: a + // stream deadline is a retryable timeout here, as it was before membrane + // 0.7, where membrane itself gives it no retry. + const facts = error as { retryable?: boolean; type?: string; httpStatus?: number; message?: string }; + // A failure that doesn't say is retried as an unknown one is; only false refuses (handled above). + const cls = classifyFailure({ retryable: facts.retryable !== false, type: facts.type, httpStatus: facts.httpStatus, message: facts.message }); + // `attempt` counts this turn's earlier failures; membrane counts tries. + const decision = retryDecision(cls, attempt + 1, error.retryAfterMs, this.settings); + return decision.retry ? { retry: true, delayMs: decision.delayMs } : { retry: false }; } } @@ -1877,6 +2082,9 @@ interface ProviderAccelerationCooldown { /** Armed by an auxiliary (compression) failure with no primary involved: * release reopens auxiliary admission without synthesising an inference. */ auxiliaryOrigin?: boolean; + /** Its held requests were taken out by dropContinuations: release reopens + * admission without synthesising an inference in their place. */ + emptiedByDrop?: boolean; /** Set when this hold is a provider wait (a stated retry-after) for the * agent's primary model: it holds primary turns only. Auxiliary calls are * admitted per model by the provider waits themselves, so a compression @@ -2202,12 +2410,14 @@ function scriptResultBrief(content: unknown[]): string { /** * What a script gets for a tool result that can't be converted to JSON, - * such as a cyclic object or a BigInt that an in-process module returned. + * such as a cyclic object or a BigInt that an in-process module returned, + * or an MCPL peer's blocks nested deeper than JSON.stringify can take. * It names the tool, and says the call itself completed, so a script that * reports it doesn't invite a blind retry. The host's log gets the same - * line, since the tool that returned it needs fixing whether or not the - * script reports it. The reason and the tool's own error are cut to - * VALUE_CHARS each, so the failure reaches the script whatever its memory. + * line, since the tool, a module's or a server's, returns what a script + * can't receive, whether or not the script reports it. The reason and the + * tool's own error are cut to VALUE_CHARS each, so the failure reaches the + * script whatever its memory. */ function unconvertibleScriptResult(toolName: string, err: unknown, ownError?: string): string { let reason: string; @@ -2239,13 +2449,15 @@ const SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES = Buffer.byteLength( * memoryMaxBytes. So the figure moves with memoryMaxBytes. What it can't * count: a result's `structured` value, which the measure leaves out and the * answer carries as JSON beside the content, and that result's blocks past - * their text, which the answer carries as they are (each further block's - * keys, an image's data); nesting past 2,000 levels; and a + * their text, which the answer carries as the script lane gives them (each + * further block's keys, an image's data); nesting past 2,000 levels; and a * parse that needs more of the script's memory than is free, as for JSON * dense in small objects or a script already holding most of its memory. * Such a result, or one past the figure that doesn't fit, reaches the script * as ResultTooLarge, saying the call took effect, and nothing of it is cut or - * saved. + * saved. One the host can't convert to JSON at all, such as one nested deeper + * than its runtime's JSON.stringify can take, is answered instead as a + * failure naming the tool and saying the call itself completed. */ const scriptResultMaxChars = (memoryMaxBytes: number): number => Math.max(0, Math.floor((Math.min(maxResultBytes(memoryMaxBytes), bufferConstants.MAX_STRING_LENGTH) - SCRIPT_TEXT_ANSWER_ENVELOPE_BYTES) / 6)); @@ -2552,6 +2764,15 @@ type McpRestartClaim = { readonly restarting: string; endedBy?: string }; /** Why the framework cancelled a stream it owns: the mark it records, by * agent and stream, before the cancel, for the stream's terminal to read. */ type FrameworkCancelKind = 'turn_ended' | 'budget_restart' | 'quiesce_abandoned' | 'shutdown'; +/** How a turn whose continuation is dropped before it starts ends: an + * operator's quiesce abandon or stop() settles it as a stop ends a turn's + * start; any other drop ends it as a start dropped before it sends anything. + * `why` is said in the line its end logs. */ +type ContinuationDrop = + | { kind: 'quiesce_abandoned' | 'shutdown' | 'dropped'; why: string } + /** A caller's stop (abortInference) of a turn whose continuation or retry + * is on its way, with the reason it gave. */ + | { kind: 'caller'; reason?: string; why: string }; /** What opened a channel the agent didn't ask to open * (recordChannelAutoOpenNotice). */ @@ -2585,12 +2806,6 @@ type ChannelNoticePassOn = | { notice: 'channel-opened'; serverId: string; cause: ChannelOpenedCause; channels: Array<{ channelId: string; label?: string }> } | { notice: 'reply-open-failed'; serverId: string; channel: { channelId: string; label?: string }; selectedForOpening: SelectedForOpening }; -/** A channel as the channel notices show it (the opened and reply notices, - * and the lines that log them): its label beside its id, or its id alone. */ -function shownChannel(channel: { channelId: string; label?: string }): string { - return channel.label && channel.label !== channel.channelId ? `${channel.label} (${channel.channelId})` : channel.channelId; -} - /** A channel as a notice's inputs carry it, read back from stored metadata. */ function noticeChannel(value: unknown): { channelId: string; label?: string } | null { const channel = value as { channelId?: unknown; label?: unknown } | null | undefined; @@ -2636,12 +2851,57 @@ function passesToPrimary(metadata: unknown): boolean { channelNoticePassOn(metadata) !== null; } +/** The batched-wake lines a merge of wakes carries (InferenceRequest + * wakeLines), one per event, in request order; undefined for none. */ +function mergeWakeLines(requests: readonly InferenceRequest[]): WakeLine[] | undefined { + const byEntry = new Map(); + for (const request of requests) { + for (const line of request.wakeLines ?? []) { + const key = wakeLineKey(line); + if (!byEntry.has(key)) byEntry.set(key, line); + } + } + return byEntry.size > 0 ? [...byEntry.values()] : undefined; +} + +/** A batched-wake line's identity: its event's stored entry. */ +function wakeLineKey(line: WakeLine): string { + const { eventType, serverId, channelId, messageId, eventId } = line.entry; + return JSON.stringify([eventType, serverId ?? null, channelId ?? null, messageId ?? null, eventId ?? null]); +} + export class AgentFramework { private toolPresentations = new Map(); private presentationPreviews = new WeakMap(); getRequestToolPresentation(request: object): PresentationSnapshot | null { return this.presentationPreviews.get(request) ?? null; } + /** + * The render stats of the dry compile that built `request`, a request + * `previewActivation` returned (or `Agent.buildActivationRequest` with + * `dryRun`), as context-manager gives them in `CompileResult.renderStats`: + * what that compile's select put in the context, by kind. They count the + * selection, not each message of the request. The injections + * context-manager merges in after its select, such as those + * `previewActivation` gathers with `injections: true`, aren't counted; + * context-manager's doc of `CompileResult.renderStats` names what else its + * compile does after the select. The agent then changes the messages + * itself, and the stats come from before that: they count a message it + * drops, such as one left holding only blank text, and leave out the turns + * it adds, such as `[Continue]`, a silent heartbeat's separator and + * request-only turns. A later preview or live compile leaves them as they + * were. + * They're the object the compile returned, not a copy, so treat them as + * read-only: a change to them shows in every later read. Null for a request + * no dry run built, a request that is sent included, and when the agent's + * strategy records none (of context-manager's own, the autobiographical + * strategy and the knowledge strategy built on it do). Null too with + * context-manager before 0.20.1, whose compiles carry no render stats: a + * host that reads them moves its own context-manager to 0.20.1. + */ + getRequestCompileStats(request: object): RenderStats | null { + return dryCompileStatsOf(request); + } private store: JsStore; private ownsStore: boolean; @@ -2764,7 +3024,7 @@ export class AgentFramework { * window message. Announce-on-change only: steady state emits nothing * (KV-safe, no per-turn chatter). In-memory — after a process restart the * first turn's locus is announced once to re-establish the baseline. */ - private lastAnnouncedLocus: Map = new Map(); + private lastAnnouncedLocus: Map = new Map(); // ---- Held prose drafts (src/prose-drafts.ts) --------------------------- /** Every resident's held drafts: plain speech that was suppressed, bounced @@ -2826,6 +3086,10 @@ export class AgentFramework { /** Consecutive bounce-wakes per agent; capped so an agent that keeps * emitting unprefixed prose can't wake-loop (notices still append). */ private proseBounceStreaks: Map = new Map(); + /** Consecutive autoContinue continuations per agent, capped by its + * maxConsecutive. Cleared when a turn starts with a cause of its own + * (processInferenceRequests) or a tool ends a turn (skip_reply). */ + private autoContinueStreaks: Map = new Map(); private pendingAssistantBlocks: Map = new Map(); /** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed * `${agentName}:${streamId}`: an endTurn tool result or a context-budget @@ -2944,7 +3208,7 @@ export class AgentFramework { * retry the receipt answers, which delivers nothing again, commits the one * its first attempt parked. Keyed by server and event id; bounded. */ - private parkedAnchors: Map = new Map(); + private parkedAnchors: Map = new Map(); /** `tuneOut.announce`: the excepted channel hears the all-except scope open and close. */ private tuneOutAnnounce = true; /** `speech.locus`: where prose goes when nothing names a destination (see FrameworkConfig). */ @@ -3006,6 +3270,12 @@ export class AgentFramework { */ private deferredScanFrom = new Map(); private deferredMessages: DeferredWrite[] = []; + /** The marked held writes whose store couldn't be searched, each with the + * failure said, said once until a lookup of them succeeds and again when + * the failure changes (landedCopiesById), and those a drain has said it + * holds what's queued behind (writeDrainedInOrder). */ + private unsearchableSaid = new Map(); + private stillUnknownSaid = new Set(); // Turn-alive markers (2026-07-31 Mythos phantom-skip incident): agentName → // token of the turn currently in progress. Set at startAgentStream ENTRY — @@ -3025,6 +3295,15 @@ export class AgentFramework { // and its settle except that turn's own blocks, and, before its checkpoint, // the inputs its start writes (flushForStart). private activeTurnTokens: Map = new Map(); + /** Each running scheduler pass's requests, which it took from + * pendingRequests: it holds those of the agents it hasn't taken up yet + * while it awaits an earlier agent's start, so a continuation among them + * is still on its way (pendingContinuationAgents). */ + private passRequests: Set> = new Set(); + /** Continuations whose start waits on provider admission behind an + * auxiliary call, in no queue meanwhile. A drop takes one out and gives + * its admission back; the park, woken, then finds it gone. */ + private parkedContinuations: Set = new Set(); /** Each turn whose settle has ended it (settleAgent, endDroppedTurn), for * its agent's scripts: its start's teardown can still hold the agent's * token, and a turn that has ended isn't live (liveTurns). A later start @@ -3072,12 +3351,14 @@ export class AgentFramework { * is still the agent's, so a start that ends after a later frame opened * one, such as its retry's stream waiting to retry, leaves that one open. * An operator's abandon marks the window, and the frame's check collects - * the mark, which goes with the window. abortInference() and the abandon - * count a turn with no stream only here; a stop made anywhere else, as - * while an agent is idle, a turn finishes its last delivery or a provider - * hold has parked it, has no check to end it, and the next start drops - * it. Keyed by the Agent, so a replacement agent under the same name - * never shares one. + * the mark, which goes with the window. A stop of a turn with no stream + * lands here, or, for a turn whose continuation or retry is on its way (a + * provider hold's, a park's, the queue's or a pass's), through + * abortInference(), an abandon or stop(), which drop that continuation or + * retry (dropContinuations); a stop made anywhere else, as while an agent + * is idle or a turn finishes its last delivery, has no check to end it, + * and the next start drops it. Keyed by the Agent, so a replacement agent + * under the same name never shares one. */ private checkAhead: Map = new Map(); @@ -3151,6 +3432,9 @@ export class AgentFramework { /** Per source agent, per kind: how many derivations, since when, and at which count the last note went. */ private derivationTallies: Map> = new Map(); private readonly sharedSlotAgents = new Set(); + /** The batched-wake lines already written (placeWakeLines), so every agent a + * wake was for, and every later start of it, writes each at most once. */ + private readonly placedWakeLines = new WeakSet(); /** Rounds of tool calls, by their results array, that a save_recent_image * in the round counted by their share of the bound: such a round is * stored by its share whether or not it fits (buildStoredToolResultContent). */ @@ -3171,10 +3455,10 @@ export class AgentFramework { private conversationRouter: ConversationRouter | null = null; /** Agent configs by name — fork agents are built from the template's config. */ private agentConfigs: Map = new Map(); - // A fork agent's home channel is its registry record's `homeChannel`. - // Permanent (unlike router bindings, which expire): publish/injection - // scoping must survive unbinding so the closure turn still lands in the - // right channel. + // A fork agent's home channel is its registry record's `homeChannel` + // (homeOf). Permanent (unlike router bindings, which expire): + // publish/injection scoping must survive unbinding so the closure turn + // still lands in the right channel. /** Forks being spawned, by channel id, each until its spawn settles (see * routeConversation). The promise never rejects. */ private readonly conversationSpawns = new Map>(); @@ -3188,9 +3472,24 @@ export class AgentFramework { // config.codeExecution.enabled: its settings, and every agent's scripts, // each in a process of its own on the engine (AgentScripts). Waiters // resolve script-inner tool calls dispatched through the normal - // dispatchToolCall path. + // dispatchToolCall path. A waiter's route is where the dispatch sent its + // call: `toServer` is set when it went to a server's own tool (an MCPL or + // MCP-only peer's), so the script lane's exit treats every other result as + // a module's (scriptResultAnswer), by where the call went rather than by a + // name lookup a connect or disconnect can change while it runs. A + // framework tool that talks to a server itself, such as a channel tool, + // builds its own result, which counts as a module's. A caller of + // dispatchScriptToolCall that passes no route gets one left false, the + // reading that withholds more. private codeExecution: { settings: IsolateSettings; scripts: AgentScripts } | null = null; - private scriptToolWaiters: Map void> = new Map(); + private scriptToolWaiters: Map void; route: { toServer: boolean } }> = new Map(); + /** A script's call being dispatched, by its call id: what its script was + * started with, its words' turn and reading. `turnId` is the turn the + * script was started in, if one was (callingTurnOf); `readThrough` is the + * consumed watermark its agent had when the run call started it, which a + * publish the call makes is credited with (publishReadThrough). Kept only + * while the dispatch runs (dispatchScriptToolCall). */ + private readonly scriptCallStarts = new Map(); /** Per agent, a round that called code_execution and waits for those calls' * results before its prose is routed (ScriptRound). */ private scriptRounds: Map = new Map(); @@ -3369,8 +3668,8 @@ export class AgentFramework { onTrace: (listener) => this.onTrace(listener), // The ONLY entry that confers module origin — a private closure the // framework hands to its own module registry. The public - // executeToolCall() is shared with model/ephemeral callers and always - // stamps agent origin (see there). + // executeToolCall(), whose callers act for an agent (the puppet, a + // host), always stamps agent origin (see there). callTool: (call) => this.executeToolCallFrom(call, { kind: 'module' }), notifyOps: (kind, agentName, message, data) => this.notifyOps(kind, agentName, message, data), }); @@ -3690,15 +3989,20 @@ export class AgentFramework { initialConfig: config.gate.config, privilegedUsersPath: config.gate.privilegedUsersPath, emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }), - addMessage: (p, c, m, forAgent) => framework.addMessage(p, c, m as MessageMetadata, forAgent ? { forAgent } : undefined), - requestInference: (agentName, reason, source, provenance) => { + addMessage: (p, c, m, forAgent, placement) => framework.addMessage(p, c, m as MessageMetadata, + forAgent || placement ? { ...(forAgent ? { forAgent } : {}), ...(placement ? { placement } : {}) } : undefined), + requestInference: (agentName, reason, source, provenance, after) => { framework.pendingRequests.push({ agentName, reason, source, timestamp: Date.now(), + ...framework.readsDeferredFor(after, agentName), // gate-requested wakes carry where/who (EventGate wakeProvenance) // as TELEMETRY fields — the host's stamp reads them. ...(provenance?.channelId ? { wakeChannelId: provenance.channelId } : {}), ...(provenance?.counterparty ? { counterparty: provenance.counterparty } : {}), ...(provenance?.at ? { wakeAt: provenance.at } : {}), + // The batch's line, when the gate left it to the turn's start + // (beginAgentTurn decides it: placeWakeLines). + ...(provenance?.line ? { wakeLines: [provenance.line] } : {}), // A batch that contains an ADDRESSED message (mention / reply / // DM) routes like the direct path does: the reply goes to that // message's channel. Without it the turn froze on the @@ -3712,9 +4016,14 @@ export class AgentFramework { // route applies to a fork only when it IS that home, and never to // another agent when a fork owns that channel. Otherwise a fork // bound elsewhere would show typing where it isn't replying. - ...(provenance?.routeChannelId && framework.mayTakeGateRoute(agentName, provenance.routeChannelId) - ? { channelId: provenance.routeChannelId, addressed: true } - : {}), + ...((): Partial => { + const route = provenance?.routeChannelId !== undefined + ? framework.channelNamed(provenance.routeChannelId, provenance.routeServerId) + : undefined; + return route && framework.mayTakeGateRoute(agentName, route) + ? { channelId: route.channelId, channelServerId: route.serverId, addressed: true } + : {}; + })(), }); }, getAgentNames: () => framework.registry.gateRecipients(), @@ -3724,6 +4033,13 @@ export class AgentFramework { // them to the registered composite id the same way ingestion does // (handleMcplPushEvent registers that channel on arrival, before any // debounce can fire). + // A batched wake's summary names each channel as the agent names it + // back, with its server where another server shares its id. + channelShown: (channel) => framework.channelRegistry?.nameShown(channel) ?? channel.channelId, + // A batched wake for one message still newest in the window hands its + // `[Gate: …]` line to the wake, for a fresh start of the wake's turn + // to decide (placeWakeLines). + isNewestEntry: (entry) => framework.isNewestContextEntry(entry), resolveRouteChannel: (info) => { if (info.eventType === 'mcpl:channel-incoming') return info.channelId || undefined; if (info.eventType === 'mcpl:push-event') { @@ -3933,17 +4249,19 @@ export class AgentFramework { currentSequence: () => framework.store.currentSequence(), setSubconsciousAnchor: (sequence) => framework.subconsciousStrategy?.setAnchor(sequence), - isForkBound: (channelId) => - framework.registry.list().some((record) => record.homeChannel === channelId), + isForkBound: (channel) => + framework.registry.list().some((record) => sameChannel(framework.homeOf(record.name), channel)), isPrivilegedAuthor: (authorId) => framework.eventGate?.isPrivilegedUser(authorId) ?? false, allowChannelSpeech: () => framework.subconsciousConfig?.allowChannelSpeech === true, holdWithoutReader: () => framework.tuneOutOffered, ...(framework.tuneOutAnnounce ? { - announce: async (_serverId: string, channelId: string, text: string) => { + // The excepted channel on its server: a bare id two servers + // register is refused (publishForAgent). + announce: async (serverId: string, channelId: string, text: string) => { const r = await framework.channelRegistry!.publishForAgent( - channelId, text, framework.primaryAgentName ?? 'resident'); + { serverId, channelId }, text, framework.primaryAgentName ?? 'resident'); return { success: r.success, ...(r.error ? { error: r.error } : {}) }; }, } @@ -4309,6 +4627,16 @@ export class AgentFramework { // framework must not keep up to the whole ledger budget referenced. this.toolImageLedgers.clear(); + // A turn whose continuation is still on its way ends with the stop, as + // a turn's start a stop reaches does: the continuation would start only + // after a start(), in a lifecycle where its logical turn is gone + // (openLogical, below). Before the holds and the parks go, so the + // continuation leaves them with its turn. + step(() => { + for (const agentName of this.pendingContinuationAgents()) { + this.dropContinuations(agentName, { kind: 'shutdown', why: 'the framework is stopping' }); + } + }); // A stopped host must never hang behind its own cooldown. step(() => this.cancelProviderAdmission()); // An open logical turn doesn't survive a stop: a start after a restart @@ -4476,7 +4804,7 @@ export class AgentFramework { // them, come before the registry forgets its channels, since a notice // names its channel by the label the registry holds (channelForNotice). // The registry names the conversation forks and their home channels. - for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.registry.homeChannel(r.name) }))) { + for (const { name, home } of this.listAgents().filter((r) => r.kind === 'conversation-fork').map((r) => ({ name: r.name, home: this.homeOf(r.name) }))) { step(() => { if (home !== undefined && this.conversationRouter?.getBinding(home)?.agentName === name) this.conversationRouter.unbind(home); this.disposeConversationAgent(name); @@ -4885,7 +5213,8 @@ export class AgentFramework { return { delayMs: ownMs, ownMs, stated: false }; } /** Whether two requests are one wake, for a provider hold's dedup: the - * same agent, reason, source, time and channel, and the same coalescing + * same agent, reason, source, time and channel, on the same server (two + * servers' channels can share an id), and the same coalescing * marks: a batch wake's subject, a batch-only merge's subjects (it has no * single subject), and the write a wake reads. A wake with any of them * can be withdrawn or dropped on its own, and must not stand for one @@ -4893,9 +5222,25 @@ export class AgentFramework { private sameInferenceRequest(a: InferenceRequest, b: InferenceRequest): boolean { const batchSubjects = (r: InferenceRequest) => (r.coalescingBatchSubjects ?? []).join('\n'); return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source && - a.timestamp === b.timestamp && a.channelId === b.channelId && + a.timestamp === b.timestamp && a.channelId === b.channelId && a.channelServerId === b.channelServerId && a.coalescingSubject === b.coalescingSubject && batchSubjects(a) === batchSubjects(b) && - a.readsDeferredWrite === b.readsDeferredWrite; + a.readsDeferredWrite === b.readsDeferredWrite && + (a.wakeLines ?? []).map(wakeLineKey).join('\n') === (b.wakeLines ?? []).map(wakeLineKey).join('\n'); + } + /** Keep `request` among a hold's `held` requests, once by + * sameInferenceRequest. A held turn's retry (TURN_RETRY) whose equal is + * already held marks that one, so the hold still keeps the turn alive. */ + private holdRequest(held: InferenceRequest[], request: InferenceRequest): void { + const same = held.find((r) => this.sameInferenceRequest(r, request)); + if (!same) { + held.push(request); + return; + } + if (isTurnRetry(request)) (same as MarkedRequest)[TURN_RETRY] = true; + // A merged start's batch (MERGED_BATCH) stays recorded on the request + // kept in its place, which then stands for it, as the start did. + const batch = mergedBatch(request); + if (batch) (same as MarkedRequest)[MERGED_BATCH] = [...new Set([...(mergedBatch(same) ?? []), ...batch])]; } private consultProviderHold(error: Error, agentName: string): { holdMs: number; reason: string } | undefined { if (!this.providerHoldHook) return undefined; @@ -4948,14 +5293,19 @@ export class AgentFramework { // failure streak), and the wait applies to the calls that follow it. if (!acceleration && !hostHold) { return wait !== undefined && isMembraneError(error) && error.retryable === true - && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true); + && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true); } + // As in holdForProviderWait: once stop() has closed admission, nothing is + // held, and nothing started. + if (this.providerAdmissionClosed) return true; const now = Date.now(); const pacing = hostHold ? undefined : this.accelerationCooldownMs(agent.name, error as MembraneError); const delayMs = hostHold ? hostHold.holdMs : pacing!.delayMs; const existing = this.providerAccelerationCooldowns.get(agent.name); const held = existing?.heldRequests ?? []; - if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger))) held.push(trigger); + // The failed turn's own request, kept as its retry (TURN_RETRY). An + // auxiliary call's failure passes no trigger: it holds no turn of its own. + if (trigger) this.holdRequest(held, asTurnRetry(trigger)); if (existing) clearTimeout(existing.timer); const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), Math.min(delayMs, MAX_TIMER_DELAY_MS)); timer.unref?.(); this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now, @@ -4964,7 +5314,8 @@ export class AgentFramework { // The stated wait (recorded above for agent.model) set this hold's // length: an operator release of it returns the hold to AF's own pacing. ...(pacing?.stated ? { statedWait: { model: agent.model, ownUntil: now + pacing.ownMs } } : {}), - ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}) }); + ...(auxiliary && (!existing || existing.auxiliaryOrigin) ? { auxiliaryOrigin: true } : {}), + ...(existing?.emptiedByDrop && held.length === 0 ? { emptiedByDrop: true } : {}) }); if (auxiliary && !existing) { console.error(`[provider-cooldown] agent=${agent.name} host hold (${hostHold!.reason}) from an auxiliary call — ` + `holding primary/auxiliary for ${delayMs}ms`); @@ -5003,7 +5354,7 @@ export class AgentFramework { if (!isMembraneError(error) || typeof error.retryAfterMs !== 'number') return undefined; if (this.registry.homeChannel(agent.name) !== undefined) { const wait = this.recordProviderWait(agent.name, agent.model, error); - return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [], true) + return error.retryable === true && wait !== undefined && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], true) ? 'held' : undefined; } @@ -5071,9 +5422,12 @@ export class AgentFramework { ? `no longer holds primary turns: the primary model is now ${model} (the wait stays recorded)` : `primary hold ended after ${Date.now() - cooldown.startedAt}ms`)); } - if (cooldown.auxiliaryOrigin && cooldown.heldRequests.length === 0) { - // Nobody asked for an inference while parked: just reopen the gate. - console.error(`[provider-cooldown] agent=${agentName} auxiliary hold released after ${Date.now() - cooldown.startedAt}ms`); + if ((cooldown.auxiliaryOrigin || cooldown.emptiedByDrop) && cooldown.heldRequests.length === 0) { + // Nobody asked for an inference while parked, or what was held was a + // continuation since dropped with its turn: just reopen the gate. A + // primary the hold left pending won't run, so its recovery ends. + console.error(`[provider-cooldown] agent=${agentName} ${cooldown.auxiliaryOrigin ? 'auxiliary hold' : 'hold, its held continuation dropped,'} released after ${Date.now() - cooldown.startedAt}ms`); + if (cooldown.emptiedByDrop) this.endPendingPrimary(agentName); this.flushAuxiliaryAdmission(agentName); return; } @@ -5150,17 +5504,38 @@ export class AgentFramework { * Hold this agent's primary turns for a provider wait on its model. The * requests are kept and released together, into one fresh compile, when * the wait ends: the same path an acceleration cooldown takes, without - * parking auxiliary calls to other models. + * parking auxiliary calls to other models. `report`, once the wait is + * found to bind, runs before anything is held: a caller that reports its + * turn's end there (inference:failed) does so before the hold takes the + * turn's retry, as every failure site does. */ - private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false): boolean { + private holdForProviderWait(agent: Agent, wait: ProviderWait, requests: InferenceRequest[], failed = false, report?: () => void): boolean { const now = Date.now(); const until = wait.until ?? Number.POSITIVE_INFINITY; if (until <= now) return false; + report?.(); + // stop() has cleared every hold and dropped every turn on its way: a + // failure or start reaching here after it holds nothing, so no retry + // outlives the stop as a turn still alive. Nothing is started either. + if (this.providerAdmissionClosed) return true; + // A listener of the failure reported here (`report`) may have ended the + // agent (stopAgent): its end dropped what of its turn was on its way, + // and nothing is held for an agent that's gone, so no retry outlives it + // as a turn still alive. (A caller that reports a failure before calling + // here checks the agent first: nonOwningProviderWait holds only for an + // agent whose registry record is still live, and the scheduler and the + // last boundary pass this.agents' own Agent.) + if (this.agents.get(agent.name) !== agent) return true; + // A listener of the report may have released the wait + // (releaseProviderWait) before anything was held: nothing binds now, so + // the requests start, as the release of an installed hold starts them. + if (report && !this.providerWaits?.active(agent.name, wait.model)) { + this.pendingRequests.push(...requests); + return true; + } const existing = this.providerAccelerationCooldowns.get(agent.name); const held = existing?.heldRequests ?? []; - for (const request of requests) { - if (!held.some((r) => this.sameInferenceRequest(r, request))) held.push(request); - } + for (const request of requests) this.holdRequest(held, request); // An acceleration or host hold already parks the whole agent; its release // re-checks this wait at admission. if (existing && existing.waitModel === undefined) return true; @@ -5738,6 +6113,16 @@ export class AgentFramework { } this.resetCompletedStop('addModule()'); await this.moduleRegistry.addModule(module); + // The workspace judges whether an agent's history holds a message as the + // framework judges its own notices, for its head-move notice + // (noticeHeadMoves) and the writes it weighs against it. A module taken + // for the workspace by its writeBinary (getWorkspaceModule) may have no + // such call, as a host's own or a test's stand-in hasn't. + const workspace = this.getWorkspaceModule(); + if (workspace === module && typeof workspace.useHistory === 'function') { + workspace.useHistory((agentName, messageId) => + this.agents.get(agentName)?.getContextManager().getMessage(messageId as MessageId) != null); + } this.emitTrace({ type: 'module:added', moduleName: module.name }); } @@ -5773,9 +6158,11 @@ export class AgentFramework { /** * Abort an in-flight inference for an agent. True when there was one to - * stop: a stream, or a turn with no stream and one of its checks ahead (its + * stop: a stream; a turn with no stream and one of its checks ahead (its * hooks or compile, a wait to retry, the tool-result guard's wait), which - * ends at that check, before it sends anything more. + * ends at that check, before it sends anything more; or a turn whose + * continuation or retry is on its way (queued, kept by a provider hold, + * parked at admission or in a scheduler pass), which ends here. */ abortInference(agentName: string, reason?: string): boolean { const agent = this.agents.get(agentName); @@ -5805,6 +6192,20 @@ export class AgentFramework { checkWindow.wake?.(); return true; } + // A turn whose continuation or retry is on its way (a budget restart or + // a guard retry, or a failed turn's retry a provider hold keeps: + // pendingContinuationAgents) ends here, as a stop of a turn's start ends + // one, whether or not the frame that handed it on has let go of its + // token yet (that frame ends on its own and settles nothing more): the + // continuation or retry is dropped, with one inference:aborted carrying + // the stop's reason. Wakes that arrived meanwhile aren't the turn's, and + // still run; a hold of such wakes alone keeps no turn, and has nothing to + // stop. Every failure site reports inference:failed before a hold takes + // the retry, so a listener of it finds nothing held, and reports false. + if (!result && this.pendingContinuationAgents().has(agentName)) { + this.dropContinuations(agentName, { kind: 'caller', reason, why: 'a stop ended the turn' }); + return true; + } return !!result; } @@ -6107,7 +6508,7 @@ export class AgentFramework { } const mount = this.firstWritableMountName(workspace); if (!mount) { - console.error(`[refs] couldn't store ${fileName}: no writable workspace mount without an include`); + console.error(`[refs] couldn't store ${fileName}: ${this.noFileMountText()}`); return null; } const path = `${mount}/refs/${fileName}`; @@ -6766,16 +7167,77 @@ export class AgentFramework { return n; } + /** + * A continuation the framework makes for a turn (a budget restart's, which + * it queues, or a tool-result guard's retry, which its frame starts at + * once) holds no turn token until its start takes one. Once its turn's + * frame has let go of its token, its start can wait in the queue, a + * provider hold, a park on admission or a scheduler pass's hands. Its turn + * is alive meanwhile. So is a turn whose call failed into a provider hold, + * which keeps the turn's retry (TURN_RETRY), until that retry starts, + * wherever it waits. These are the agents with one on its way. A + * `tool_results_ready` continuation follows a tool round left waiting with + * no stream, which only the direct Agent API makes (Agent's + * runInferenceWithInjections; the framework's own rounds wait with their + * stream, enterWaitingForTools). Its caller owns that turn's timing, so it + * isn't counted. + */ + private pendingContinuationAgents(): Set { + const agents = new Set(); + const add = (r: InferenceRequest): void => { if (continuesTurn(r)) agents.add(r.agentName); }; + // Read defensively: a framework built from the prototype, as the + // operator tests build one, sets none of these. + for (const r of this.pendingRequests ?? []) add(r); + for (const cooldown of this.providerAccelerationCooldowns?.values() ?? []) for (const r of cooldown.heldRequests) add(r); + for (const r of this.parkedContinuations ?? []) add(r); + for (const pass of this.passRequests ?? []) for (const requests of pass.values()) for (const r of requests) add(r); + return agents; + } + + /** The agents with a turn alive: holding a turn token, or with a + * continuation of their turn, or its retry, on its way. */ + private turnHolders(): Set { + const holders = this.pendingContinuationAgents(); + for (const name of this.activeTurnTokens.keys()) holders.add(name); + return holders; + } + + private hasTurn(agentName: string): boolean { + return this.activeTurnTokens.has(agentName) || this.pendingContinuationAgents().has(agentName); + } + + /** The provider hold keeping `agentName`'s turn for its retry (TURN_RETRY), + * in words naming its end and its reason, and whether a stated wait holds + * it; undefined when no hold keeps one. */ + private heldTurnRetry(agentName: string): { says: string; statedWait: boolean } | undefined { + // Optional, as pendingContinuationAgents reads it: a framework built from + // its prototype, as some suites build one, has no map of holds. + const hold = this.providerAccelerationCooldowns?.get(agentName); + if (!hold?.heldRequests.some(isTurnRetry)) return undefined; + // A deadline past what a Date can name (an infinite wait, or a stated + // one too long for an instant) is one only a release ends. + const end = new Date(hold.until); + const until = Number.isNaN(end.getTime()) ? 'until released' : `until ${end.toISOString()}`; + return { + says: `a provider hold keeps its turn to start again, ${until}: ${hold.reason}`, + // release-provider-wait lifts a stated wait's hold, not a host's or an + // acceleration cooldown's own pacing. + statedWait: hold.waitModel !== undefined || hold.statedWait !== undefined, + }; + } + getHostModeStatus(): HostModeStatus { const parkedAdmissions = this.parkedAdmissionCount(); + const holders = this.turnHolders(); return { quiesced: this.quiesced, ...(this.quiesceReason ? { reason: this.quiesceReason } : {}), ...(this.quiescedAt !== undefined ? { since: this.quiescedAt } : {}), - drained: this.activeTurnTokens.size === 0 && parkedAdmissions === 0, - activeTurns: this.activeTurnTokens.size, + drained: holders.size === 0 && parkedAdmissions === 0, + activeTurns: holders.size, + activeTurnAgents: [...holders].sort(), parkedAdmissions, - gatedRequests: this.pendingRequests.filter((r) => !isTurnContinuation(r.reason)).length, + gatedRequests: this.pendingRequests.filter((r) => !passesQuiesce(r)).length, backgroundScripts: this.codeExecution?.scripts.leftRunning() ?? 0, deferredWrites: this.deferredMessages.length, ...(this.lastUnabandonable.length > 0 ? { unabandonable: [...this.lastUnabandonable] } : {}), @@ -6792,8 +7254,10 @@ export class AgentFramework { /** * Enter quiesce mode: write the deferred-write queue, persist the flag, * suppress gate deliveries, pause all MCPL data planes, and wait for - * in-flight turns to settle (turn-alive is `activeTurnTokens`, which spans - * dequeue → settled teardown). Idempotent, but for the queue's write, which + * in-flight turns to settle (turn-alive is a turn token, which spans + * dequeue → settled teardown, a continuation of the turn on its way to its + * start, or the turn's retry, which a provider hold keeps or which waits + * to start: turnHolders). Idempotent, but for the queue's write, which * every call makes first: when the queue can't reach disk, because its * write fails or it's over the cap, the call rejects with a * QuiesceBlockedError and the host's mode as it was, unless it has @@ -6803,7 +7267,10 @@ export class AgentFramework { * On drain timeout the host STAYS quiesced (`drained: false` in the * result); with `abandon: true` the undrained streams are cancelled via a * dedicated cancel kind that settles the turn without feeding the - * inference-failure accounting. + * inference-failure accounting, and a turn whose continuation or retry + * hasn't started ends with it dropped. Drain covers the framework's + * own turns: a caller of the direct Agent API (`Agent.runInference`) owns + * its turns' timing. */ async quiesce(opts?: { reason?: string; @@ -6829,13 +7296,13 @@ export class AgentFramework { // out undrained must be escalatable with a second quiesce({abandon}) // without resume()+re-quiesce (which would reopen data planes and // release parked wakes mid-surgery). - if (opts?.abandon && this.activeTurnTokens.size > 0) { + if (opts?.abandon && this.turnHolders().size > 0) { const unabandonable = await this.abandonActiveTurns(); this.emitTrace({ type: 'host:quiesce', ...(this.quiesceReason ? { reason: this.quiesceReason } : {}), - drained: this.activeTurnTokens.size === 0, - activeTurns: this.activeTurnTokens.size, + drained: this.turnHolders().size === 0, + activeTurns: this.turnHolders().size, abandoned: true, ...(unabandonable.length > 0 ? { unabandonable } : {}), }); @@ -6861,7 +7328,7 @@ export class AgentFramework { this.lastUnabandonable = []; console.error( `[host-mode] quiescing${opts?.reason ? ` (${opts.reason})` : ''}: parking wakes, ` + - `pausing MCPL data planes, draining ${this.activeTurnTokens.size} in-flight turn(s)`, + `pausing MCPL data planes, draining ${this.turnHolders().size} in-flight turn(s)`, ); this.eventGate?.setQuiesced(true); for (const connection of this.mcplServerRegistry?.getAllServers() ?? []) { @@ -6887,7 +7354,7 @@ export class AgentFramework { while ( this.quiesced && !this.queue?.isClosed && - (this.activeTurnTokens.size > 0 || this.parkedAdmissionCount() > 0) && + (this.turnHolders().size > 0 || this.parkedAdmissionCount() > 0) && Date.now() < deadline ) { await new Promise((resolve) => setTimeout(resolve, 250)); @@ -6911,7 +7378,7 @@ export class AgentFramework { let abandoned = false; let unabandonable: string[] = []; - if (this.activeTurnTokens.size > 0 && opts?.abandon) { + if (this.turnHolders().size > 0 && opts?.abandon) { abandoned = true; unabandonable = await this.abandonActiveTurns(); if (!this.quiesced) return this.getHostModeStatus(); @@ -6944,7 +7411,18 @@ export class AgentFramework { * the already-quiesced escalation path. */ private async abandonActiveTurns(): Promise { const unabandonable: string[] = []; + // A turn whose continuation is still on its way ends with the + // continuation dropped. Its stream has ended; a frame still letting go of + // its token ends on its own, and settles nothing, since it handed the turn + // to the continuation. + const handedOff = new Set(); + for (const agentName of this.pendingContinuationAgents()) { + console.error(`[host-mode] abandoning ${agentName}'s turn before its continuation or held retry starts: it is dropped`); + this.dropContinuations(agentName, { kind: 'quiesce_abandoned', why: 'its turn was abandoned' }); + handedOff.add(agentName); + } for (const agentName of [...this.activeTurnTokens.keys()]) { + if (handedOff.has(agentName)) continue; const agent = this.agents.get(agentName); const state = agent?.state; const stream = state && 'stream' in state ? state.stream : undefined; @@ -7913,6 +8391,9 @@ export class AgentFramework { * * Pass `{ injections: true }` to opt into full-fidelity gathering and accept * those side effects (byte-faithful to a real activation's injected context). + * + * What the dry compile selected, by kind, is `getRequestCompileStats` of the + * request this returns. */ async previewActivation( agentName: string, @@ -7964,7 +8445,7 @@ export class AgentFramework { const hookParams = this.buildBeforeInferenceParams(agent); const hookInjections = this.scopeInjectionsForAgent( agentName, - await this.hookOrchestrator.beforeInference(hookParams), + await this.hookOrchestrator.beforeInference(hookParams, (serverId) => this.channelsForServer(agentName, serverId)), ); if (hookInjections.length > 0) { injections = injections ? [...injections, ...hookInjections] : hookInjections; @@ -8171,15 +8652,28 @@ export class AgentFramework { /** * Add or replace (upsert) a gate policy at runtime. Validated and persisted - * to gate.json; hot-applied in memory. Throws if no gate is configured or the - * policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING - * (including addressed-message wake rules — see EventGate.lintShadows); - * `{before: name}` / `{after: name}` anchor it against a specific rule, - * which is usually what a sampler or debounce actually wants. + * to gate.json; hot-applied in memory. Placement: `'prepend'` puts a rule + * ahead of EVERYTHING (including addressed-message wake rules — see + * EventGate.lintShadows); `{before: name}` / `{after: name}` anchor it + * against a specific rule, which is usually what a sampler or debounce + * actually wants. Every rule the change doesn't touch is written back as it + * was written. Over a gate.json the gate doesn't use, the change is written + * only if gate.json then loads, as when it replaces or removes the rule that + * keeps it out. Fields the gate reads nowhere, and that aren't a slip of one + * it does, are left out, and named to `onIgnoredFields`, called once the + * change is made; a throw from it is logged, not thrown. Throws if no gate is + * configured, the policy is invalid, the placement can't be made, gate.json + * has no rule list a change could edit, it isn't used and the change doesn't + * mend it, or it can't be read, isn't JSON or can't be written + * (EventGate.addPolicy). A refusal leaves gate.json as it is; a write that + * fails after its open may leave it part-written, and its error says so. */ addGatePolicy( rawPolicy: unknown, - options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } }, + options?: { + position?: 'append' | 'prepend' | { before: string } | { after: string }; + onIgnoredFields?: (notes: string[]) => void; + }, ): import('./gate/types.js').GatePolicy { if (!this.eventGate) { throw new Error('No EventGate configured (FrameworkConfig.gate is unset).'); @@ -8189,7 +8683,10 @@ export class AgentFramework { /** * Remove a gate policy by name at runtime. Returns false if it didn't exist. - * Throws if no gate is configured. + * Throws if no gate is configured, gate.json has no rule list a change could + * edit, it isn't used and the removal doesn't mend it, or it can't be read, + * isn't JSON or can't be written (EventGate.removePolicy); the rules in + * force then stay as they were. */ removeGatePolicy(name: string): boolean { if (!this.eventGate) { @@ -9538,9 +10035,10 @@ export class AgentFramework { ? 'reading your context up to this point' : 'with a copy of your context'; const route = record.relationships.resultTo; + const home = this.channelOfHome(record.homeChannel); const result = !route - ? record.homeChannel - ? `it speaks for itself in ${record.homeChannel}` + ? home + ? `it speaks for itself in ${this.channelShown(home)}` : 'it returns nothing to you' : route.to === from ? `its result returns to you as ${route.as === 'message' ? 'a message' : 'a tool result'}` @@ -9581,7 +10079,7 @@ export class AgentFramework { } const agent = this.agents.get(name); if (!agent) return; - this.releaseAgentRuntime(agent, record.homeChannel); + this.releaseAgentRuntime(agent, this.channelOfHome(record.homeChannel)); } /** @@ -9593,12 +10091,14 @@ export class AgentFramework { * never read reached no one). The window release delivers what the agent * never read to the primary, now or after a resume, as the idle reap did. */ - private releaseAgentRuntime(agent: Agent, homeChannel?: string): void { + private releaseAgentRuntime(agent: Agent, home?: ChannelRef, why = 'its agent ended'): void { const name = agent.name; - if (homeChannel && this.conversationRouter?.getBinding(homeChannel)?.agentName === name) { - this.conversationRouter.unbind(homeChannel); + if (home && this.conversationRouter?.getBinding(home)?.agentName === name) { + this.conversationRouter.unbind(home); } this.closingConversationAgents.delete(name); + // A continuation of its turn still on its way goes with it. + this.dropContinuations(name, { kind: 'dropped', why }); // A batch its guard still holds is settled as `aborted`: the stream's // own finally does neither once the agent has gone. if (agent.toolResultGuard.hasPending) { @@ -10205,6 +10705,9 @@ export class AgentFramework { this.activeTurnTokens.delete(agent.name); this.activeTurnTriggers.delete(agent.name); this.logicalTurnToolCalls.delete(agent); + // A continuation of its turn still on its way goes too: the run, and + // with it the turn, has ended above. + this.dropContinuations(agent.name, null); if (run.endReason === 'host-stop') { // Cut off by stop(): left live for the next boot to end as interrupted. const record = this.registry.inspect(agent.name); @@ -10264,7 +10767,7 @@ export class AgentFramework { type: 'object', properties: { channelId: { type: 'string', description: 'The channel to tune out — or, with scope "all-except", the one channel to keep.' }, - serverId: { type: 'string', description: 'Required only when the channel id exists on several servers.' }, + serverId: { type: 'string', description: 'The channel\'s server, where the channel id exists on several servers; channelId also takes its server-qualified id ("serverId:channelId").' }, mode: { type: 'string', enum: ['enter', 'cancel', 'check'], description: 'Default: enter.' }, scope: { type: 'string', enum: ['channel', 'all-except'], description: 'Default: channel.' }, peek: { type: 'string', description: 'mode "check": a held channel whose newest held lines to show (nothing is released).' }, @@ -10377,7 +10880,7 @@ export class AgentFramework { '• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' + '`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' + '• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' + - 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, behavior: { debounce: 60000 } }` ' + + 'e.g. `{ name: "watch-cairn", match: { source: "discord", channel: "discord:*:12345", tagsAny: ["chat:ambient"] }, debounceMs: 60000 }` ' + '(the channel must be subscribed for ambient events to arrive — see channel mode).', inputSchema: { type: 'object', @@ -10479,8 +10982,9 @@ export class AgentFramework { name: 'wake_remove_rule', description: 'Remove a wake rule (gate.json policy) by name at runtime. Any pending ' + - 'debounce batch for that rule is delivered first. Returns whether a rule ' + - 'was removed (false if no rule had that name).', + 'debounce batch for that rule is still delivered, held while your turn runs, as ' + + 'its timer would deliver it. Returns whether a rule was removed (false if no ' + + 'rule had that name).', inputSchema: { type: 'object', properties: { @@ -10726,25 +11230,24 @@ export class AgentFramework { // The refused agent's own view of the latest inbound message: an // ephemeral run has none (takesGlobalFallback), so a subagent's refusal // marks no one else's channel. - const incoming = this.channelRegistry?.buildChannelContext(agentName)?.incoming; + const incoming = this.channelRegistry?.incomingFor(agentName); if (!incoming) return; - // incoming.channelId is the MCPL composite id ("discord::"); + // The channel id is the MCPL composite id ("discord::"); // the reaction tool wants the raw Discord channel (or thread) id — the // last segment. - const parts = incoming.channelId.split(':'); + const parts = incoming.channel.channelId.split(':'); if (parts[0] !== 'discord') return; const channelId = parts[parts.length - 1]; // Own names only: an inherited one such as 'constructor' takes the fallback. const emoji = Object.hasOwn(REFUSAL_REACTIONS, category) ? REFUSAL_REACTIONS[category] : REFUSAL_REACTION_FALLBACK; - // Resolve the MCPL server that owns the locus channel and call - // tools/call directly on its connection (bare tool name — no prefix - // games), bypassing the agent event queue so no synthetic tool-result - // is injected into a turn the agent never took. - const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId); - const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null; + // Call tools/call directly on the connection of the server whose + // channel the message came in on (bare tool name — no prefix games), + // bypassing the agent event queue so no synthetic tool-result is + // injected into a turn the agent never took. + const server = this.mcplServerRegistry?.getServer(incoming.channel.serverId); if (!server) { console.error( - `[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`, + `[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channel.channelId)} on server ${logValue(incoming.channel.serverId)} (agent=${agentName})`, ); return; } @@ -11807,7 +12310,8 @@ export class AgentFramework { * by stop() while it runs. * * The branch moves under every agent sharing the store, so the switch - * reserves the store as a surgery does: every agent must be idle, or it is + * reserves the store as a surgery does: every agent must be idle with no + * turn alive (no continuation or held retry of one on its way), or it is * refused (OperatorActionError 'agent-busy'), and no turn starts until the * switch is done. */ @@ -12059,9 +12563,21 @@ export class AgentFramework { ); } const busy: string[] = []; + let heldTurns = false; + let statedWaits = false; for (const [name, a] of this.agents) { - if (a.state.status !== 'idle') busy.push(`${name} is ${a.state.status}`); - else if (this.activeTurnTokens.has(name)) busy.push(`${name} is idle+turn-alive`); + if (a.state.status !== 'idle') { busy.push(`${name} is ${a.state.status}`); continue; } + if (!this.hasTurn(name)) continue; + // A provider hold keeping the turn's retry is named: a quiesce alone + // doesn't end that turn. + const held = this.heldTurnRetry(name); + if (held) { + heldTurns = true; + if (held.statedWait) statedWaits = true; + busy.push(`${name} is idle+turn-alive (${held.says})`); + } else { + busy.push(`${name} is idle+turn-alive`); + } } for (const name of this.activeTurnTokens.keys()) { if (!this.agents.has(name)) busy.push(`${name} is turn-alive`); @@ -12069,7 +12585,8 @@ export class AgentFramework { if (busy.length > 0) { throw new OperatorActionError( 'agent-busy', - `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)`, + `Cannot ${verb} while ${busy.join(', ')} — every agent sharing the store must be idle (quiesce the host first)` + + (heldTurns ? `; ${heldTurnWayOn(statedWaits)}` : ''), ); } const reserved = new Map(); @@ -13003,10 +13520,11 @@ export class AgentFramework { // Direct inference requests (e.g. runEphemeralToCompletion) bypass the // event queue — without this the loop can exit before they're drained. // While quiesced, gate-parked requests are NOT progress and must not - // block idle (only a budget restart can still run); identity-equivalent - // to `.length > 0` when not quiesced. + // block idle; what a quiesce lets through (passesQuiesce: a turn's + // continuation, or a held turn's retry) can still run, and is. + // Identity-equivalent to `.length > 0` when not quiesced. this.pendingRequests.some( - (r) => !this.quiesced || isTurnContinuation(r.reason), + (r) => !this.quiesced || passesQuiesce(r), ) || this.activeStreams.size > 0 || Array.from(this.agents.values()).some((a) => a.state.status !== 'idle') @@ -13381,7 +13899,7 @@ export class AgentFramework { // was killed while the call ran: the result reaches no one, but the // call did finish. this.toolLifecycleEmitter?.onResult(event.agentName, event.callId, event.result); - scriptWaiter(event.result); + scriptWaiter.resolve(event.result); return; } @@ -13550,12 +14068,22 @@ export class AgentFramework { // safe), up to what came after them: a later message can still // take an earlier one past an image limit, as for any message, and // the next compile then strips that image where the live rounds - // carried it. One the next compile won't send among its newest - // isn't injected; a notice after the batch tells the agent of it - // (recordLeftOutNotice). A write that threw after it was stored, whose landing - // couldn't be read, is the exception: it's carried at the flush - // that finds it, after what was stored since, so there the store and - // the live request differ (writeDrainedInOrder). + // carried it. One the render gives null goes in as stored (see the + // boundary's null below). A write that threw after it was stored, + // whose readback can't say whether it landed, is looked up by its + // id here, and carried here, where it landed. One that can be + // neither read back nor looked up is the exception, a known limit: + // it waits, marked, and the flush that later finds it counts it + // where it was stored, ahead of what was stored since without it. + // There the store and the live request differ, and a reply stored + // after it whose request didn't carry it was made without it. On + // an account where the provider enforces that binding, a later + // request replaying that reply's signed thinking is refused with a + // 400 (context-manager #155). It's injected at the boundary that + // finds it, after what was stored since (writeDrainedInOrder). A + // recompile before that boundary, such as a retry, has already + // carried it where it was stored, so it's carried twice there, as + // in 0.26.0, and a reply made after it meets the same refusal. // // The queue is drained ONLY at the target agent's own boundary: // draining at another agent's boundary would store the messages @@ -13591,9 +14119,9 @@ export class AgentFramework { // rounds the stream never showed it. const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = []; // What this boundary stored for the agent to hear, in store order, - // injected or left out, for a boundary that leaves it to the turn's - // next start as arrivals: that start's compile sends it. - const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[]; parts: number }> = []; + // for a boundary that leaves it to the turn's next start as + // arrivals: that start's compile sends it. + const heard: Array<{ msg: DeferredWrite; id: MessageId; content: ContentBlock[] }> = []; if (currentState.supportsInjectedMessages !== false) { // A clock row due (storeDueClock) goes right after the round's // results, ahead of what is drained below, and joins the stream @@ -13613,7 +14141,7 @@ export class AgentFramework { // counts as stored, as what landed of it (writeDrainedInOrder). const stored = this.writeDrainedInOrder(deferred, `tool boundary for ${agent.name}`, (msg) => agent.getContextManager().addMessage(msg.participant, msg.content, withDeferredWriteId(msg.metadata, msg.id))); - for (const [msg, { id, content, parts }] of stored) { + for (const [msg, { id, content }] of stored) { this.releaseLandedMail(msg.metadata, agent.name); // Every other resident reads what this boundary stores; for its // own agent, the boundary weighs what it injects below. @@ -13623,45 +14151,51 @@ export class AgentFramework { // here would put in the live turn what the next compile hides // (#232 gap 5). if (heldFromResidents(msg.metadata as Record | undefined)) continue; - heard.push({ msg, id, content, parts }); + heard.push({ msg, id, content }); } // Rendered in one call: a render prepares as a compile does, so // the batch shares one preparation. The image limits count from - // the batch's oldest message to the newest, as a compile does. + // the batch's oldest message the render places to the newest, as + // a compile does. // The clock, stored ahead of them, renders first in the same batch // and is injected first, as the next compile sends it. const rendered = this.renderForInjection(agent, [...(clock ? [clock.id] : []), ...heard.map((h) => h.id)]); const first = clock ? 1 : 0; - if (clock) { - const sent = rendered ? rendered[0]! : clock.content; - if (sent !== null) { - midTurnInjections.push({ participant: 'user', content: sent, metadata: clock.metadata }); - } else { - console.error(`[clock] ${agent.name}: clock ${clock.id} left out of the live round, since the next compile won't send it among its newest`); - } - } - const leftOut: typeof heard = []; + // A null is a message the render places before its window. Under + // the memory strategy that's one before its recent window, when + // what this boundary stored is bigger than it, or any message + // while a head window a topic change reset hasn't filled yet. The + // next compile still sends it, raw and in order, after the tool + // result, so leaving it out would change the prefix as surely as + // any form, and the agent wouldn't hear it (context-manager#275's + // review, R1-1). It goes in as stored, which is the form the next + // compile sends with two exceptions. The render doesn't count a + // null message's images or cut it, so a batch with more images + // than the limit, or a long message under a cap that doesn't + // shard, still differs where an image limit or the per-message + // cap reaches it. And a body the strategy stored in parts goes in + // whole, joined as the store reads it back, where the next + // compile sends it in more than one piece. + // That rests on two things. The only view filter this framework + // gives a store is the tune-out stamp (createAgent's; the other + // stores it opens have none), and a stamped message is skipped + // above, so no null here means a message the view hides. And the + // store and the render run with nothing between them, so nothing + // can put a summary in place of what this flush stored, or move + // windowed passthrough's anchor past it, before the render. A + // write found here that landed at an earlier flush + // (writeDrainedInOrder) is the exception: by now a summary may + // stand in for it, or the anchor be past it, and then it goes in + // where the next compile doesn't send it. + const asSent = (i: number, content: ContentBlock[]): ContentBlock[] => rendered?.[i] ?? content; + if (clock) midTurnInjections.push({ participant: 'user', content: asSent(0, clock.content), metadata: clock.metadata }); heard.forEach((h, i) => { - const sent = rendered ? rendered[i + first]! : h.content; - // The next compile won't send it among its newest: injected, it - // would put in the live turn what that compile sends elsewhere, - // or not at all. It's under any consumed mark the boundary sets - // (below), as anything stored here is. - if (sent === null) { leftOut.push(h); return; } midTurnInjections.push({ participant: h.msg.participant, - content: sent, + content: asSent(i + first, h.content), ...(h.msg.metadata ? { metadata: h.msg.metadata } : {}), }); }); - if (leftOut.length > 0) { - console.error(`[injection] ${agent.name}: left out of the live round, since the next compile won't send them among its newest: ${leftOut.map((h) => h.id).join(', ')}`); - // The agent is told what it didn't hear, after the batch, as - // the routing notice is: stored, so the next compile sends it - // where the live round heard it. - const notice = this.recordLeftOutNotice(agent, leftOut); - if (notice) midTurnInjections.push(notice); - } } // Deferred writes handed to the store above: sync, then drop them @@ -13753,7 +14287,11 @@ export class AgentFramework { if (!isConversationalInjection(inj.metadata)) continue; if (typeof m?.channelId !== 'string') continue; if (!isAddressedMessage(m.tags as string[] | undefined)) continue; - const notice = this.addressedElsewhereNotice(agent, routing, m.channelId, authorName(m)); + // Its channel as a re-pin reads it (repinChannel): on the + // message's own server. + const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined); + if (!channel) continue; + const notice = this.addressedElsewhereNotice(agent, routing, channel, authorName(m)); if (notice) midTurnInjections.push(notice); } } @@ -13809,7 +14347,7 @@ export class AgentFramework { // the pin. // The last injection that qualifies wins (repinChannel). const routing = this.routingOf(agent.name); - let newLocus: string | undefined; + let newLocus: ChannelRef | undefined; for (const inj of midTurnInjections) newLocus = this.repinChannel(inj.metadata, routing) ?? newLocus; if (newLocus) { // What this boundary just stored is newer than any arrival the @@ -13822,8 +14360,7 @@ export class AgentFramework { // A boundary that can't re-pin now (over budget, or no live // stream to tell) leaves what it stored for the agent to the // turn's next start, as arrivals: a budget restart, or a - // re-inference on these tool results. That includes a message - // left out of the injection, since that start compiles anew. + // re-inference on these tool results. for (const { msg, id } of heard) this.noteArrival(agent.name, msg.metadata, id); } @@ -13849,7 +14386,25 @@ export class AgentFramework { this.streamBoundaryEnds.get(currentState.stream)?.('turn_ended'); } this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name }); + } else if ((overBudget || overPhysical) && this.providerAdmissionClosed) { + // A stopping framework starts no continuation: the turn ends with + // its stream, as stop() ends a live stream's turn. + if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'shutdown'); + agent.cancelStream(); } else if (overBudget || overPhysical) { + // The continuation is queued, and the stream cancelled, before the + // boundary's usage report and the restart's trace, so a listener + // that stops or quiesces the host on either finds the turn waiting + // for its continuation, with nothing left of the stream to end. + const continuation: InferenceRequest = { + agentName: agent.name, + reason: 'context_budget_restart', + source: 'framework', + timestamp: Date.now(), + suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse, + silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat, + }; + this.pendingRequests.push(continuation); // Context budget exceeded: break the stream, let compile() compress. // Mark the cancel as framework-initiated BEFORE cancelling: the // membrane delivers `aborted` before the restart bumps streamId, @@ -13857,13 +14412,19 @@ export class AgentFramework { // a terminal failure — rejecting an ephemeral's promise mid-run // and emitting a spurious inference:exhausted (same race shape as // endTurn above). - if (currentState.stream) { - this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart'); - // Its rounds were billed; the restart opens a fresh stream - // with its own usage. - this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window'); + if (currentState.stream) this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart'); + try { + agent.cancelStream(); + } catch (error) { + // The cancel failed and the stream is still live, so the turn + // has no continuation: take it back out of the queue. + this.pendingRequests = this.pendingRequests.filter((r) => r !== continuation); + throw error; + } finally { + // Its rounds were billed, even when the cancel throws; the + // restart opens a fresh stream with its own usage. + if (currentState.stream) this.streamBoundaryEnds.get(currentState.stream)?.(overBudget ? 'context_budget' : 'physical_window'); } - agent.cancelStream(); this.emitTrace({ type: 'inference:stream_restarted', agentName: agent.name, @@ -13874,14 +14435,6 @@ export class AgentFramework { inputTokens: overBudget ? agent.lastStreamInputTokens : projectedRealTokens, budget: overBudget ? agent.maxStreamTokens : (agent.physicalWindowTokens ?? 0), }); - this.pendingRequests.push({ - agentName: agent.name, - reason: 'context_budget_restart', - source: 'framework', - timestamp: Date.now(), - suppressProse: this.activeTurnTriggers.get(agent.name)?.suppressProse, - silentHeartbeat: this.activeTurnTriggers.get(agent.name)?.silentHeartbeat, - }); } else if (currentState.stream) { // What this round appends is in the next call's prompt. If that // call reports no prompt, the next boundary projects it from the @@ -13930,18 +14483,6 @@ export class AgentFramework { // Under context-manager#160 such a write already gives the // replies stored after it stamps their requests didn't carry, // whatever the mark says; deferring those writers is the fix. - // The mark also covers a message the boundary stored and left out - // of the injection, since the next compile won't send it among its - // newest: the notice telling the agent of it is injected, so a - // boundary that leaves one out injects something, unless that - // notice couldn't be stored. The message then counts as read - // though no request carried it: a coalesced render's wake to read - // it drops, and a release doesn't pass it to the primary. The - // notice has told the agent it came, from whom, and how to read - // it. Where the view leaves it out, no request will carry it. - // Where the memory strategy places it before its recent window, a - // batch bigger than that window, the next compile carries it, in - // whatever turn something else starts. if (midTurnInjections.length > 0) { // Possibly shown, whatever the stream declares: the first-view // mark (CoalescerHost.isUnshown) counts a doubt as shown. @@ -14021,10 +14562,19 @@ export class AgentFramework { // and it doesn't count as accepting a surface's input. A line and a // trace name the module and where, and the other modules' responses // apply. process:completed and the process log list the responses that - // applied, so a view built on them doesn't show a refused response's - // messages as the event's. A store error while a response applies, such - // as a shard the store refuses when a strategy's chunking makes one, - // propagates as any store error does. + // passed that check, so a view built on them doesn't show a refused + // response's messages as the event's. A response that throws as it + // applies, such as at an edit of a held write while its store can't be + // searched (refuseUnsettled), a shard the store refuses when a + // strategy's chunking makes one, or any store error, stops there: what + // it asked for before the throw stays applied, the operation that threw + // may have taken effect in part or whole, and the rest of it isn't. + // A line names the module and where it threw, and process:completed and + // the process log name the response with where it threw (`threw`), so + // neither reads it as applied whole. The other modules' + // responses, a tool call's dispatch and the trace go on, since a module + // can't catch a throw on this path, and one module's mustn't cost the + // event. const applying = responses.filter(({ moduleName, response }) => { const refusal = malformedResponseContent(response); if (refusal === null) return true; @@ -14044,8 +14594,20 @@ export class AgentFramework { if (surface && applying.some(({ response }) => (response.addMessages?.length ?? 0) > 0)) { this.noteInboundAccepted(surface); } - for (const { moduleName, response } of applying) { - await this.applyProcessResponse(response, event, moduleName, surface); + for (const entry of applying) { + const { moduleName, response } = entry; + const step = { at: 'its start' }; + try { + await this.applyProcessResponse(response, event, moduleName, surface, step); + } catch (error) { + entry.threw = { at: step.at, error: error instanceof Error ? error.message : String(error) }; + console.error( + `[process] ${moduleName}'s response to ${event.type} threw at ${step.at}: what it asked for before that ` + + `was applied, that operation may have taken effect in part or whole, and the rest of it wasn't; ` + + `the event goes on:`, + error, + ); + } } // Handle tool calls specially @@ -14069,45 +14631,60 @@ export class AgentFramework { event: ProcessEvent, moduleName: string, /** The event's surface envelope, when it is console/API input. */ - surface?: InboundSource, + surface: InboundSource | undefined, + /** Names the operation under way, for the line when one throws. */ + step: { at: string }, ): Promise { // Add messages. Only the framework writes the inbound envelope: one a // module's metadata carries (echoed from a server's event, say) is // dropped, and a surface event's messages carry the framework's own. + // The wakes below go by the one message a response adds (readsDeferredFor). + // A wake for several stays plain, and so does one for a channel's + // message (its metadata, or its event's, names the channel), or for + // console or API input: conversation, whose answer the module or the + // surface carries where the framework records none. + const eventChannel = (event as { metadata?: { channelId?: unknown } }).metadata?.channelId; + const namesChannel = typeof eventChannel === 'string' && eventChannel !== ''; + let added: { agent?: string; deferredId?: string } | undefined; if (response.addMessages) { - for (const msg of response.addMessages) { + for (const [i, msg] of response.addMessages.entries()) { + step.at = `adding message ${i + 1} of ${response.addMessages.length}`; let metadata = msg.metadata; if (metadata && INBOUND_SOURCE_KEY in metadata) { const { [INBOUND_SOURCE_KEY]: _dropped, ...rest } = metadata; metadata = rest; } if (surface) metadata = { ...metadata, [INBOUND_SOURCE_KEY]: surface }; - const id = this.addMessage(msg.participant, msg.content, metadata); + const placement: { agent?: string; deferredId?: string } = {}; + const id = this.addMessage(msg.participant, msg.content, metadata, { placement }); + if (response.addMessages.length === 1 && !surface && !namesChannel && metadata?.channelId === undefined) added = placement; this.emitTrace({ type: 'message:added', messageId: id, source: event.type }); } } // Edit messages - if (response.editMessages) { - for (const edit of response.editMessages) { - this.editMessage(edit.messageId, edit.content); - } + for (const edit of response.editMessages ?? []) { + step.at = `the edit of ${edit.messageId}`; + this.editMessage(edit.messageId, edit.content); } // Remove messages - if (response.removeMessages) { - for (const id of response.removeMessages) { - this.removeMessage(id); - } + for (const id of response.removeMessages ?? []) { + step.at = `the removal of ${id}`; + this.removeMessage(id); } - // Apply module state update atomically with message operations + // Apply module state update after the message operations: a response + // whose operation threw stops there, so its update describes no change + // that wasn't made. if (response.stateUpdate !== undefined) { + step.at = 'its state update'; this.moduleRegistry.setModuleState(moduleName, response.stateUpdate); } // Queue inference requests if (response.requestInference) { + step.at = 'its inference request'; const source = 'source' in event ? (event as { source: string }).source : 'unknown'; // Gate non-MCPL events (MCPL events are already gated in PushHandler/ChannelRegistry) @@ -14137,6 +14714,7 @@ export class AgentFramework { reason: event.type, source, timestamp: Date.now(), + ...this.readsDeferredFor(added, agentName), }); } } @@ -14187,13 +14765,14 @@ export class AgentFramework { serverId: string; channelId: string; messageId: string; threadId?: string; tags?: string[]; eventId?: string; coalescingSubject?: string; firstView?: boolean; }): void { - const anchor = { channelId: event.channelId, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true }; + // On the server the message came from (noteDelivered). + const anchor = { channel: { serverId: event.serverId, channelId: event.channelId }, messageId: event.messageId, threadId: event.threadId, tags: event.tags, firstView: event.firstView === true }; if (event.coalescingSubject && typeof event.eventId === 'string') { this.parkedAnchors.set(`${event.serverId}:${event.eventId}`, anchor); if (this.parkedAnchors.size > 1000) this.parkedAnchors.delete(this.parkedAnchors.keys().next().value!); return; } - this.channelRegistry?.noteDelivered(anchor.channelId, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView); + this.channelRegistry?.noteDelivered(anchor.channel, anchor.messageId, anchor.threadId, anchor.tags, anchor.firstView); } private async handleMcplChannelIncoming(event: { @@ -14350,14 +14929,15 @@ export class AgentFramework { if (decision.locus) this.noteDeliveredOrPark(event); // A correction may target an older engagement. Refresh only the // binding whose agent actually received the message. - if (this.conversationRouter.getBinding(event.channelId)?.agentName === target.name) { - this.conversationRouter.touch(event.channelId); + const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; + if (this.conversationRouter.getBinding(channel)?.agentName === target.name) { + this.conversationRouter.touch(channel); } this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (decision.wake) { this.pendingRequests.push({ agentName: target.name, reason: 'mcpl:channel-incoming', source: event.serverId, timestamp: Date.now(), - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed, ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), @@ -14416,12 +14996,14 @@ export class AgentFramework { timestamp: Date.now(), // Route this turn's auto-published speech back to THIS channel, not // the global most-recent-inbound locus (item-3 redux, trunk agents). - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, // Addressed messages outrank ambient chatter when a batched wake // picks the turn's frozen speech locus. addressed, + ...ambientMark(event.tags), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), + ...this.readsDeferredFor(placement, agentName), }); } } @@ -14450,7 +15032,8 @@ export class AgentFramework { admission: AdmissionDecision, ): Promise { const router = this.conversationRouter!; - const descriptor = this.channelRegistry?.getDescriptor(event.channelId); + const channel: ChannelRef = { serverId: event.serverId, channelId: event.channelId }; + const descriptor = this.channelRegistry?.getDescriptor(channel); const decision = await this.routeConversation({ channelId: event.channelId, @@ -14463,6 +15046,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-unrouted', channelId: event.channelId, + serverId: event.serverId, messageId: event.messageId, }); return; @@ -14475,6 +15059,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-spawned', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, generation: decision.generation, template: router.templateAgent, @@ -14485,6 +15070,7 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-spawn-failed', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, error: err.message, }); @@ -14495,10 +15081,11 @@ export class AgentFramework { if (!agent) { // Bound agent vanished (e.g. external reset) — unbind so the next // qualifying message respawns a fresh fork. - router.unbind(event.channelId); + router.unbind(channel); this.emitTrace({ type: 'mcpl:conversation-binding-orphaned', channelId: event.channelId, + serverId: event.serverId, agentName: decision.agentName, }); return; @@ -14519,7 +15106,7 @@ export class AgentFramework { // resident (the channel context's `incoming`; the fallback locus under // the legacy rule). A held or dropped message never is. if (admission.locus) this.noteDeliveredOrPark(event as Parameters[0]); - router.touch(event.channelId); + router.touch(channel); this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' }); if (trigger) { @@ -14530,7 +15117,7 @@ export class AgentFramework { timestamp: Date.now(), // A fork's home channel wins in routeSpeech regardless, but carry the // triggering channel too so the trunk/active path stays consistent. - channelId: event.channelId, + channelId: event.channelId, channelServerId: event.serverId, counterparty: event.author?.id ? `${event.serverId}:user:${event.author.id}` : undefined, addressed: isAddressedMessage(event.tags), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), @@ -14547,7 +15134,7 @@ export class AgentFramework { * wholesale-copy pattern; see the seeding comment below), registered in the * event loop like any agent — but never primary. */ - private async createConversationAgent(name: string, channelId: string): Promise { + private async createConversationAgent(name: string, home: ChannelRef): Promise { const router = this.conversationRouter!; const templateConfig = this.agentConfigs.get(router.templateAgent); const template = this.agents.get(router.templateAgent); @@ -14650,7 +15237,7 @@ export class AgentFramework { this.announceAgentCreated(this.registry.register({ ...conversationForkSpec(name, { template: router.templateAgent, - channelId, + homeChannel: home, idleTtlMs: router.idleTtlMs, }), model: config.model, @@ -14685,15 +15272,16 @@ export class AgentFramework { private async routeConversation(facts: IncomingMessageFacts): Promise< Exclude | (Extract & { spawned: Promise }) > { + const channel: ChannelRef = { serverId: facts.serverId ?? '', channelId: facts.channelId }; for (;;) { - const inFlight = this.conversationSpawns.get(facts.channelId); + const inFlight = this.conversationSpawns.get(destinationKey(channel)); if (inFlight) { await inFlight; continue; } const decision = this.conversationRouter!.route(facts); if (decision.kind !== 'spawn') return decision; - return { ...decision, spawned: this.spawnConversationAgent(facts.channelId, decision) }; + return { ...decision, spawned: this.spawnConversationAgent(channel, decision) }; } } @@ -14718,19 +15306,21 @@ export class AgentFramework { * channel first moves to the spawn's generation, which the store already * holds, and the channel's next qualifying message spawns the next one. */ - private spawnConversationAgent(channelId: string, decision: { agentName: string; generation: number }): Promise { + private spawnConversationAgent(channel: ChannelRef, decision: { agentName: string; generation: number }): Promise { + const key = destinationKey(channel); const spawn = (async () => { - this.storeConversationGeneration(channelId, decision.generation); - const agent = await this.createConversationAgent(decision.agentName, channelId); + // The router's counters are kept by bare id (ConversationRouter.generations). + this.storeConversationGeneration(channel.channelId, decision.generation); + const agent = await this.createConversationAgent(decision.agentName, channel); if (this.agents.get(decision.agentName) !== agent) { - this.conversationRouter!.hydrateGenerations({ [channelId]: decision.generation }); + this.conversationRouter!.hydrateGenerations({ [channel.channelId]: decision.generation }); throw new Error(`conversation fork "${decision.agentName}" ended before its channel was bound`); } - this.conversationRouter!.bind(channelId, decision.agentName, decision.generation); + this.conversationRouter!.bind(channel, decision.agentName, decision.generation); return agent; })(); - const done = (): void => { this.conversationSpawns.delete(channelId); }; - this.conversationSpawns.set(channelId, spawn.then(done, done)); + const done = (): void => { this.conversationSpawns.delete(key); }; + this.conversationSpawns.set(key, spawn.then(done, done)); return spawn; } @@ -14793,15 +15383,17 @@ export class AgentFramework { * context stays in Chronicle for investigation. */ private disposeConversationAgent(agentName: string): void { - const channelId = this.registry.homeChannel(agentName); + const home = this.homeOf(agentName); const agent = this.agents.get(agentName); // The one release every ended agent gets (releaseAgentRuntime), then the // registry's end, whose outcome settles what the fork spawned. if (agent) { - this.releaseAgentRuntime(agent, channelId); + this.releaseAgentRuntime(agent, home, 'its agent was disposed'); } else { // Nothing live under the name: only what the process keeps by name. this.closingConversationAgents.delete(agentName); + // A continuation of its turn still on its way goes with it. + this.dropContinuations(agentName, { kind: 'dropped', why: 'its agent was disposed' }); this.turnRouting.delete(agentName); this.proseSpeechChains.delete(agentName); this.openLogical.delete(agentName); @@ -14811,7 +15403,8 @@ export class AgentFramework { this.emitTrace({ type: 'mcpl:conversation-disposed', agentName, - channelId, + channelId: home?.channelId, + serverId: home?.serverId, }); } @@ -14828,15 +15421,19 @@ export class AgentFramework { agentName: string, injections: ContextInjection[], ): ContextInjection[] { - const home = this.registry.homeChannel(agentName); + const home = this.homeOf(agentName); const registry = this.channelRegistry; if (!home || !registry || injections.length === 0) { return injections; } + // A channel's context injection is namespaced by its id alone, so a + // namespace is another channel's where it's a registered channel's id, + // on any server; a server-qualified name isn't a namespace's form. + const ids = new Set(registry.listChannelsRaw().map((e) => e.descriptor.id)); return injections.filter((inj) => { const ns = inj.namespace; - if (!ns || ns === home) return true; - return registry.getDescriptor(ns) === undefined; + if (!ns || ns === home.channelId) return true; + return !ids.has(ns); }); } @@ -14854,10 +15451,11 @@ export class AgentFramework { this.lastConversationSweep = now; for (const binding of this.conversationRouter.expired(now)) { - this.conversationRouter.unbind(binding.channelId); + this.conversationRouter.unbind(binding); this.emitTrace({ type: 'mcpl:conversation-closed', channelId: binding.channelId, + serverId: binding.serverId, agentName: binding.agentName, reason: 'idle-ttl', }); @@ -14890,10 +15488,13 @@ export class AgentFramework { // (request dropped as stale, inference policy veto) would otherwise stay // registered forever. No turn, no active stream and no pending request = // done. A turn holds its token from dequeue, through its hooks and - // compile, before its stream registers, so a fork mid-compile is busy. + // compile, before its stream registers, so a fork mid-compile is busy; + // and a turn is alive while its continuation or a provider hold's retry + // of it is on its way (hasTurn), so a closure turn held for its retry + // finishes when the hold ends. for (const agentName of [...this.closingConversationAgents]) { if ( - !this.activeTurnTokens.has(agentName) && + !this.hasTurn(agentName) && !this.activeStreams.has(agentName) && !this.pendingRequests.some((r) => r.agentName === agentName) ) { @@ -14961,8 +15562,9 @@ export class AgentFramework { }): { block: ContentBlock; metadataPatch: Record } | null { if (!this.channelRegistry) return null; if (!opts.tags?.includes('chat:addressed')) return null; - if (this.channelRegistry.isChannelOpen(opts.channelId)) return null; - const descriptor = this.channelRegistry.getDescriptor(opts.channelId); + const channel: ChannelRef = { serverId: opts.serverId, channelId: opts.channelId }; + if (this.channelRegistry.isChannelOpen(channel)) return null; + const descriptor = this.channelRegistry.getDescriptor(channel); const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0; const label = opts.channelLabel ?? descriptor?.label; // A label that is only the channel's id (a lazily registered channel's) @@ -14978,11 +15580,11 @@ export class AgentFramework { // target "#DM:"). A registry that can name targets is authoritative: no safe // token means no prefix option. A stand-in without proseTargetFor gets a // whitespace-free guess, never a label with spaces. - const namer = this.channelRegistry as { proseTargetFor?: (id: string, serverId?: string) => string | undefined }; + const namer = this.channelRegistry as unknown as { proseTargetFor?: (ref: ChannelRef) => string | undefined }; const target = typeof namer.proseTargetFor === 'function' - ? namer.proseTargetFor(opts.channelId, opts.serverId) - : [hashLabel, opts.channelId].find((t): t is string => !!t && !/\s/.test(t)); - const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(opts.channelId, opts.serverId); + ? namer.proseTargetFor(channel) + : [hashLabel, opts.channelId].find((t): t is string => !!t && isProseTargetToken(t)); + const inboundOnlyServer = this.channelRegistry.inboundOnlyServer(channel); const replyOption = inboundOnlyServer ? `1. Replying there isn't available: ${inboundOnlyServer} declared this channel inbound-only, so nothing ` + `you write is posted into it. To speak there, use ${inboundOnlyServer}'s own tools.\n` @@ -15265,7 +15867,7 @@ export class AgentFramework { remove: (p) => this.removePlacement(p), deliver: (occ, materialized, assemblingFor) => this.deliverCoalesced(occ, materialized, assemblingFor), wakeForBatch: (occ) => this.wakeForCoalescedBatch(occ), - wakeToRead: (occ, agentName, deferredId) => this.queueCoalescedWakes({ ...occ, deliverTo: agentName }, true, deferredId), + wakeToRead: (occ, agentName, deferredId, allAmbient) => this.queueCoalescedWakes({ ...occ, deliverTo: agentName }, true, deferredId, allAmbient), cancelWake: (subject) => this.cancelCoalescedWakes(subject), authorized: (occ) => this.coalescedAuthorized(occ), audience: (occ) => this.coalescedAudience(occ), @@ -15293,9 +15895,18 @@ export class AgentFramework { recordReceipt: (record) => { // Written now, not at the throttled flush: an acknowledged acceptance // must not lose its receipt, the subject's birth, or (deferred) the - // accepted work itself to a crash. A failure here fails the acceptance. + // accepted work itself to a crash. A failure here fails the answer: + // the coalescer keeps the receipt, and the producer's retry writes it + // again, with the subject's state as it is then. So a record whose + // write failed leaves the list: a later write would carry it after + // the batch it holds had moved on. this.coalescingRecentReceipts.push(record); - this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); + try { + this.store.setStateJson(COALESCING_RECENT_ID, this.coalescingRecentReceipts); + } catch (error) { + this.coalescingRecentReceipts.pop(); + throw error; + } }, // The acceptance of coalesced work (deferred included) is observed // here, once; its later delivery reuses the frozen envelope. @@ -15402,6 +16013,9 @@ export class AgentFramework { this.store.setStateJson(COALESCING_RECENT_ID, []); } catch (err) { console.error('[coalescing] could not persist coalescing state:', err); + // Still unsaved: the next persist, or the stop's flush, writes it. No + // timer of its own, so a store that keeps failing isn't retried here. + this.coalescingSnapshotDirty ??= snapshot; } } @@ -15469,7 +16083,7 @@ export class AgentFramework { const parked = this.parkedAnchors.get(`${serverId}:${message.eventId}`); if (parked) { this.parkedAnchors.delete(`${serverId}:${message.eventId}`); - this.channelRegistry?.noteDelivered(parked.channelId, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true); + this.channelRegistry?.noteDelivered(parked.channel, parked.messageId, parked.threadId, parked.tags, parked.firstView || firstView === true); } return { messageId: message.messageId, accepted: true, coalesce: result.coalesce, ...(firstView ? { firstView: true as const } : {}) }; }); @@ -15565,7 +16179,9 @@ export class AgentFramework { // §3.2 / §5.4 rule 4: the channel's CURRENT authority, whichever lane. if (!server.grant.has('channels.incoming')) return false; if (occ.event.lane === 'push') return this.channelRegistry?.isDeclaredChannel(occ.serverId, occ.scope.id) ?? false; - return !!this.channelRegistry?.getDescriptor(occ.scope.id); + // This server's own registration: another server's channel with the + // same id carries none of this server's authority. + return !!this.channelRegistry?.getDescriptor({ serverId: occ.serverId, channelId: occ.scope.id }); } return true; } @@ -15576,7 +16192,8 @@ export class AgentFramework { if (explicit) return explicit.filter((n) => this.agents.has(n)); if (occ.scope.kind === 'channel' && this.conversationRouter) { const router = this.conversationRouter; - const bound = router.getBinding(occ.scope.id)?.agentName; + const channel: ChannelRef = { serverId: occ.serverId, channelId: occ.scope.id }; + const bound = router.getBinding(channel)?.agentName; if (bound) return this.agents.has(bound) ? [bound] : []; // No fork yet: the same bind decision an incoming message would get. A // batch that qualifies spawns its fork now so it has someone to wake. @@ -15585,7 +16202,7 @@ export class AgentFramework { channelId: occ.scope.id, serverId: occ.serverId, mentioned: metadata.mentioned === true, - kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(occ.scope.id), metadata), + kind: ConversationRouter.classifyChannel(this.channelRegistry?.getDescriptor(channel), metadata), }); if (decision.kind !== 'spawn') return decision.kind === 'unbound' ? [] : (this.agents.has(decision.agentName) ? [decision.agentName] : []); try { @@ -15679,8 +16296,9 @@ export class AgentFramework { /** Queue a wake for each agent of the batch's audience, when its latest * occurrence triggers inference. `plain`: an ordinary push wake, without * the subject a withdrawal of the batch's wakes goes by, for a render - * placed in its agent's deferred queue (wakeToRead), at `readsDeferredWrite`. */ - private async queueCoalescedWakes(occ: CoalescedOccurrence, plain = false, readsDeferredWrite?: string): Promise { + * placed in its agent's deferred queue (wakeToRead), at `readsDeferredWrite`, + * marked ambient when every notice the render folds was (`allAmbient`). */ + private async queueCoalescedWakes(occ: CoalescedOccurrence, plain = false, readsDeferredWrite?: string, allAmbient = false): Promise { const subject = coalescingSubjectKey(occ.serverId, occ.binding, occ.scope, occ.key); if (occ.event.lane !== 'push' || !occ.event.event.triggerInference) return; const push = occ.event.event; @@ -15722,9 +16340,13 @@ export class AgentFramework { reason: 'mcpl:push-event', source: occ.serverId, timestamp: Date.now(), - channelId, + channelId, ...(channelId !== undefined ? { channelServerId: occ.serverId } : {}), counterparty: authorId ? `${occ.serverId}:user:${authorId}` : undefined, addressed: isAddressedMessage(occ.tags), + // A render folds the batch's every notice, so it asks nothing of the + // agent only when each of them was marked ambient, not the latest + // alone; only its wake is judged by what was read (readsDeferredWrite). + ...(plain && allAmbient ? { ambient: true as const } : {}), ...(plain ? (readsDeferredWrite ? { readsDeferredWrite } : {}) : { coalescingSubject: subject, coalescingBatch: true }), }); } @@ -15736,6 +16358,19 @@ export class AgentFramework { for (const cooldown of this.providerAccelerationCooldowns.values()) { cooldown.heldRequests = cooldown.heldRequests.filter((r) => r.coalescingSubject !== subject); } + // A wake of the subject's merged into a start that carries a turn on + // (MERGED_BATCH) is withdrawn from the start's batch, so no stop puts + // it back and no pass unpacks it. + const unrecord = (r: InferenceRequest): void => { + const batch = mergedBatch(r); + if (batch?.some((w) => w.coalescingSubject === subject)) { + (r as MarkedRequest)[MERGED_BATCH] = batch.filter((w) => w.coalescingSubject !== subject); + } + }; + for (const r of this.pendingRequests) unrecord(r); + for (const cooldown of this.providerAccelerationCooldowns.values()) cooldown.heldRequests.forEach(unrecord); + for (const r of this.parkedContinuations ?? []) unrecord(r); + for (const pass of this.passRequests ?? []) for (const requests of pass.values()) requests.forEach(unrecord); } /** @@ -16001,11 +16636,12 @@ export class AgentFramework { reason: 'mcpl:push-event', source: event.serverId, timestamp: Date.now(), - channelId: triggerChannel?.channelId, + channelId: triggerChannel?.channelId, ...(triggerChannel ? { channelServerId: event.serverId } : {}), // DMs and addressed-while-closed messages arrive as push events; // they must outrank ambient chatter in a batched wake's locus // selection just like their channels/incoming counterparts. addressed: isAddressedMessage(event.tags), + ...ambientMark(event.tags), ...(silentHeartbeat ? { suppressProse: true, // The tick's instruction rides its request-only separator @@ -16016,6 +16652,7 @@ export class AgentFramework { silentHeartbeat: { eventId: event.eventId, serverId: event.serverId }, } : {}), ...(event.coalescingSubject ? { coalescingSubject: event.coalescingSubject } : {}), + ...this.readsDeferredFor(placement, agentName), }); } } @@ -16028,15 +16665,84 @@ export class AgentFramework { * included. A fork speaks only in its home channel, and a channel bound to * a fork is that fork's conversation, so no other agent takes it. */ - private mayTakeGateRoute(agentName: string, routeChannelId: string): boolean { - const home = this.registry.homeChannel(agentName); - if (home !== undefined) return home === routeChannelId; + private mayTakeGateRoute(agentName: string, route: ChannelRef): boolean { + const home = this.homeOf(agentName); + if (home !== undefined) return sameChannel(home, route); for (const record of this.registry.list()) { - if (record.homeChannel === routeChannelId) return false; + if (sameChannel(this.channelOfHome(record.homeChannel), route)) return false; } return true; } + /** + * The one channel a wake's channel names: `channelId` on `serverId` when + * the wake gives its server, otherwise the one registered channel that id, + * or its server-qualified name, means (ChannelRegistry.channelsNamed). + * Undefined when it means none or several: channel ids are unique only + * within one server's connection (MCPL §14.2), and a name two servers' + * channels answer to is never resolved to either. + */ + private channelNamed(channelId: string, serverId?: string): ChannelRef | undefined { + if (serverId) return { serverId, channelId }; + const named = this.channelRegistry?.channelsNamed(channelId) ?? []; + return named.length === 1 ? named[0] : undefined; + } + + /** + * A live agent's home channel, from its registry record (channelOfHome): + * defined for a conversation fork, and only for one. Undefined for an agent + * with none, and for one that has ended. + */ + private homeOf(agentName: string): ChannelRef | undefined { + return this.channelOfHome(this.registry.homeChannel(agentName)); + } + + /** + * The channel a registry record's `homeChannel` names: the channel on its + * server. A bare channel id, as a record written before homes carried + * their server keeps, is read as a wake that names its channel without its + * server is (channelNamed): the one registered channel it names, read when + * it's used, since the servers registered at that moment are what it can + * mean. An id that names no one registered channel, as one no server + * registers now or two servers do, is the home on no server (an empty + * serverId, which channelNamed reads as none given), so the agent keeps a + * home. A path that reads the pair finds no server registering it, and + * refuses it; one that reads the name reads what the name means then: none + * or two channels, which it refuses, or, once one server alone registers + * the id, that server's channel. + */ + private channelOfHome(home: AgentRecord['homeChannel']): ChannelRef | undefined { + if (home === undefined) return undefined; + if (typeof home !== 'string') return home; + return this.channelNamed(home) ?? { serverId: '', channelId: home }; + } + + /** + * Explicit mode's typing channel for one start of a turn, which has no + * locus, read once as the start opens (driveStream then shows what the + * start showed, TurnRouting.typing): where that start's wake came from, + * its trigger's channel with its server (channelNamed), so a start batched + * with a new wake shows typing where that wake came from. Not the turn's + * trigger channel, which a channel_open moves as the turn's default + * outgoing channel. A start whose wake names no one channel, as a budget + * restart's or a re-inference's names none, keeps the channel the turn + * shows typing in (TurnRouting.typing), showing it again where a stop or + * a failed start ended the indicator, since the turn is still at work + * there; a turn that has shown none, as a heartbeat's, shows none. A guard + * retry's wake is its start's, read again. In a turn that has no channel + * (TurnRouting.noChannel), a wake named without its server isn't read: a + * retry's trigger names it again, and reading it then could take a + * channel the turn never had. A wake that gives its server, as every one + * the framework makes for a channel does, names that pair. + */ + private explicitTypingChannel(routing: TurnRouting, trigger: InferenceRequest | undefined): ChannelRef | null { + // A server is read as channelNamed reads one: an empty one as none given. + const own = trigger?.channelId && !(routing.noChannel && !trigger.channelServerId) + ? this.channelNamed(trigger.channelId, trigger.channelServerId) + : undefined; + return own ?? routing.typing ?? null; + } + /** * Derive the MCPL composite channel id (the outbound routing locus) for a * push event from its server-defined `origin`, if it carries one. @@ -16095,17 +16801,28 @@ export class AgentFramework { return undefined; } + /** One scheduler pass, whose requests stay visible as on their way until + * it takes their agent up (passRequests). */ private async processInferenceRequests(): Promise { if (this.pendingRequests.length === 0) { return; } + const requestsByAgent = new Map(); + this.passRequests?.add(requestsByAgent); + try { + await this.runInferencePass(requestsByAgent); + } finally { + this.passRequests?.delete(requestsByAgent); + } + } + + private async runInferencePass(requestsByAgent: Map): Promise { const STALE_REQUEST_MS = 30_000; const now = Date.now(); const state = this.createFrameworkState(); // Group requests by agent - const requestsByAgent = new Map(); for (const req of this.pendingRequests) { const existing = requestsByAgent.get(req.agentName) ?? []; existing.push(req); @@ -16118,6 +16835,13 @@ export class AgentFramework { // Check each agent for (const [agentName, groupedRequests] of requestsByAgent) { let requests = groupedRequests; + // Taken up now: from here to its start's turn token (or its hold or + // park) nothing awaits, so the requests leave the pass's map, which + // shows only what the pass still holds while it awaits a start. + requestsByAgent.delete(agentName); + // A drop while this pass awaited an earlier agent's start can have + // taken every request of this agent's (dropContinuations). + if (requests.length === 0) continue; const agent = this.agents.get(agentName); if (!agent) { // Agent not found — request is orphaned. Emit warning and drop. @@ -16130,6 +16854,10 @@ export class AgentFramework { oldestRequestAge: now - oldest, }); console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`); + // A continuation among them leaves its turn to end here. + if (requests.some(continuesTurn)) { + this.endContinuedTurn(agentName, { kind: 'dropped', why: 'its agent is gone' }); + } continue; } @@ -16139,11 +16867,7 @@ export class AgentFramework { // selected now, the turn proceeds (the wait stays recorded). const otherModel = providerCooldown.waitModel !== undefined && providerCooldown.waitModel !== agent.model; if (!otherModel && (now < providerCooldown.until || this.extendHostProviderHold(agentName, providerCooldown))) { - for (const req of requests) { - if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) { - providerCooldown.heldRequests.push(req); - } - } + for (const req of requests) this.holdRequest(providerCooldown.heldRequests, req); const recovery = this.providerAccelerationRecoveries.get(agentName); if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length; // The timer owns release. Do not hot-poll these requests. @@ -16157,6 +16881,17 @@ export class AgentFramework { if (recovery) recovery.releasedAt = now; requests = [...providerCooldown.heldRequests, ...requests]; } + // A start that carries a turn on and came back before it took its + // token stands for the batch it was merged from (MERGED_BATCH), + // including one a hold released just now. The batch takes its place, + // each request as it was, judged one by one from here: a wait holds + // them apart, a quiesce parks the wakes while the turn's request + // passes, and the read check and the policy decide on them all; the + // merge below records the batch anew. + if (requests.some((r) => mergedBatch(r) !== undefined)) { + requests = requests.flatMap((r) => mergedBatch(r) ?? [r]); + } + // A provider's stated wait on this agent's primary model, including one // recorded before a restart, holds the turn until it passes. An // ephemeral run records nothing, its name being single-use, so no wait, @@ -16190,10 +16925,26 @@ export class AgentFramework { // kept): heartbeat/gate wakes arrive once per tick and nothing else // bounds a multi-day maintenance window's accumulation. if (this.quiesced) { + // An autoContinue continuation doesn't outlast a quiesce: at resume, + // possibly hours later, "another turn starts now" would be untrue. + // Its own wake is dropped and the run's count given back; a requeued + // trigger that carried one parks without the mark. + if (requests.some(carriesContinuation)) { + this.giveBackAutoContinue(agentName, 'the host is quiesced'); + requests = requests + .filter((r) => r.reason !== AUTO_CONTINUE_REASON) + .map((r) => { + if (!carriesContinuation(r)) return r; + const { autoContinueRun: _mark, ...rest } = r; + return rest; + }); + if (requests.length === 0) continue; + } // Continuations of a held turn (budget restart, non-streaming tool - // results) pass; everything else parks. - const passThrough = requests.filter((r) => isTurnContinuation(r.reason)); - const parked = requests.filter((r) => !isTurnContinuation(r.reason)); + // results) pass, and so does a held turn's retry (passesQuiesce), so + // a turn alive at the quiesce can finish; everything else parks. + const passThrough = requests.filter((r) => passesQuiesce(r)); + const parked = requests.filter((r) => !passesQuiesce(r)); // Coalesce per (reason, addressed): every channel wake shares one // reason, so keying on reason alone would let an ambient message // parked later evict a DM/mention parked earlier — and the @@ -16202,13 +16953,17 @@ export class AgentFramework { const newestByKey = new Map(); for (const request of parked) { // A coalesced wake is withdrawable by its subject (RFC-006 §4.2), - // and a wake to read a render is dropped once it's read: neither - // may absorb an unrelated wake that would vanish with it. + // and a wake for a deferred write is dropped once the agent is + // done with that write: neither may absorb an unrelated wake that + // would vanish with it. const key = `${request.reason}|${request.addressed ? 'a' : 'n'}|${request.coalescingSubject ?? ''}|${request.readsDeferredWrite ?? ''}`; const prev = newestByKey.get(key); - if (!prev || request.timestamp >= prev.timestamp) { - newestByKey.set(key, request); - } + const kept = !prev || request.timestamp >= prev.timestamp ? request : prev; + // The wake it absorbs hands on its lines (WakeLine): each event's + // line stays to be decided when the host resumes. + const lines = prev ? mergeWakeLines([prev, request]) : undefined; + if (lines) kept.wakeLines = lines; + newestByKey.set(key, kept); } this.pendingRequests.push(...newestByKey.values()); if (passThrough.length === 0) continue; @@ -16219,7 +16974,10 @@ export class AgentFramework { requests.push(...passThrough); } - const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName); + // Turn-alive includes a continuation of the agent's turn still on its + // way elsewhere (a park, another pass's hands): a fresh wake waits for + // it, as for a turn holding its token. + const turnAlive = !budgetRestart && this.hasTurn(agentName); const providerGate = this.providerGates?.get(agentName); // A primary can own provider admission while yielding to an already in-flight // auxiliary call. Keep this agent's later wakes queued, but do not block the @@ -16271,16 +17029,24 @@ export class AgentFramework { continue; } - // A wake whose sole cause was reading a write placed in this agent's - // queue (wakeToRead's readsDeferredWrite) has nothing left to do once - // the agent has read that write, or it was removed: it starts no turn. + // A wake whose sole cause was a write deferred for this agent + // (readsDeferredWrite) has nothing left to do once a request that + // carried that write was answered and, if the wake carries a channel + // and isn't marked ambient, the agent has published there since, or + // once the write is gone from the queue and the store's current + // branch: it starts no turn (deferredWriteDone). A wake is marked + // ambient when its message's server tagged it chat:ambient, or for a + // render, every notice the render stands for: such a message asked + // nothing of the agent, so reading it was all its wake was for. One + // with neither tag is judged as addressed. const read = requests.filter((r) => r.readsDeferredWrite !== undefined - && this.deferredWriteReadOrGone(agent, r.readsDeferredWrite)); + && this.deferredWriteDone(agent, r.readsDeferredWrite, + r.channelId === undefined || r.ambient === true ? undefined : { serverId: r.source, channelId: r.channelId })); if (read.length > 0) { const left = requests.filter((r) => !read.includes(r)); requests.length = 0; requests.push(...left); - console.error(`[coalescing] ${agentName}: ${read.length} wake(s) to read a render it has read, or that was removed, dropped` + + console.error(`[wake] ${agentName}: dropped ${read.length} wake(s) for writes an answered request carried, and that it spoke after in the wake's channel where it carries one not marked ambient, or that were removed` + (requests.length === 0 ? '; no turn starts' : '')); if (requests.length === 0) { this.endPendingPrimary(agentName); @@ -16297,13 +17063,42 @@ export class AgentFramework { `[inference-dropped] agent=${agentName} reason=policy-skip ` + `requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`, ); - // A context-budget restart inherits the predecessor's EventGate liveness. - // If policy drops the queued successor, no driveStream remains to release it. - if (budgetRestart) this.eventGate?.onInferenceEnded(agentName); + // A continuation inherits its predecessor's EventGate liveness and + // logical turn. If policy drops it, no driveStream remains to end them, + // so its turn ends here (endContinuedTurn). + if (requests.some(continuesTurn)) { + this.endContinuedTurn(agentName, { kind: 'dropped', why: 'the inference policy dropped its continuation' }); + } + if (requests.some(carriesContinuation)) this.giveBackAutoContinue(agentName, 'the inference policy dropped its wake'); this.endPendingPrimary(agentName); continue; } + // An autoContinue continuation is the turn's when nothing in the batch + // is a cause of its own (hasOwnCauseForRun): then the turn's trigger + // carries its mark, and the turn writes its `[continue]` row as it + // starts (startAgentStream), past every gate that could still stop it. + // A batch with a cause of its own is that cause's turn: no row, and the + // run starts again below. + const continuation = requests.find(carriesContinuation); + let continuationMark = continuation?.autoContinueRun && !requests.some(hasOwnCauseForRun) + ? { ...continuation.autoContinueRun, sole: requests.every(carriesContinuation) } + : undefined; + // A batch that resumes a logical turn (a budget restart, a guard retry) + // takes the continuation's wake into that turn, which writes no start + // inputs: its count goes back, and it writes no row. + if (continuationMark && requests.some((r) => resumesLogicalTurn(r.reason))) { + this.giveBackAutoContinue(agentName, 'a resumed turn took its wake'); + continuationMark = undefined; + } + // Its row goes into the residents' shared slot, which no write may + // enter while another reader's turn is alive (as for a clock row): the + // continuation waits until that turn has ended. + if (continuationMark && this.sharedSlotBusy(agent)) { + this.pendingRequests.push(...requests); + continue; + } + // Start streaming inference (non-blocking — driveStream runs in background) // The request that grants the turn-alive exception must also define the // downstream turn semantics. An ordinary wake can already be pending @@ -16321,6 +17116,14 @@ export class AgentFramework { if (requests.some((r) => !FRAMEWORK_WAKE_SOURCES.has(r.source))) { this.proseBounceStreaks?.delete(agentName); } + // Likewise autoContinue's cap bounds only a run of continuations that + // nothing else feeds. A silent heartbeat tick doesn't feed it: batched + // with a continuation it would otherwise restart the run at every tick, + // and a resident whose ticks come faster than its turns would never + // reach the cap (hasOwnCauseForRun). + if (requests.some(hasOwnCauseForRun)) { + this.autoContinueStreaks?.delete(agentName); + } // Route this turn's auto-published speech to the channel that triggered // it (item-3 redux). A batched wake may carry several triggering channels // (messages arrived in >1 channel while the agent was busy/idle): @@ -16347,12 +17150,21 @@ export class AgentFramework { const eventAt = (r: InferenceRequest): number => r.wakeAt ?? r.timestamp; let ambientReq: InferenceRequest | undefined; let addressedReq: InferenceRequest | undefined; + // An autoContinue wake names the finished turn's locus. It speaks for + // the turn only when nothing else in the batch names a channel: a + // message that arrived meanwhile, ambient included, outranks it, though + // its wake was queued first. + let continuationReq: InferenceRequest | undefined; for (const r of requests) { if (!r.channelId) continue; + if (r.reason === AUTO_CONTINUE_REASON) { + continuationReq ??= r; + continue; + } if (!ambientReq || eventAt(r) >= eventAt(ambientReq)) ambientReq = r; if (r.addressed && (!addressedReq || eventAt(r) >= eventAt(addressedReq))) addressedReq = r; } - const channelReq = addressedReq ?? ambientReq; + const channelReq = addressedReq ?? ambientReq ?? continuationReq; // Telemetry provenance (who/where woke the agent). An ADDRESSED winner // is its own provenance, even when it names no author (a push event // can route without an author id): borrowing another request's author @@ -16375,7 +17187,7 @@ export class AgentFramework { // exists only once assembly renders it. Recorded for the whole batch // (not inherited from requests[0]), so a requeued trigger stays honest. const batchOnly = !budgetRestart && requests.every((r) => r.coalescingBatch === true && !!r.coalescingSubject); - await this.startAgentStream(agent, { + const merged: InferenceRequest = { ...trigger, coalescingBatch: batchOnly || undefined, coalescingBatchSubjects: batchOnly @@ -16392,9 +17204,17 @@ export class AgentFramework { // take no other cause with it. readsDeferredWrite: requests.every((r) => r.readsDeferredWrite === trigger?.readsDeferredWrite) ? trigger?.readsDeferredWrite : undefined, + // Every merged wake's handed line, each event's once: the turn's start + // decides each (beginAgentTurn). + wakeLines: mergeWakeLines(requests), suppressProse: silentOnly ? trigger?.suppressProse : undefined, silentHeartbeat: silentOnly ? trigger?.silentHeartbeat : undefined, + // The channel and its server from the one request, so the turn's + // channel is that message's, never one server's id with another's. channelId: channelReq?.channelId, + channelServerId: channelReq?.channelServerId, + // Only the mark this batch decided, never one requests[0] carried. + autoContinueRun: continuationMark, addressed: addressedReq !== undefined, // A context-budget restart continues the same logical turn: it keeps // the channel for routing but names no author — the restart is its @@ -16403,7 +17223,17 @@ export class AgentFramework { counterparty: budgetRestart ? undefined : provReq?.counterparty, wakeChannelId: budgetRestart ? undefined : provReq?.wakeChannelId, wakeAt: budgetRestart ? undefined : provReq?.wakeAt, - }); + }; + // A held turn's retry merged with other wakes leaves the start its + // turn's retry (TURN_RETRY), whichever request leads the merge: held + // again before it takes a token, it keeps the turn alive as before. + const start: MarkedRequest = requests.some(isTurnRetry) ? asTurnRetry(merged) : merged; + // A start that carries a turn on and took in wakes that don't records + // the batch it was merged from (MERGED_BATCH): until it takes its + // token it stands for them all. + if (continuesTurn(start) && requests.some((r) => !continuesTurn(r))) start[MERGED_BATCH] = [...requests]; + else delete start[MERGED_BATCH]; + await this.startAgentStream(agent, start); } } @@ -16414,22 +17244,33 @@ export class AgentFramework { * `speech.locus: 'delivery'` the registry alone has no default, and the * tool failed on the very turn its own delivery woke (first live run, * 2026-10-12). Both dispatch paths — the stream driver's and - * executeToolCall's — pass through here. - */ - private publishInputWithTurnLocus(input: unknown, agentName: string): unknown { + * executeToolCall's — pass through here. Only a call made inside the turn + * (callingTurnOf) takes the turn's locus: one made outside it, as a script + * left running after its turn makes or a host's executeToolCall naming the + * agent, takes none of the turn's channels. A conversation fork's home + * isn't the turn's but the fork's own, so such a call from a fork naming + * no channel still goes there (homeOf), as the fork's dispatched calls do + * (dispatchChannelToolCall); anyone else's takes the registry's own + * default. + */ + private publishInputWithTurnLocus(input: unknown, agentName: string, callingTurn: TurnRouting | undefined): unknown { const given = (input as { channelId?: unknown } | null | undefined)?.channelId; if (typeof given === 'string' && given !== '') return input; - const locus = this.turnRouting.get(agentName)?.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null; + const locus = callingTurn + ? callingTurn.locus ?? this.channelRegistry?.resolveLocus(agentName) ?? null + : this.homeOf(agentName) ?? null; if (!locus) return input; - return { ...((input as Record | null | undefined) ?? {}), channelId: locus }; - } - - /** A channel as shown to the agent: its label with the id, or the id. */ - private shownChannel(channelId: string): string { - const label = this.channelRegistry?.getDescriptor(channelId)?.label; - return label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId; + // The pair: a serverId beside a channelId names that server's channel. + // A serverId given with no channelId names the locus's id on that + // server, as the registry reads one given beside its own default: the + // default's id on the server given (handleToolPublish). So a fork's call + // naming another server and no channel reaches that server's channel + // with its home's id, where one is registered: its dispatched calls are + // refused that (dispatchChannelToolCall), and executeToolCall applies + // none of that guard, on main as here. + const record = (input as Record | null | undefined) ?? {}; + const serverId = typeof record.serverId === 'string' && record.serverId ? record.serverId : locus.serverId; + return { ...record, channelId: locus.channelId, serverId }; } /** @@ -16539,9 +17380,9 @@ export class AgentFramework { } else if (parsed.kind === 'target') { // As deliverHybridProse resolves it: a channel its server declared // inbound-only is refused as a target for speech. - const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!, { forSpeech: true }); + const resolved = this.channelRegistry?.resolveProseTarget(parsed.target!, { forSpeech: true, own: this.ownChannels(routing) }); if (resolved && !('error' in resolved)) { - routing.pin = resolved.channelId; + routing.pin = { serverId: resolved.serverId, channelId: resolved.channelId }; delete routing.hybridHold; } else { delete routing.pin; @@ -16688,9 +17529,11 @@ export class AgentFramework { return JSON.stringify(flat.length > max ? `${flat.slice(0, max)}…` : flat); } - private static destinationText(d: PublishDestination): string { + /** A destination as the agent is shown it: its label, with its name as + * ChannelRegistry.nameShown gives it. */ + private destinationText(d: PublishDestination): string { const label = d.label && d.label !== d.channelId ? `${d.label.startsWith('#') || d.label.startsWith('DM') ? d.label : `#${d.label}`} ` : ''; - return `${label}(${d.channelId})`; + return `${label}(${this.channelRegistry?.nameShown(d) ?? d.channelId})`; } /** One line per draft: id, state, when, why, size, preview. */ @@ -16707,7 +17550,7 @@ export class AgentFramework { if (state === 'unconfirmed') { const risky = uncertainAttempt(draft); if (risky) { - return `UNCONFIRMED — an attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + return `UNCONFIRMED — an attempt to ${this.destinationText(risky.destination)} at ` + `${this.draftTime(risky.at)} may have been posted`; } return `UNCONFIRMED — it includes the words of ${draft.inheritedRisk!.draftId}, which may already have been posted`; @@ -16722,14 +17565,14 @@ export class AgentFramework { private riskText(draft: Draft): string { const risky = uncertainAttempt(draft); if (risky) { - return `${draft.id}'s attempt to ${AgentFramework.destinationText(risky.destination)} at ` + + return `${draft.id}'s attempt to ${this.destinationText(risky.destination)} at ` + `${this.draftTime(risky.at)} may already have been posted: ${risky.outcome?.reason ?? this.noOutcomeText(draft, risky)}`; } const inherited = draft.inheritedRisk!; const owner = inherited.sourceDraftId ?? inherited.draftId; const via = owner !== inherited.draftId ? `, which itself includes the words of ${owner}` : ''; const where = inherited.destination - ? `${owner}'s attempt to ${AgentFramework.destinationText(inherited.destination)}` + + ? `${owner}'s attempt to ${this.destinationText(inherited.destination)}` + (inherited.at !== undefined ? ` at ${this.draftTime(inherited.at)}` : '') : owner; return `${draft.id} includes the words of ${inherited.draftId}${via}, and ${where} may already have been posted: ${inherited.reason}`; @@ -16752,7 +17595,7 @@ export class AgentFramework { const delivered = draft.attempts.find((a) => a.outcome?.status === 'delivered'); if (!delivered) return `${draft.id}: not delivered`; return `${draft.id}: delivered — confirmed at ${this.draftTime(delivered.outcome!.at)} to ` + - `${AgentFramework.destinationText(delivered.destination)}` + + `${this.destinationText(delivered.destination)}` + (delivered.outcome!.messageId ? `, message ${delivered.outcome!.messageId}` : '') + ' (a record of that delivery, not a check that the message is still there)'; } @@ -16819,6 +17662,48 @@ export class AgentFramework { } } + /** + * Turn start, every one: where the agent's history holds neither its + * latest direct write to a mount nor the notice of its departure + * from it, as after an undo or another move of the store's head away from + * that write, the workspace's notice that those writes are still on disk + * goes into the history, privately, before the compile, as the drafts + * notice does, and the workspace keeps its id (headMoveNotice). On an + * unmoved head it finds nothing due and stores nothing. + */ + private noticeHeadMoves(agent: Agent): void { + const workspace = this.getWorkspaceModule(); + // A module taken for the workspace by its writeBinary may have no such + // check (addModule says why), and then nothing is due. + if (!workspace || typeof workspace.headMoveNotice !== 'function' || typeof workspace.headMoveNoticeStored !== 'function') return; + let due: { text: string; mounts: string[] } | null; + try { + due = workspace.headMoveNotice(agent.name); + } catch (err) { + console.error('[workspace] head-move check failed:', err); + return; + } + if (!due) return; + let mid: string; + try { + mid = agent.getContextManager().addMessage( + 'user', + [{ type: 'text', text: due.text }], + { system: true, kind: 'workspace-head-move', mounts: due.mounts } as MessageMetadata, + ); + } catch (err) { + console.error('[workspace] head-move notice failed:', err); + return; + } + // Recorded as soon as it's stored, so nothing after can leave it stored and unrecorded. + workspace.headMoveNoticeStored(agent.name, due.mounts, mid); + try { + this.emitTrace({ type: 'message:added', messageId: mid, source: 'workspace-head-move' }); + } catch (err) { + console.error('[workspace] head-move notice trace failed:', err); + } + } + /** * Logical turn end, after the turn's deferred notices are flushed: record * as noticed exactly the drafts a stored message names (the receipt, a @@ -16892,8 +17777,8 @@ export class AgentFramework { /** The drafts tool's answer to a resident's call, from the model's own * dispatch or an agent-origin call through executeToolCall (a puppet's): * a failure the handler throws is answered as one. */ - private draftsToolResult(agentName: string, rawInput: unknown): Promise { - return this.handleDraftsTool(agentName, rawInput).catch((err): ToolResult => ({ + private draftsToolResult(agentName: string, rawInput: unknown, turn?: TurnRouting, callId?: string): Promise { + return this.handleDraftsTool(agentName, rawInput, turn, callId).catch((err): ToolResult => ({ success: false, error: `drafts failed: ${thrownText(err)}`, isError: true, @@ -16905,12 +17790,23 @@ export class AgentFramework { * drafts. Private to the calling resident. `resend` is an explicit send: * each draft is published verbatim, in order, through the normal publish * executor, its attempt journaled durably before the request leaves. + * `turn` is the turn the call was made in (callingTurnOf), none for a + * call made outside the agent's current turn: a resend's destination names + * its own channels (ownChannels) by their ids, and its sends engage it. */ - private async handleDraftsTool(agentName: string, rawInput: unknown): Promise { + private async handleDraftsTool(agentName: string, rawInput: unknown, turn?: TurnRouting, callId?: string): Promise { const input = (rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput) ? rawInput : {}) as Record; + const own = turn ? this.ownChannels(turn) : []; // The turn that sends: a resent draft's channel is recorded as engaged on - // it, as a send tool's is, even when the result comes after the turn. - const sendingTurn = this.turnRouting.get(agentName); + // it, as a send tool's is, even when the result comes after the turn. A + // call made outside the turn engages none. + const sendingTurn = turn; + // A delivered resend made in the turn is noted on the agent with what it + // had read when it made the call (Agent.notePublished, + // publishReadThrough); not one made outside it, as a background + // script's is. + const resender = turn ? this.agents.get(agentName) : undefined; + const readThrough = resender ? this.publishReadThrough(resender, callId) : null; const refuse = (error: string): ToolResult => ({ success: false, error, isError: true }); // Plain text, not a JSON-quoted string: draft text must read exactly as held. const ok = (text: string): ToolResult => ({ success: true, data: [{ type: 'text', text }] }); @@ -16989,7 +17885,7 @@ export class AgentFramework { : o.status === 'unknown' ? `UNKNOWN — may have been posted: ${o.reason ?? 'no valid receipt'}` : `failed, nothing posted: ${o.reason ?? 'refused'}`; - return ` attempt ${this.draftTime(a.at)} → ${AgentFramework.destinationText(a.destination)}: ${status}` + + return ` attempt ${this.draftTime(a.at)} → ${this.destinationText(a.destination)}: ${status}` + (o?.status === 'unknown' && o.detail !== undefined ? `; connector detail: ${connectorText(o.detail)}` : '') + (a.confirmedDuplicate ? ' (resent knowing it might duplicate)' : ''); }), @@ -17056,28 +17952,45 @@ export class AgentFramework { } const registry = this.channelRegistry; if (!registry) return refuse('No channels are configured, so there is nowhere to resend to.'); - const home = this.registry.homeChannel(agentName); + const home = this.homeOf(agentName); let spec = typeof input.destination === 'string' ? input.destination.trim() : ''; if (present(input.destination) && typeof input.destination !== 'string') { return refuse('destination must be "#channel", "@person" or a channel id.'); } - if (!spec && home) spec = home; - if (!spec) return refuse('resend needs a destination: "#channel", "@person" or a channel id.'); + if (!spec && !home) return refuse('resend needs a destination: "#channel", "@person" or a channel id.'); let confirmDuplicate = false; if (present(input.confirmDuplicate)) { if (typeof input.confirmDuplicate !== 'boolean') return refuse('confirmDuplicate must be true, false or null.'); confirmDuplicate = input.confirmDuplicate; } - const resolved = registry.resolveProseTarget(spec); - if ('error' in resolved) { - const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : ''; - return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`); + // A destination named in the call resolves as a `>>` target does; with + // none, a conversation fork's resend goes home, on its home's server. A + // fork acts only on its home, so a name its home answers to, its id or + // its qualified name, means its home, as in its channel tools. + let named: ChannelRef; + if (home && (spec === home.channelId || spec === qualifiedChannelName(home))) { + named = home; + } else if (spec) { + // A resend posts, so it resolves as speech does (forSpeech), and inside + // its turn a name the turn's own channel answers to means that channel. + const resolved = registry.resolveProseTarget(spec, { forSpeech: true, own }); + if ('error' in resolved) { + const cand = resolved.candidates?.length ? ` It could mean: ${resolved.candidates.join(', ')}.` : ''; + return refuse(`Destination "${spec}" did not resolve: ${resolved.error}.${cand} Nothing was sent.`); + } + named = resolved; + } else { + named = home!; } - const target = registry.resolveDestination({ channelId: resolved.channelId }); - if ('error' in target) return refuse(`Destination "${spec}": ${target.error}. Nothing was sent.`); + const target = registry.resolveDestination({ serverId: named.serverId, channelId: named.channelId }); + // Channels named as the agent names them back (nameShown). + if ('error' in target) return refuse(`Destination "${spec || registry.nameShown(home!)}": ${target.error}. Nothing was sent.`); const destination = target.destination; - if (home && destination.channelId !== home) { - return refuse(`This conversation is bound to channel ${home}; resending to ${destination.channelId} is not allowed. Nothing was sent.`); + if (home && !sameChannel(destination, home)) { + return refuse( + `This conversation is bound to channel ${registry.quotedShown(home)}; resending to ${registry.quotedShown(destination)} ` + + 'is not allowed. Nothing was sent.', + ); } // Refuse up front, before anything is published, rather than half-way. @@ -17154,13 +18067,14 @@ export class AgentFramework { channelId: outcome.destination?.channelId ?? destination.channelId, ...(outcome.messageId ? { messageId: outcome.messageId } : {}), }); - const where = AgentFramework.destinationText(outcome.destination ?? destination); + const where = this.destinationText(outcome.destination ?? destination); // A record that failed after reaching the journal was reconciled into // it, so say what the draft reads as now rather than guess. const unrecorded = recorded ? '' : ` (recording this outcome failed; the draft reads as ${this.draftStateNow(agentName, d.id)} now)`; if (outcome.status === 'delivered') { lines.push(`${d.id}: delivered to ${where}${outcome.messageId ? `, message ${outcome.messageId}` : ''}${unrecorded}.`); - sendingTurn?.engaged.add(destination.channelId); + sendingTurn?.engaged.add(destinationKey(destination)); + resender?.notePublished(destinationKey(destination), readThrough); continue; } stopped = `${d.id} was not confirmed delivered`; @@ -17200,10 +18114,10 @@ export class AgentFramework { /** Record a successful plain-prose delivery for this turn's receipt. */ private recordProseDelivery( routing: TurnRouting, - outcome: { delivered: boolean; channelId: string } | null | undefined, + outcome: { delivered: boolean; serverId: string; channelId: string } | null | undefined, ): void { if (!outcome?.delivered) return; - routing.deliveries.push(outcome.channelId); + routing.deliveries.push({ serverId: outcome.serverId, channelId: outcome.channelId }); } /** @@ -17232,15 +18146,11 @@ export class AgentFramework { routing.suppressed = 0; const seen = new Set(); const shown: string[] = []; - for (const id of list ?? []) { - if (seen.has(id)) continue; - seen.add(id); - const label = this.channelRegistry?.getDescriptor(id)?.label; - shown.push( - label && label !== id - ? `${label.startsWith('#') ? label : `#${label}`} (${id})` - : id, - ); + for (const channel of list ?? []) { + const key = destinationKey(channel); + if (seen.has(key)) continue; + seen.add(key); + shown.push(this.channelShown(channel)); } const notes: string[] = []; // Each of the turn's drafts as it stands now: a mid-turn notice lets the @@ -17292,10 +18202,10 @@ export class AgentFramework { const how = attempt.via === 'resend' ? 'by your resend' : 'by your {{unsent}}'; // A hybrid `{{unsent}}` envelope is already listed above, as the plain // speech it was; a resend is not. - if (attempt.via === 'unsent-token' && seen.has(attempt.destination.channelId)) { + if (attempt.via === 'unsent-token' && seen.has(destinationKey(attempt.destination))) { notes.push(`draft ${d.id} delivered ${how}`); } else { - shown.push(`${AgentFramework.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); + shown.push(`${this.destinationText(attempt.destination)} (draft ${d.id}, ${how})`); } } if (notHeld) { @@ -17353,10 +18263,10 @@ export class AgentFramework { * no destination" is the unremarkable boot default). Never triggers * inference; never locus-eligible itself (system + no channel metadata). */ - private announceLocusIfChanged(agentName: string, locus: string | null): void { + private announceLocusIfChanged(agentName: string, locus: ChannelRef | null): void { const hasBaseline = this.lastAnnouncedLocus.has(agentName); const prev = this.lastAnnouncedLocus.get(agentName) ?? null; - if (hasBaseline ? prev === locus : locus === null) { + if (hasBaseline ? sameChannel(prev, locus) : locus === null) { if (!hasBaseline) this.lastAnnouncedLocus.set(agentName, locus); return; } @@ -17370,11 +18280,7 @@ export class AgentFramework { '[routing] Your plain speech currently has no channel — it stays in ' + 'your archive. Use an explicit send tool to reach a channel.'; } else { - const label = this.channelRegistry?.getDescriptor(locus)?.label; - const shown = - label && label !== locus - ? `${label.startsWith('#') ? label : `#${label}`} (${locus})` - : locus; + const shown = this.channelShown(locus); const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(locus); text = inboundOnlyServer ? `[routing] The conversation is now in ${shown}. ${inboundOnlySpeechNote(inboundOnlyServer)} ` + @@ -17394,7 +18300,7 @@ export class AgentFramework { this.lastAnnouncedLocus.set(agentName, locus); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); console.error( - `[routing] ${agentName}: locus ${prev ?? '(none)'} -> ${locus ?? '(none)'} (announced in window)`, + `[routing] ${agentName}: locus ${prev ? qualifiedChannelName(prev) : '(none)'} -> ${locus ? qualifiedChannelName(locus) : '(none)'} (announced in window)`, ); } catch (err) { console.error('announceLocusIfChanged: failed to record routing notice:', err); @@ -17415,12 +18321,17 @@ export class AgentFramework { * agent is told it was addressed from elsewhere and the locus stays * (addressedElsewhereNotice). */ - private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): string | undefined { + private repinChannel(metadata: MessageMetadata | undefined, routing: TurnRouting): ChannelRef | undefined { if (!isConversationalInjection(metadata)) return undefined; const m = metadata as Record | undefined; if (typeof m?.channelId !== 'string') return undefined; - if (isAddressedMessage(m.tags as string[] | undefined)) return m.channelId; - return routing.engaged.has(m.channelId) ? m.channelId : undefined; + // The message's own server, which every channel message's metadata + // carries; one without it names its channel only if no other server + // registers that id (channelNamed). + const channel = this.channelNamed(m.channelId, typeof m.serverId === 'string' ? m.serverId : undefined); + if (!channel) return undefined; + if (isAddressedMessage(m.tags as string[] | undefined)) return channel; + return routing.engaged.has(destinationKey(channel)) ? channel : undefined; } /** Whether `agent` has a turn alive: starting, streaming or waiting on its tools. */ @@ -17455,12 +18366,7 @@ export class AgentFramework { occasion: 'event' | 'turn-start' | 'tool-boundary', ): { id: MessageId; content: ContentBlock[]; metadata: MessageMetadata } | undefined { const config = agent.clock; - if (!config || this.quiesced) return undefined; - if (this.sharedSlotAgents.has(agent)) { - for (const reader of this.sharedSlotAgents) { - if (reader !== agent && this.turnAlive(reader)) return undefined; - } - } + if (!config || this.slotHeldFrom(agent)) return undefined; let planned: ReturnType; try { planned = planClock({ now: Date.now(), ...this.lastClockOf(agent.getContextManager()), config, zone: this.timeZone }); @@ -17493,6 +18399,18 @@ export class AgentFramework { return { id, content: planned.content, metadata }; } + /** + * Whether a write the framework makes into `agent`'s window of its own + * accord (a clock row, a batched wake's line) waits: while the host is + * quiesced, or, for a reader of the residents' shared slot (the + * subconscious, which reads it through a merged view, included), while + * another reader of it has a turn alive, where it would land inside that + * turn. + */ + private slotHeldFrom(agent: Agent): boolean { + return this.quiesced || this.sharedSlotBusy(agent); + } + /** Rows a window's last-clock scan reads at a time. */ private static readonly CLOCK_SCAN_ROWS = 256; @@ -17531,10 +18449,10 @@ export class AgentFramework { const open = this.openLogical.get(agentName); if (!open) return; if (heldFromResidents(metadata as Record | undefined)) return; - const channelId = this.repinChannel(metadata, open); - if (!channelId) return; + const channel = this.repinChannel(metadata, open); + if (!channel) return; const tags = (metadata as Record | undefined)?.tags as string[] | undefined; - open.arrivals.push({ messageId, channelId, addressed: isAddressedMessage(tags) }); + open.arrivals.push({ messageId, channel, addressed: isAddressedMessage(tags) }); } /** @@ -17564,14 +18482,14 @@ export class AgentFramework { } /** - * Show the turn's typing indicator in `channelId`, stopping the one it last + * Show the turn's typing indicator in `channel`, stopping the one it last * showed elsewhere: this stream's, or one an earlier stream of the turn * left running when it handed the turn on. */ - private showTurnTyping(routing: TurnRouting, channelId: string): void { - if (routing.typing !== undefined && routing.typing !== channelId) this.channelRegistry?.stopTyping(routing.typing); - this.channelRegistry?.startTyping(channelId); - routing.typing = channelId; + private showTurnTyping(routing: TurnRouting, channel: ChannelRef): void { + if (routing.typing !== undefined && !sameChannel(routing.typing, channel)) this.channelRegistry?.stopTyping(routing.typing); + this.channelRegistry?.startTyping(channel); + routing.typing = channel; } /** @@ -17582,14 +18500,14 @@ export class AgentFramework { * message in the store; the last compile can't be the test, since an * arrival between the turn's streams isn't in it yet. */ - private remainingArrival(agent: Agent, routing: TurnRouting): string | undefined { + private remainingArrival(agent: Agent, routing: TurnRouting): ChannelRef | undefined { const cm = agent.getContextManager(); for (let i = routing.arrivals.length - 1; i >= 0; i--) { - const { messageId, channelId } = routing.arrivals[i]!; + const { messageId, channel } = routing.arrivals[i]!; // The store answers null for a message that isn't there. A store that // can't be read throws here, as anywhere else in the start, rather // than have its candidates read as withdrawn. - if (cm.getMessage(messageId)) return channelId; + if (cm.getMessage(messageId)) return channel; } return undefined; } @@ -17609,17 +18527,15 @@ export class AgentFramework { private repinTurnLocus( agent: Agent, routing: TurnRouting, - channelId: string, + channel: ChannelRef, cause: string, ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined { - if (routing.locus === channelId) return undefined; + if (sameChannel(routing.locus, channel)) return undefined; const prevPin = routing.locus ?? null; - routing.locus = channelId; - const label = this.channelRegistry?.getDescriptor(channelId)?.label; - const shown = label && label !== channelId - ? `${label.startsWith('#') ? label : `#${label}`} (${channelId})` - : channelId; - const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(channelId); + routing.locus = channel; + noteTurnChannel(routing, channel); + const shown = this.channelShown(channel); + const inboundOnlyServer = this.channelRegistry?.inboundOnlyServer(channel); const content: ContentBlock[] = [{ type: 'text', text: inboundOnlyServer @@ -17632,9 +18548,9 @@ export class AgentFramework { const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; try { const id = agent.getContextManager().addMessage('user', content, metadata); - this.lastAnnouncedLocus.set(agent.name, channelId); + this.lastAnnouncedLocus.set(agent.name, channel); this.emitTrace({ type: 'message:added', messageId: id, source: 'routing-notice' }); - console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ?? '(none)'} -> ${channelId}`); + console.error(`[routing] ${agent.name}: ${cause} re-pin ${prevPin ? qualifiedChannelName(prevPin) : '(none)'} -> ${qualifiedChannelName(channel)}`); return { participant: 'user', content, metadata }; } catch (err) { console.error('turn locus re-pin: failed to record routing notice:', err); @@ -17654,18 +18570,19 @@ export class AgentFramework { private addressedElsewhereNotice( agent: Agent, routing: TurnRouting, - channelId: string, + channel: ChannelRef, who: string, when = 'while you were busy', ): { participant: string; content: ContentBlock[]; metadata: MessageMetadata } | undefined { const pin = routing.locus ?? null; - if (channelId === pin || routing.addressedElsewhere.has(channelId)) return undefined; - routing.addressedElsewhere.add(channelId); - const where = pin ? `still lands in ${this.shownChannel(pin)}` : 'stays in your archive (no channel)'; + const key = destinationKey(channel); + if (sameChannel(channel, pin) || routing.addressedElsewhere.has(key)) return undefined; + routing.addressedElsewhere.add(key); + const where = pin ? `still lands in ${this.channelShown(pin)}` : 'stays in your archive (no channel)'; const content: ContentBlock[] = [{ type: 'text', text: - `[routing] ${who} addressed you from ${this.shownChannel(channelId)} ${when}. ` + + `[routing] ${who} addressed you from ${this.channelShown(channel)} ${when}. ` + `Your plain speech ${where} this turn; reaching them takes an explicit send.`, }]; const metadata = { system: true, kind: 'routing-notice' } as MessageMetadata; @@ -17688,11 +18605,11 @@ export class AgentFramework { private noticeAddressedArrivals(agent: Agent, routing: TurnRouting, arrivals: Arrival[], when: string): void { if (arrivals.length === 0) return; const cm = agent.getContextManager(); - for (const { messageId, channelId, addressed } of arrivals) { + for (const { messageId, channel, addressed } of arrivals) { if (!addressed) continue; const message = cm.getMessage(messageId); if (!message) continue; - this.addressedElsewhereNotice(agent, routing, channelId, authorName(message.metadata as Record | undefined), when); + this.addressedElsewhereNotice(agent, routing, channel, authorName(message.metadata as Record | undefined), when); } } @@ -17775,8 +18692,8 @@ export class AgentFramework { channels: Array<{ channelId: string; label?: string }>, cause: ChannelOpenedCause, ): ChannelNotice[] { - const shown = channels.map(shownChannel).join(', '); - const ids = channels.map((c) => c.channelId); + const shown = channels.map((c) => this.labelledChannel({ serverId, ...c })).join(', '); + const refs = channels.map((c) => ({ serverId, channelId: c.channelId })); const plural = channels.length > 1; // What the channels deliver is said only to an agent that holds their // traffic (holdsChannelTraffic), and channel_close is named only to one @@ -17803,7 +18720,7 @@ export class AgentFramework { const sender = this.agentForPrimary(agentName); return [{ text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it; ${sender.again} has ended.` + - optOut(this.holdsChannelTraffic(primary, ids), this.canCloseChannels(primary, ids)), + optOut(this.holdsChannelTraffic(primary, refs), this.canCloseChannels(primary, refs)), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} has ended)`, shown, @@ -17812,7 +18729,7 @@ export class AgentFramework { // The reader: the agent whose send opened the channels, or for a policy // admission, the agent addMessage delivers to (deliveryAgent). const who = agentName || this.deliveryAgent()?.name; - const canClose = this.canCloseChannels(who, ids); + const canClose = this.canCloseChannels(who, refs); // Where the agent whose send opened them can't close them, as a fork away // from its home can't, or an agent whose allowedTools leave channel_close // out, the primary is told by name too, where it's registered and can @@ -17820,7 +18737,7 @@ export class AgentFramework { // and the agent hears who was told. (The primary can't be that agent: it // can close them, and that agent can't.) const tellPrimary = !!agentName && !canClose && primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, ids); + this.canCloseChannels(primary, refs); const readerText = (told: string) => (cause === 'subscription-policy' ? `[channels] Selected for opening by subscription policy: ${shown}. ` + (plural @@ -17829,7 +18746,7 @@ export class AgentFramework { : 'The host opens it, and if that fails, tries again when its server next registers it; ' + "channel_list shows whether it's open yet.") : `[channels] Now open: ${shown}: your own message into the closed channel opened it.`) + - optOut(this.holdsChannelTraffic(who, ids), canClose) + told; + optOut(this.holdsChannelTraffic(who, refs), canClose) + told; const reader: ChannelNotice = { text: readerText(tellPrimary ? ` ${this.primaryForReader(agentName, primary)} has been told and can close ${plural ? 'them' : 'it'}.` : ''), ...(tellPrimary ? { untold: readerText('') } : {}), @@ -17846,7 +18763,7 @@ export class AgentFramework { const sender = this.agentForPrimary(agentName); notices.push({ text: `[channels] Now open: ${shown}: a message from ${sender.first} into the closed channel opened it, and ${sender.again} can't close ${plural ? 'them' : 'it'}.` + - optOut(this.holdsChannelTraffic(primary, ids), true), + optOut(this.holdsChannelTraffic(primary, refs), true), metadata: { system: true, kind: 'channel-notice', speaker: agentName } as MessageMetadata, told: `the primary, by name (${agentName} can't close ${plural ? 'them' : 'it'})`, shown, @@ -17857,19 +18774,19 @@ export class AgentFramework { } /** - * Whether `who` can call channel_close for every one of `channelIds` here: + * Whether `who` can call channel_close for every one of `channels` here: * its allowedTools hold it (canAgentUseTool), and a conversation fork, * whose channel_close dispatchChannelToolCall refuses for any channel but * its home, only for its home. */ - private canCloseChannels(who: string | undefined, channelIds: readonly string[]): boolean { + private canCloseChannels(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; - const home = this.registry.homeChannel(who); - return channelIds.every((id) => !home || home === id) && this.canAgentUseTool(who, 'channel_close'); + const home = this.homeOf(who); + return channels.every((channel) => !home || sameChannel(home, channel)) && this.canAgentUseTool(who, 'channel_close'); } /** - * Whether `who` holds the traffic of every one of `channelIds`, so that + * Whether `who` holds the traffic of every one of `channels`, so that * what an open channel delivers is in its window. Where conversations are * routed, an incoming message goes to the fork its channel is bound to, or * nowhere, and never to the primary (handleMcplChannelIncoming), so a fork @@ -17885,11 +18802,11 @@ export class AgentFramework { * comes whether or not the channel is open, so it isn't what opening one * delivers.) */ - private holdsChannelTraffic(who: string | undefined, channelIds: readonly string[]): boolean { + private holdsChannelTraffic(who: string | undefined, channels: readonly ChannelRef[]): boolean { if (who === undefined) return false; - const home = this.registry.homeChannel(who); + const home = this.homeOf(who); if (home !== undefined) { - return channelIds.every((id) => id === home && this.conversationRouter?.getBinding(id)?.agentName === who); + return channels.every((channel) => sameChannel(channel, home) && this.conversationRouter?.getBinding(channel)?.agentName === who); } if (this.conversationRouter) return false; return this.residentHoldsTraffic(who); @@ -17932,10 +18849,10 @@ export class AgentFramework { * notice passed on later, at resume or at boot, names it by its name. */ private agentForPrimary(agentName: string): { first: string; again: string } { - const home = this.registry.homeChannel(agentName); + const home = this.homeOf(agentName); if (home === undefined) return { first: agentName, again: agentName }; const label = this.channelRegistry?.getDescriptor(home)?.label; - const bound = label && label !== home ? `${label} (${home})` : home; + const bound = this.labelledChannel({ ...home, ...(label ? { label } : {}) }); const fork = this.forkOfPrimary(agentName) ? 'your fork' : 'the fork'; return { first: `${fork} in ${bound}, named ${agentName},`, again: 'that fork' }; } @@ -18014,7 +18931,7 @@ export class AgentFramework { channel: { channelId: string; label?: string }, selectedForOpening?: SelectedForOpening, ): ChannelNotice[] { - const shown = shownChannel(channel); + const shown = this.labelledChannel({ serverId, ...channel }); const ended = !!agentName && !this.agents.has(agentName); if (ended && !selectedForOpening) { console.error(`[channel] ${agentName} has ended; not telling it that ${shown} couldn't be opened`); @@ -18027,7 +18944,7 @@ export class AgentFramework { } const who = ended ? primary : agentName || this.deliveryAgent()?.name; const canOpen = who !== undefined && this.registry.homeChannel(who) === undefined && this.canAgentUseTool(who, 'channel_open'); - const canClose = this.canCloseChannels(who, [channel.channelId]); + const canClose = this.canCloseChannels(who, [{ serverId, channelId: channel.channelId }]); const selected = selectedForOpening === 'unrecorded' ? " It stays selected for opening, because the host couldn't record undoing that: a later delivery or reconnect tries again" : selectedForOpening !== undefined @@ -18042,7 +18959,7 @@ export class AgentFramework { // can't be the reader: it can close it, and the reader can't.) const tellPrimary = !ended && selectedForOpening !== undefined && who !== undefined && !canClose && primary !== undefined && this.agents.has(primary) && - this.canCloseChannels(primary, [channel.channelId]); + this.canCloseChannels(primary, [{ serverId, channelId: channel.channelId }]); const sender = ended ? this.agentForPrimary(agentName!) : undefined; const readerText = (told: string) => `[channels] Not open: ${shown}. ` + (ended @@ -18114,6 +19031,169 @@ export class AgentFramework { }); } + /** + * autoContinue (AgentConfig.autoContinue): after a turn the model ended + * itself (AUTO_CONTINUE_STOP_REASONS), with prose or with nothing, queue + * another, unless its last word was a skip (`>>skip_reply`, or a hybrid + * `>>>skip_reply` hold still standing). Runs at the logical turn end of a + * stream's `complete`, after the turn's deliveries and `[delivered]` + * receipt, and only when the turn asked for no ` !` continuation (that one + * runs instead). A tool that ends the turn (skip_reply, sleep) never + * reaches here, and clears the run's count. + * + * It continues only an agent that has a way to stop it is offered: the + * skip_reply tool, or in explicit/hybrid mode a skip envelope. It leaves + * a turn whose closing prose bounced to the bounce, and never continues an + * ephemeral run or a conversation fork. The wake names + * the turn's locus, or the unresolved channel of a turn that had none, so + * the next turn speaks, or doesn't, where this one did; a batched message's + * channel outranks it. Its mark (InferenceRequest.autoContinueRun) holds + * what the `[continue]` row says, written as the turn starts + * (startAgentStream). A run is capped at maxConsecutive; the last allowed + * row says so, and the cap holds until a turn starts with a cause of its own. + */ + private autoContinueAfterTurn( + agent: Agent, + routing: TurnRouting, + trigger: InferenceRequest | undefined, + stopReason: string, + opts: { heldAfterSend?: boolean } = {}, + ): void { + const config = agent.autoContinue; + if (!config) return; + const name = agent.name; + if (!AUTO_CONTINUE_STOP_REASONS.has(stopReason)) return; + // A silent heartbeat tick is a private self-check, never the start of a run. + if (trigger?.suppressProse) return; + // A subagent's run ends with its result; a conversation fork answers + // its home channel's message. + if (this.ephemeralRuns.has(name) || this.registry.homeChannel(name) !== undefined) return; + if (this.providerAdmissionClosed || this.agents.get(name) !== agent) { + console.error(`[continue] ${name}: not continued — ${this.providerAdmissionClosed ? 'the framework is stopping' : 'the agent is no longer registered'}`); + return; + } + const skipped = agent.proseRouting === 'explicit' + ? routing.lastEnvelopeSkipped === true + : agent.proseRouting === 'hybrid' && routing.hybridHold === 'private'; + if (skipped) { + this.autoContinueStreaks.delete(name); + console.error(`[continue] ${name}: turn ended on a skip_reply envelope — waiting for the next event`); + return; + } + const stop = this.autoContinueStop(agent); + if (!stop) { + console.error(`[continue] ${name}: not continued — no way to stop is offered (no skip_reply tool, no skip envelope in ${agent.proseRouting} mode)`); + return; + } + // The turn's closing prose bounced: the bounce, with its own cap, is + // explicit and hybrid mode's answer to that turn, and continuing past + // the cap would restart the loop the cap breaks. + if (routing.lastEnvelopeBounced) { + console.error(`[continue] ${name}: not continued — the turn's closing prose bounced`); + return; + } + const count = (this.autoContinueStreaks.get(name) ?? 0) + 1; + if (count > config.maxConsecutive) { + console.error(`[continue] ${name}: ${config.maxConsecutive} continuation(s) in a row already — waiting for the next event`); + return; + } + this.autoContinueStreaks.set(name, count); + console.error( + `[continue] ${name}: turn ended (${stopReason}) without skip_reply — continuation ${count} of ${config.maxConsecutive}` + + (routing.locus ? ` in ${this.channelRegistry?.nameShown(routing.locus) ?? routing.locus.channelId}` : ''), + ); + // The next turn speaks where this one did (locus and hybrid modes): the + // locus's server and id, as the framework's own wakes name a channel. A + // turn that had no channel (noChannel: its wake named one that resolves + // to none) passes its wake's channel on, so the next has none either, + // rather than taking the global fallback this one refused. + const channel = routing.locus + ? { channelId: routing.locus.channelId, channelServerId: routing.locus.serverId } + : routing.noChannel && trigger?.channelId + ? { channelId: trigger.channelId, ...(trigger.channelServerId ? { channelServerId: trigger.channelServerId } : {}) } + : {}; + this.pendingRequests.push({ + agentName: name, + reason: AUTO_CONTINUE_REASON, + source: 'framework', + timestamp: Date.now(), + ...channel, + autoContinueRun: { stop, ...(opts.heldAfterSend ? { heldAfterSend: true } : {}) }, + }); + } + + /** How an autoContinue agent stops a run, as its `[continue]` row says it, + * or undefined when no way is offered: the skip_reply tool when the + * channel registry offers it and the agent may call it, else a skip + * envelope in explicit or hybrid mode. */ + private autoContinueStop(agent: Agent): string | undefined { + if (this.channelRegistry && this.canAgentUseTool(agent.name, 'skip_reply')) return 'call skip_reply'; + if (agent.proseRouting === 'explicit') return 'end with a >>skip_reply envelope'; + if (agent.proseRouting === 'hybrid') return 'end with a >>>skip_reply envelope'; + return undefined; + } + + /** + * Store a continuation's `[continue]` row in the agent's window as its turn + * starts (startAgentStream), with the run's count as it stands and the + * agent's cap. False when it couldn't be stored. + */ + private writeAutoContinueNotice(agent: Agent, mark: AutoContinueRun): boolean { + const count = this.autoContinueStreaks.get(agent.name) ?? 1; + const max = agent.autoContinue?.maxConsecutive ?? count; + const text = autoContinueNotice({ + agentName: agent.name, stop: mark.stop, last: count >= max, heldAfterSend: mark.heldAfterSend, + }); + const before = this.storedCountOf(agent); + let id: MessageId; + try { + id = agent.getContextManager().addMessage( + 'user', + [{ type: 'text', text }], + { system: true, kind: 'auto-continue', agentName: agent.name, continuation: count, maxConsecutive: max } as MessageMetadata, + ); + } catch (err) { + // A write can throw after its row landed (context-manager's own + // propagation failing): read back from where the store stood, as the + // clock row does, so a row that is there isn't called missing. + const landed = before === undefined + ? undefined + : this.storedSince(agent, before, (m) => { + const meta = m.metadata as { kind?: unknown; agentName?: unknown } | undefined; + return meta?.kind === 'auto-continue' && meta.agentName === agent.name; + }, 'continue', 'the continuation row'); + if (!landed || landed.length === 0) { + console.error(`[continue] ${agent.name}: row not stored${landed ? '' : ' (whether it landed is unknown)'}, so not continued:`, err); + return false; + } + console.error(`[continue] ${agent.name}: the continuation row landed though its write threw:`, err); + id = landed[0]!.id; + } + this.emitTrace({ type: 'message:added', messageId: id, source: 'auto-continue' }); + console.error(`[continue] ${agent.name}: continuation ${count} of ${max} starts`); + return true; + } + + /** Whether another reader of the residents' shared slot has a turn alive, + * so no row may be written there now (as storeDueClock checks). */ + private sharedSlotBusy(agent: Agent): boolean { + if (!this.sharedSlotAgents.has(agent)) return false; + for (const reader of this.sharedSlotAgents) { + if (reader !== agent && this.turnAlive(reader)) return true; + } + return false; + } + + /** A continuation whose turn doesn't run: the run's count goes back to + * what it was before it was queued. */ + private giveBackAutoContinue(agentName: string, why: string): void { + const count = this.autoContinueStreaks?.get(agentName); + if (!count) return; + if (count <= 1) this.autoContinueStreaks.delete(agentName); + else this.autoContinueStreaks.set(agentName, count - 1); + console.error(`[continue] ${agentName}: continuation ${count} dropped — ${why}`); + } + /** The agent's routing state for its current turn, made when it has none. */ private routingOf(agentName: string): TurnRouting { let routing = this.turnRouting.get(agentName); @@ -18124,6 +19204,68 @@ export class AgentFramework { return routing; } + /** + * A turn's own channels, every channel that has been its trigger or its + * locus (TurnRouting.wasIn), so a channel_open or a re-pin adds one and + * drops none: a `>>` target, a drafts resend's destination or a channel + * tool's name one of them answers to by its id means that channel, on its + * server (ChannelRegistry.ownChannelNamed). A tool call takes them only + * when it is made inside that turn (callOwnChannels). + */ + private ownChannels(routing: TurnRouting): ChannelRef[] { + const own = [...routing.wasIn]; + for (const c of [routing.triggerChannel, routing.locus]) { + if (c && !own.some((o) => sameChannel(o, c))) own.push(c); + } + return own; + } + + /** + * The agent's current turn, where a tool call was made inside it, else + * none: a call made outside it, such as a script's left running after its + * turn, carries no turn (ToolCall.callerTurnId), and a script's call in a + * turn other than the one that started the script isn't that turn's. The + * one test for both what a call's names mean (callOwnChannels) and which + * turn's routing it may change: a channel_open's move of the trigger and + * locus, and the engagement of a send or a drafts resend. Read while the + * call is dispatched, since a script's start turn is kept only then. + */ + private callingTurnOf(agentName: string, call: ToolCall): TurnRouting | undefined { + // The agent's latest routing, or its open turn's where a silent start's + // routing is the latest: a call the open turn makes meanwhile, as a + // script it watches does, is still that turn's. + const latest = this.turnRouting.get(agentName); + const open = this.openLogical.get(agentName); + const routing = call.callerTurnId === undefined ? undefined + : latest?.turnId === call.callerTurnId ? latest + : open?.turnId === call.callerTurnId ? open : undefined; + if (!routing) return undefined; + // A script's call carries the turn watching the script, which may be a + // later turn than the one its words were written in: it's that turn's + // only where that turn is the one that started the script. + const start = this.scriptCallStarts.get(call.id); + if (start && start.turnId !== routing.turnId) return undefined; + return routing; + } + + /** What a publish a call makes is credited with having read + * (Agent.notePublished): its agent's consumed watermark at the call, or, + * for a script's call, the watermark when the run call started the + * script. A script is written then, and a later round's wait that watches + * it shares the turn's id but not what the agent has read since, so the + * script's words can't answer anything injected after it started. */ + private publishReadThrough(agent: Agent, callId: string | undefined): { branch: string; sequence: number } | null { + const start = callId === undefined ? undefined : this.scriptCallStarts.get(callId); + return start ? start.readThrough : agent.getConsumedWatermark(); + } + + /** The own channels (ownChannels) of the turn a tool call was made in + * (callingTurnOf), and none for a call made outside it. */ + private callOwnChannels(agentName: string, call: ToolCall): ChannelRef[] { + const turn = this.callingTurnOf(agentName, call); + return turn ? this.ownChannels(turn) : []; + } + /** * Explicit-prose-routing delivery gateway (docs/explicit-prose-routing.md), * entered directly: parse the `>>` prefix, resolve the target, deliver, or @@ -18140,8 +19282,9 @@ export class AgentFramework { ): Promise { // The direct entry delivers as a live round does: under the live turn's // routing. - await this.deliverAdmittedProse(agent, this.admitProse(rawText), { - routing: this.routingOf(agent.name), + const routing = this.routingOf(agent.name); + await this.deliverAdmittedProse(agent, this.admitProse(rawText, routing), { + routing, notice: round.notice ?? 'later', keepPrivate: round.keepPrivate, }); @@ -18153,12 +19296,13 @@ export class AgentFramework { * delivery posts to these resolutions, however the registry's names change * before it runs. */ - private admitProse(rawText: string): AdmittedEnvelope[] { + private admitProse(rawText: string, routing: TurnRouting): AdmittedEnvelope[] { + const own = this.ownChannels(routing); return splitExplicitEnvelopes(rawText).map((text) => { const parsed = parseProsePrefix(text); if (parsed.kind !== 'target') return { text }; try { - return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }) }; + return { text, resolved: this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true, own }) }; } catch (err) { // Admission runs in the stream's event loop, so a resolver that // throws makes the envelope bounce rather than stop the round. @@ -18281,13 +19425,13 @@ export class AgentFramework { private async deliverWithUnsent( agent: Agent, body: string, - channelId: string, + channel: ChannelRef, unsent: Draft | undefined, ): Promise { const registry = this.channelRegistry!; let attemptId: string | undefined; if (unsent) { - const target = registry.resolveDestination({ channelId }); + const target = registry.resolveDestination({ serverId: channel.serverId, channelId: channel.channelId }); if (!('error' in target)) { try { attemptId = this.proseDrafts.beginAttempt(agent.name, unsent.id, target.destination, 'unsent-token', false); @@ -18308,7 +19452,7 @@ export class AgentFramework { try { // Both callers hold a failed delivery's words as a bounce draft, whose // notice says what happened, so the registry writes no second marker. - outcome = await registry.deliverSpeech(agent.name, body, channelId, { heldOnFailure: true }); + outcome = await registry.deliverSpeech(agent.name, body, channel, { heldOnFailure: true }); } finally { if (flight) { this.draftsInFlight.delete(flight); @@ -18350,12 +19494,13 @@ export class AgentFramework { private async deliverHybridProse( agent: Agent, rawText: string, - locus: string | null, + locus: ChannelRef | null, allowLocus: boolean, round: ProseRound, ): Promise { const routing = round.routing; for (const envelope of splitHybridEnvelopes(rawText)) { + routing.lastEnvelopeBounced = false; const normalized = envelope; const attempted = normalized.startsWith('>>>'); const parsed = parseHybridProsePrefix(normalized); @@ -18369,7 +19514,7 @@ export class AgentFramework { continue; } if (parsed.kind === 'target') { - const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true }); + const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!, { forSpeech: true, own: this.ownChannels(routing) }); if ('error' in resolved) { delete routing.pin; routing.hybridHold = 'failed'; @@ -18386,15 +19531,16 @@ export class AgentFramework { } // A function replacement: a draft's words go in exactly, `$&` and `$$` included. if (usedClipboard) body = body.replaceAll('{{unsent}}', () => unsent.draft?.text ?? ''); - routing.pin = resolved.channelId; + const pinned: ChannelRef = { serverId: resolved.serverId, channelId: resolved.channelId }; + routing.pin = pinned; delete routing.hybridHold; if (!body.trim()) { console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`); continue; } try { - const outcome = await this.deliverWithUnsent(agent, body, resolved.channelId, unsent.draft); - this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, channelId: outcome.destination!.channelId } : null); + const outcome = await this.deliverWithUnsent(agent, body, pinned, unsent.draft); + this.recordProseDelivery(routing, outcome.status === 'delivered' ? { delivered: true, ...outcome.destination! } : null); if (outcome.status === 'delivered') { this.proseBounceStreaks.delete(agent.name); round.onPublished?.(outcome.destination!, body, outcome.messageId); @@ -18405,13 +19551,13 @@ export class AgentFramework { // not bounced, so its words are not re-held as a fresh draft that // invites a duplicate. if (outcome.status === 'failed') { - this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft }); + this.bounceProse(agent, parsed.body, `delivery to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>>', round, { substituted: unsent.draft }); } } } catch (err) { delete routing.pin; routing.hybridHold = 'failed'; - this.bounceProse(agent, parsed.body, `sending to ${resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' }); + this.bounceProse(agent, parsed.body, `sending to ${this.channelRegistry?.nameShown(resolved) ?? resolved.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } continue; } @@ -18499,6 +19645,8 @@ export class AgentFramework { // A private round (one that called skip_reply) asks for no publication // or continuation, even when delivery runs after that turn has ended. if (parsed.continueTurn && !round.keepPrivate) routing.continuation = true; + routing.lastEnvelopeSkipped = parsed.kind === 'private'; + routing.lastEnvelopeBounced = false; if (parsed.kind === 'private') { // The text already lives in the assistant message (window/chronicle); @@ -18521,7 +19669,7 @@ export class AgentFramework { return; } - let targetChannel: string; + let targetChannel: ChannelRef; let body: string; if (parsed.kind === 'target') { // Resolved once, when the round was admitted (admitProse), never again @@ -18536,7 +19684,7 @@ export class AgentFramework { this.bounceProse(agent, parsed.body, res.error, res.candidates, '>>', round); return; } - targetChannel = res.channelId; + targetChannel = { serverId: res.serverId, channelId: res.channelId }; // Sticky within the turn: later unprefixed segments follow this target. routing.pin = targetChannel; body = parsed.body; @@ -18573,10 +19721,10 @@ export class AgentFramework { } else if (outcome.status === 'failed') { // Unknown deliveries are marker-only: offering a fresh draft would // invite a duplicate. Keep the authored body for the re-bounce rule. - this.bounceProse(agent, envelopeBody, `delivery to ${targetChannel} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft }); + this.bounceProse(agent, envelopeBody, `delivery to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} failed: ${outcome.reason ?? 'refused'}`, undefined, '>>', round, { substituted: unsent.draft }); } } catch (err) { - this.bounceProse(agent, envelopeBody, `sending to ${targetChannel} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' }); + this.bounceProse(agent, envelopeBody, `sending to ${this.channelRegistry?.nameShown(targetChannel) ?? targetChannel.channelId} threw: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>', round, { substituted: unsent.draft, outcome: 'unknown' }); } if (!delivered && this.channelRegistry!.inboundOnlyServer(targetChannel) !== undefined) { // The channel is inbound-only by now: its server declared it after the @@ -18585,7 +19733,7 @@ export class AgentFramework { // text there would be refused, so the pin ends, as a refusal at // admission ends it, unless a later delivery has moved it. A failed // delivery to a channel that isn't inbound-only keeps it, as before. - if (routing.pin === targetChannel) delete routing.pin; + if (sameChannel(routing.pin, targetChannel)) delete routing.pin; } } @@ -18671,6 +19819,7 @@ export class AgentFramework { after: { substituted?: Draft; outcome?: 'unknown' } = {}, ): void { const name = agent.name; + round.routing.lastEnvelopeBounced = true; if (after.outcome === 'unknown' && !after.substituted) { // Words with no draft behind them, whose send threw, may have gone // out. Like an unknown outcome, that is marker-only: holding them as a @@ -18708,6 +19857,10 @@ export class AgentFramework { const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1; this.proseBounceStreaks.set(name, streak); const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : ''; + // The examples name the first candidate that is one token, as a target + // takes it, so they show the form to write, such as a server-qualified + // `a:general`; with none, a generic `#channel`. + const example = candidates?.find(isProseTargetToken) ?? '#channel'; // A bare {{unsent}} whose send failed re-bounces the draft it sent, which // a later bounce may have displaced as the latest; and {{unsent}} refuses // a draft that may already have been posted. Then only the drafts tool @@ -18718,11 +19871,11 @@ export class AgentFramework { ? 'It could not be held as a draft; the words remain in your history.' : byToken ? `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, reply with a destination plus the ` + - `token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + - `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"). ` + + `token {{unsent}}, e.g. "${prefix}${example} {{unsent}}" or "${prefix}@person {{unsent}}", or use ` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"). ` + `"${prefix}skip_reply {{unsent}}" sets it aside.` : `It is held as draft ${draft.id}; nothing is lost. To deliver it unchanged, use ` + - `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "#channel"` + + `drafts(action: "resend", draftIds: ["${draft.id}"], destination: "${example}"` + `${inherited || ownRisk ? ', confirmDuplicate: true' : ''}); ` + `drafts(action: "dismiss", draftIds: ["${draft.id}"]) sets it aside.`; const risky = inherited && draft @@ -18845,7 +19998,12 @@ export class AgentFramework { // Do not await inside processInferenceRequests: one slow auxiliary call for // this resident must not block events or other residents. Admission stays // owned and the continuation receives it after the in-flight call settles. + const parkedContinuation = trigger !== undefined && continuesTurn(trigger); + if (parkedContinuation) this.parkedContinuations.add(trigger); void this.waitForAuxiliaryIdle(agent.name).then(async () => { + // A continuation dropped while it was parked left the set then, with + // its admission given back. + if (parkedContinuation && !this.parkedContinuations.delete(trigger)) return; if (this.providerAdmissionClosed) { this.releasePrimaryProviderGate(agent.name); return; @@ -18872,8 +20030,9 @@ export class AgentFramework { // park; starting here would bypass it and run a turn against a // context the operator believes frozen. Give admission back and // requeue — the scheduler re-parks it (coalesced) until resume. - // Continuations of a held turn pass, as they do at the gate. - if (this.quiesced && !isTurnContinuation(trigger?.reason ?? '')) { + // Continuations of a held turn, and a held turn's retry, pass, as + // they do at the gate (passesQuiesce). + if (this.quiesced && !passesQuiesce(trigger)) { this.releasePrimaryProviderGate(agent.name); this.pendingRequests.push(trigger ?? { agentName: agent.name, reason: 'provider-admission:requeue', source: 'scheduler', timestamp: Date.now(), @@ -18881,6 +20040,16 @@ export class AgentFramework { console.error(`[provider-admission] ${agent.name}: host quiesced while parked — wake requeued, not started`); return; } + // A start that stands for a merged batch (MERGED_BATCH) goes back + // to the scheduler as that batch: its gate lets the turn's request + // through alone and parks the wakes until resume. + const batch = trigger && mergedBatch(trigger); + if (this.quiesced && batch) { + this.releasePrimaryProviderGate(agent.name); + this.pendingRequests.push(...batch); + console.error(`[provider-admission] ${agent.name}: host quiesced while parked — the merged start goes back as its ${batch.length} requests`); + return; + } await this.startAgentStream(agent, trigger, attempt, true); }).catch((error) => { this.releasePrimaryProviderGate(agent.name); @@ -19005,6 +20174,137 @@ export class AgentFramework { } } + /** + * Drop every continuation of `agentName`'s turn still on its way, wherever + * it waits: the queue, a provider hold (which, left empty, then releases + * with nothing to run), a park on admission (whose admission it gives + * back), or a scheduler pass's hands (which then never reach it). If one + * was there, the turn ends (endContinuedTurn), unless `drop` is null: the + * caller has ended the turn already. + */ + private dropContinuations(agentName: string, drop: ContinuationDrop | null): void { + const isContinuation = (r: InferenceRequest): boolean => r.agentName === agentName && continuesTurn(r); + // A caller's stop and an abandon end the turn, not the wakes merged into + // a start of it that hasn't taken its token (MERGED_BATCH): they go back + // in the queue. A stop() or a disposal ends the agent's work, and puts + // nothing back. + const putsBack = drop?.kind === 'caller' || drop?.kind === 'quiesce_abandoned'; + const back: InferenceRequest[] = []; + let dropped = false; + const take = (requests: InferenceRequest[]): InferenceRequest[] => { + const left: InferenceRequest[] = []; + for (const r of requests) { + if (!isContinuation(r)) left.push(r); + else { + dropped = true; + if (putsBack) back.push(...mergedWakes(r)); + } + } + return left; + }; + this.pendingRequests = take(this.pendingRequests); + const hold = this.providerAccelerationCooldowns.get(agentName); + if (hold) { + const held = hold.heldRequests.length; + hold.heldRequests = take(hold.heldRequests); + if (held > 0 && hold.heldRequests.length === 0) hold.emptiedByDrop = true; + } + for (const parked of [...this.parkedContinuations]) { + if (!isContinuation(parked)) continue; + this.parkedContinuations.delete(parked); + this.releasePrimaryProviderGate(agentName); + dropped = true; + if (putsBack) back.push(...mergedWakes(parked)); + } + for (const pass of this.passRequests) { + const requests = pass.get(agentName); + if (!requests) continue; + const left = take(requests); + requests.length = 0; + requests.push(...left); + } + this.pendingRequests.push(...back); + if (!dropped || !drop) return; + // A primary a provider hold left pending for what it released won't run + // now, when nothing else of the agent's waits (endPendingPrimary). + if (!this.pendingRequests.some((r) => r.agentName === agentName) && !this.providerAccelerationCooldowns.get(agentName)?.heldRequests.length) { + this.endPendingPrimary(agentName); + } + this.endContinuedTurn(agentName, drop); + } + + /** + * End a turn whose continuation won't run. The frame that handed the turn + * to it kept the turn open for it: its logical turn, the EventGate's + * window, the tool-result guard's batch and recovery, its typing. An + * abandon or a stop of a registered agent settles the turn as it settles a + * turn's start (endStoppedStart: one inference:aborted, the turn settled, + * the gate released). Any other drop ends it as a start dropped before it + * sends anything ends. That frame, if it hasn't let go of its token yet, + * ends on its own and settles nothing. What was deferred for the agent + * lands now, or at that frame's end. + */ + private endContinuedTurn(agentName: string, drop: ContinuationDrop): void { + console.error(`[turn-continuation] ${agentName}: its turn ends without its continuation: ${drop.why}`); + const agent = this.agents.get(agentName); + const startedAt = this.lastInferenceAt.get(agentName)?.startedAt ?? Date.now(); + if (agent && drop.kind !== 'dropped') { + const routing = this.openLogical.get(agentName) ?? this.routingOf(agentName); + this.endStoppedStart(agent, drop.kind === 'caller' ? { kind: 'caller', reason: drop.reason } : { kind: drop.kind }, + routing, startedAt, undefined, false); + } else { + const owner = this.activeTurnTokens.get(agentName); + this.abortDroppedTurn(agentName, owner ?? -1, + drop.kind === 'dropped' ? CONTINUATION_DROPPED : drop.kind === 'caller' ? (drop.reason ?? 'user') : drop.kind, startedAt); + this.channelRegistry?.stopTyping(); + if (agent) { + agent.toolResultGuard.recovering = false; + if (agent.toolResultGuard.hasPending) { + try { + agent.toolResultGuard.abandon('aborted'); + } catch (error) { + console.error(`[tool-result-guard] agent=${agentName} failed to record aborted batch:`, error); + } + } + } + const routing = this.openLogical.get(agentName) ?? this.turnRouting.get(agentName); + if (routing) this.endDroppedTurn(agentName, routing); + this.eventGate?.onInferenceEnded(agentName); + } + if (!this.activeTurnTokens.has(agentName) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agentName)) { + this.readdDrained(this.drainDeferredFor(agentName), 'continuation-drop flush'); + this.ackDeferredWrites(); + } + } + + /** + * After a failure a provider hold would take: once stop() has closed + * admission, the hold installs nothing (holdForProviderWait, + * holdProviderAcceleration), so no retry follows the turn's + * inference:failed, and the turn ends as a retry a stop cancelled ends, with + * an inference:aborted saying so. Nothing is written. True when it ended + * the turn. + */ + private endFailureAtStop(agentName: string, turnToken: number, startedAt: number): boolean { + if (!this.providerAdmissionClosed) return false; + this.abortDroppedTurn(agentName, turnToken, RETRY_CANCELLED_BY_STOP, startedAt); + return true; + } + + /** + * A turn's start whose failure a provider hold took (or, once stop() has + * closed admission, would have: endFailureAtStop) ends here. Its window + * closes now, not at the start's finally a few microtasks on: no check of + * this start is ahead, and a stop made after the failure's report must + * find the held retry (abortInference's drop), not a window nothing will + * collect. + */ + private endHeldStart(agent: Agent, checkWindow: CheckWindow, turnToken: number, startedAt: number): void { + if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent); + this.endFailureAtStop(agent.name, turnToken, startedAt); + this.eventGate?.onInferenceEnded(agent.name); + } + /** * End a turn that beginAgentTurn drops before it sends anything. The turn * emitted inference:started, and listeners that bracket a turn, such as a @@ -19055,7 +20355,7 @@ export class AgentFramework { */ private endStoppedStart( agent: Agent, - stop: { kind: 'quiesce_abandoned' } | { kind: 'caller'; reason?: string }, + stop: { kind: 'quiesce_abandoned' | 'shutdown' } | { kind: 'caller'; reason?: string }, routing: TurnRouting, turnStartedAt: number, compiledRequest: NormalizedRequest | undefined, @@ -19065,7 +20365,7 @@ export class AgentFramework { * so the note says it was stopped before it finished. */ opensTurn: boolean, ): void { - console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`); + console.error(`[turn] ${agent.name}: ${stop.kind === 'caller' ? 'a stop' : stop.kind === 'shutdown' ? "the framework's stop" : 'a quiesce abandon'} reached the turn's start before it ${opensTurn ? 'sent anything' : 'finished'}; it ends there`); this.channelRegistry?.stopTyping(); // A start that continues a turn (a budget or guard restart) follows a // frame that kept the turn's tool-result batch, and the guard's recovery, @@ -19093,7 +20393,7 @@ export class AgentFramework { if (stop.kind === 'caller') { this.settleDeliberateCancel({ ...common, reason: stop.reason ?? 'user', before: opensTurn ? 'sending' : 'finishing' }); } else { - this.settleFrameworkCancel({ ...common, kind: 'quiesce_abandoned' }); + this.settleFrameworkCancel({ ...common, kind: stop.kind }); } const last = this.lastInferenceAt.get(agent.name); if (last?.startedAt === turnStartedAt) this.lastInferenceAt.set(agent.name, { ...last, endedAt: Date.now() }); @@ -19220,22 +20520,73 @@ export class AgentFramework { // vector 25). Not once the host is quiesced, when it waits for resume(). if (assembled && !this.quiesced && this.deferredMessages.length > 0) this.flushForStart(agent, 'assembly'); + // The batched-wake lines the gate left to this start (WakeLine), decided + // now that the turn's inputs are in and before its checkpoint: on the + // delivery agent's own start (a co-resident's places none, + // placeWakeLines), from here to the compile an arrival into its window is + // deferred past this request. Not on a start that resumes a logical turn, + // as the flushes above: it takes in no new input. Each line is written at + // most once (placeWakeLines); one left out is decided again by a later + // fresh start of the same wake (a provider hold's release, a requeue), + // whose window may have taken arrivals while the turn was held. Fresh + // starts only, as the flushes: a retry re-sends the same turn, and what + // follows the message by then is mostly the first attempt's own notices or + // rounds, so a line would land after them, mid-turn. + if (trigger?.wakeLines && attempt === 0 && !resumesTurn) this.placeWakeLines(agent, trigger.wakeLines); + + // An autoContinue continuation's `[continue]` row, written here: the + // turn has passed every gate that could still stop it (a stopping + // framework, a provider park's requeue, a stated wait), and the row is + // the newest of its inputs, before its checkpoint. The trigger goes on + // without the mark, so a retry doesn't write it again. A row that can't + // be stored gives back the run's count, and withdraws a turn the + // continuation alone caused: it would wake to nothing it can read. + if (attempt === 0 && !resumesTurn && trigger?.autoContinueRun) { + const mark = trigger.autoContinueRun; + const { autoContinueRun: _mark, ...unmarked } = trigger; + trigger = unmarked; + // Another reader's turn may have started since the scheduler checked. + const busy = this.sharedSlotBusy(agent); + if (busy || !this.writeAutoContinueNotice(agent, mark)) { + this.giveBackAutoContinue(agent.name, busy ? "another resident's turn is alive in the shared slot" : 'its row could not be stored'); + if (mark.sole) { + console.error(`[continue] ${agent.name}: turn not started (the continuation was its only cause)`); + if (this.activeTurnTokens.get(agent.name) === turnToken) { + this.activeTurnTokens.delete(agent.name); + this.activeTurnTriggers.delete(agent.name); + } + if (!this.activeTurnTokens.has(agent.name) + && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) { + this.readdDrained(this.drainDeferredFor(agent.name), 'withdrawn-turn flush'); + this.ackDeferredWrites(); + } + return false; + } + } + } + // The turn has passed its gate, so from here it goes on with a copy of - // its trigger without a batch wake's marks or the mark of a wake to read - // a render. A retry of it then comes back as an ordinary wake by whatever - // door it takes (a provider hold's release, the auxiliary park's requeue, - // the tool-result guard's restart): this gate doesn't withdraw it for - // finding its batches consumed, no withdrawal of the subject's wakes - // reaches it (§4.2 withdraws a wake whose sole cause is the subject, and - // a merge carries a subject only when it is every merged request's), and - // the scheduler doesn't drop it as having nothing to read once this - // attempt's compile has moved the watermark past the render. This frame's - // activeTurnTriggers entry keeps the trigger as it came; a retry's frame - // records the copy it carries. + // its trigger without a batch wake's marks or the mark of a wake for a + // deferred write. A retry of it then comes back as an ordinary wake by + // whatever door it takes (a provider hold's release, the auxiliary park's + // requeue, the tool-result guard's restart): this gate doesn't withdraw + // it for finding its batches consumed, no withdrawal of the subject's + // wakes reaches it (§4.2 withdraws a wake whose sole cause is the + // subject, and a merge carries a subject only when it is every merged + // request's), and the scheduler doesn't drop it as having nothing to read + // once this attempt's compile has moved the watermark past the write. + // This frame's activeTurnTriggers entry keeps the trigger as it came; a + // retry's frame records the copy it carries. if (trigger?.coalescingSubject !== undefined || trigger?.coalescingBatch || trigger?.readsDeferredWrite !== undefined) { const { coalescingSubject: _subject, coalescingBatch: _batch, coalescingBatchSubjects: _subjects, readsDeferredWrite: _read, ...retry } = trigger; trigger = retry; } + // The wakes merged into this start are the turn's now: it reads them, + // so a later drop of its retry puts none of them back (MERGED_BATCH). + if (trigger && mergedBatch(trigger)) { + const { [MERGED_BATCH]: _batch, ...own } = trigger as MarkedRequest; + trigger = own; + } // Record turn checkpoint before inference (only on first attempt, not retries) if (attempt === 0 && trigger?.reason !== 'tool_result_guard_retry') { @@ -19312,13 +20663,30 @@ export class AgentFramework { } this.midTurnInputSignals.delete(agent.name); } + // At every start, opening, continuing or resuming: a head can move under + // a running turn without ending it, so the notice that writes the history + // no longer holds are still on disk goes in before this start's compile. + this.noticeHeadMoves(agent); if (opensTurn) { // The turn's trigger channel (item-3 redux): the channel of a - // channel/DM-triggered turn, and none for a heartbeat / no-trigger - // turn, which falls back to the global default rather than inheriting - // a previous turn's channel. - routing.triggerChannel = trigger?.channelId; + // channel/DM-triggered turn, with its server, and none for a heartbeat + // / no-trigger turn, which falls back to the global default rather + // than inheriting a previous turn's channel. A wake whose channel, + // given without its server, names no one registered channel (a host's + // id that two servers' channels answer to, or that none does) has none + // either, and takes no fallback: it was for a channel, and the latest + // inbound one would be a guess. A wake that gives its server names that + // pair exactly, registered or not (channelNamed), as every selector + // with a server does; while it isn't registered, the turn's speech to + // it is refused, with no fallback either. + routing.triggerChannel = trigger?.channelId + ? this.channelNamed(trigger.channelId, trigger.channelServerId) + : undefined; + noteTurnChannel(routing, routing.triggerChannel); + const unresolvedTrigger = !!trigger?.channelId && routing.triggerChannel === undefined; + if (unresolvedTrigger) routing.noChannel = true; + else delete routing.noChannel; // Resolve the turn's outbound locus now: the home channel for forks, // else the trigger channel, else the global default, which an // ephemeral run never takes (takesGlobalFallback). Ambient mid-turn @@ -19335,8 +20703,12 @@ export class AgentFramework { // (announce-on-change only — no per-turn chatter, append-only for KV // stability). if (turnProseRouting !== 'explicit' && turnProseRouting !== 'disabled') { + // None for a turn with no channel (noChannel), unless it's a fork's. const locus = this.channelRegistry?.resolveLocus(agent.name) ?? null; - if (locus !== null) routing.locus = locus; + if (locus !== null) { + routing.locus = locus; + noteTurnChannel(routing, locus); + } if (attempt === 0) this.announceLocusIfChanged(agent.name, locus); } } else { @@ -19344,8 +20716,10 @@ export class AgentFramework { // that continues the open turn. A message no tool boundary of the turn // weighed can still move it, as it would have at a boundary // (TurnRouting.arrivals). - // Only a turn that has a locus reads its arrivals' messages. Under - // `speech.locus: 'delivery'` an arrival moves nothing; one that + // A turn in a mode with a locus reads its arrivals' messages, a turn + // with no channel (noChannel) included: an addressed message, or a + // follow-up where the turn sent, gives it its channel, as a re-pin does. + // Under `speech.locus: 'delivery'` an arrival moves nothing; one that // addressed the agent from another channel is announced instead, as a // boundary would have announced it (addressedElsewhereNotice). const routesLocus = turnProseRouting === 'locus' || turnProseRouting === 'hybrid'; @@ -19378,7 +20752,8 @@ export class AgentFramework { type: 'inference:started', agentName: agent.name, // The turn's locus, kept across a start that continues the turn. - channelId: turnLocus, + channelId: turnLocus?.channelId, + ...(turnLocus ? { serverId: turnLocus.serverId } : {}), ...(trigger?.suppressProse ? { silent: true } : {}), }); // A budget restart continues the same logical inference window. The @@ -19404,9 +20779,11 @@ export class AgentFramework { // Same channel choice as driveStream's own startTyping (which stays, is // idempotent per channel, and owns the 7s refresh); the catch below stops // it on the no-driveStream failure paths (e.g. a compile refusal). + // Explicit mode's is where this start's wake came from + // (explicitTypingChannel). const earlyTypingChannel = turnProseRouting === 'explicit' - ? trigger?.channelId ?? null + ? this.explicitTypingChannel(routing, trigger) : turnLocus ?? null; if (earlyTypingChannel) this.showTurnTyping(routing, earlyTypingChannel); @@ -19445,7 +20822,7 @@ export class AgentFramework { const hookParams = this.buildBeforeInferenceParams(agent, trigger); const hookInjections = this.scopeInjectionsForAgent( agent.name, - await this.hookOrchestrator.beforeInference(hookParams), + await this.hookOrchestrator.beforeInference(hookParams, (serverId) => this.channelsForServer(agent.name, serverId)), ); if (hookInjections.length > 0) { injections = injections ? [...injections, ...hookInjections] : hookInjections; @@ -19499,6 +20876,9 @@ export class AgentFramework { continuePrompt, } = await agent.startStreamWithInjections(tools, injections, undefined, compressionTools, { silentHeartbeat: trigger?.silentHeartbeat, + // This turn's retries are the framework's (errorPolicy below), not + // membrane's inside each attempt. + membraneRetries: false, }); // The compile can wait for annotations (each batch has one spill // deadline), so check ownership, admission, and the lifecycle again. @@ -19548,9 +20928,8 @@ export class AgentFramework { // hold the turn as the boundary would rather than send it into the wait. if (this.registry.homeChannel(agent.name) !== undefined) { const wait = this.providerWaits?.active(agent.name, agent.model); - if (wait && this.holdForProviderWait(agent, wait, trigger ? [trigger] : [])) { - console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` + - `(recorded while the turn compiled); turn held, nothing sent`); + // The turn has begun (inference:started), so the hold keeps its retry. + if (wait && this.holdForProviderWait(agent, wait, trigger ? [asTurnRetry(trigger)] : [], false, () => { // The stream the compile made won't be sent: it's cancelled, and // this start's window closes, before the hold is reported, so a stop // made while inference:failed is delivered finds neither, and the @@ -19561,8 +20940,16 @@ export class AgentFramework { if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent); // The turn emitted inference:started before its compile; every turn // that does gets one terminal event, which listeners (a typing loop) - // rely on, as the failure sites' holds emit inference:failed. + // rely on. It's reported before the hold takes the turn's retry, as + // at every failure site, so a listener's stop finds nothing held. this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: providerWaitText(agent.name, agent.model, wait) }); + })) { + console.error(`[provider-wait] agent=${agent.name} model=${agent.model} binds after the compile ` + + `(recorded while the turn compiled); ${this.providerAdmissionClosed ? 'the framework is stopping, so nothing is held' + : this.agents.get(agent.name) !== agent ? 'the agent ended as the failure was reported, so nothing is held' + : this.providerAccelerationCooldowns.get(agent.name)?.heldRequests.some(isTurnRetry) ? 'turn held' + : 'the wait was released as the failure was reported, so the retry is queued'}, nothing sent`); + this.endFailureAtStop(agent.name, turnToken, turnStartedAt); this.activeStreams.delete(agent.name); this.pendingAssistantBlocks.delete(agent.name); this.channelRegistry?.stopTyping(); @@ -19647,14 +21034,16 @@ export class AgentFramework { if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) { // Capacity scheduling is not poisoned history. The cooldown timer owns // one later fresh compile; no immediate retry or exhaustion marker. - this.eventGate?.onInferenceEnded(agent.name); + // (Once stop() has closed admission, nothing is held, and the turn + // ends here: endFailureAtStop.) + this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt); return false; } const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger); if (statedWait === 'held') { // A conversation agent held by the provider's stated wait, as a // resident is: the hold's release owns the later compile. - this.eventGate?.onInferenceEnded(agent.name); + this.endHeldStart(agent, checkWindow, turnToken, turnStartedAt); return false; } @@ -19689,7 +21078,15 @@ export class AgentFramework { } // The retry re-enters startAgentStream, which replaces this frame's // turn token with its own; cleanup belongs to the innermost frame. - await this.startAgentStream(agent, trigger, attempt + 1); + // The retry is its turn's (TURN_RETRY): this frame has begun the turn, + // and once the retry's start is held at the last admission boundary + // (a conversation agent's own compression can record a wait during + // the policy's delay), this frame lets go of its token. No check of + // this start is ahead now, so its window closes first: a stop made + // once the retry is held finds the held retry (abortInference's + // drop), not a window nothing will collect. + if (this.checkAhead.get(agent) === checkWindow) this.checkAhead.delete(agent); + await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1); } else { // Terminal failure with no driveStream: this frame owns teardown. // Token-matched (like the finally in driveStream) so a stale frame @@ -19839,8 +21236,8 @@ export class AgentFramework { if (turnEnded) this.recordProviderAccelerationRecovery(agent, compiledRequest, end); // The accounting writes to the store, and a write that fails must not // skip what the boundary does after this call: the rest of a turn's - // end, or a restart's cancel and its request, and then the event's - // dispatch to modules. + // end, or a restart's trace (its cancel and its request come before + // this call), and then the event's dispatch to modules. try { if (turnEnded) { completeTurn(latestUsage, durationMs); @@ -19870,6 +21267,9 @@ export class AgentFramework { cacheCreationTokens: 0, cacheReadTokens: 0, }; + // Whether a call of this stream reported signed thinking the provider + // dropped: from then on none of its calls is a calibration sample. + let thinkingDroppedInStream = false; // Output tokens billed since this turn's assistant blocks were last // persisted. The residual over the round's visible blocks is the price of // its hidden thinking, stamped on the signed blocks at persist time @@ -19894,7 +21294,7 @@ export class AgentFramework { // reactions arriving at a tool boundary must not redirect the agent's // task narration (2026-07-21 Cairn misroute). The locus lives in the // turn's routing state, so every stream of the turn shares it. - const resolveTurnLocus = (): string | null => streamRouting.locus ?? null; + const resolveTurnLocus = (): ChannelRef | null => streamRouting.locus ?? null; // Ordered delivery chain for live-routed prose. Links are enqueued // WITHOUT awaiting in the stream-event loop — an awaited network post @@ -19989,11 +21389,21 @@ export class AgentFramework { } }; - const enqueueSpeech = (text: string, locus: string | null): void => { + // A sink for publishes written having read through `readThrough`: each + // is noted on the agent (Agent.notePublished) and streamed. A round's is + // made as the round yields, since its deliveries run on the speech + // chain, which a tool boundary doesn't wait for, so a later round's + // reading would otherwise be credited to its words. + const publishedFrom = (readThrough: { branch: string; sequence: number } | null): SpeechPublished => + (to, text, messageId) => { + agent.notePublished(destinationKey(to), readThrough); + streamSpeech(to, text, messageId); + }; + const enqueueSpeech = (text: string, locus: ChannelRef | null, onPublished: SpeechPublished): void => { chainSpeech(async () => { const outcome = await this.channelRegistry!.routeSpeech(agent.name, text, locus); this.recordProseDelivery(streamRouting, outcome); - if (outcome?.delivered) streamSpeech(outcome, text, outcome.messageId); + if (outcome?.delivered) onPublished(outcome, text, outcome.messageId); }, 'mid-turn speech routing failed:'); }; @@ -20018,6 +21428,10 @@ export class AgentFramework { // historical whole-turn routing so fallback-mode prose is still // delivered exactly once, at turn end. let liveProseRouting = false; + // What the turn's first request had read, taken at its round: on the + // fallback path the prose routed at the turn's end is the whole turn's, + // so it's credited with this, which every round's words come after. + let firstRoundRead: { branch: string; sequence: number } | null | undefined; // XML/legacy tool mode has no roundContent, so its final response contains // prose from every round. A refused skip_reply's round stays private while // later rounds remain deliverable: the turn-end delivery drops that round's @@ -20046,16 +21460,19 @@ export class AgentFramework { // can move it between rounds, and followTurnLocus (below) moves the // indicator with it at the next round's first content, or at its // retry after a refusal. - // - explicit mode: there is no locus — indicate on the TRIGGER channel - // (where the wake came from). This doesn't violate never-guess: it - // says "attending to what you sent here", which is true regardless - // of where the reply goes. Heartbeat/no-trigger explicit turns show - // no indicator. + // - explicit mode: there is no locus — indicate on the TRIGGER channel, + // where this start's wake came from, as the start's opening showed + // it (explicitTypingChannel), not read again after its hooks and + // compile. This doesn't violate never-guess: it says "attending to + // what you sent here", which is true regardless of where the reply + // goes. A start whose wake names no channel keeps what the turn + // shows; a heartbeat explicit turn shows none, and a turn with no + // channel doesn't read a wake that gives no server. let typingChannel = turnProseRouting === 'disabled' ? null : turnProseRouting === 'explicit' - ? trigger?.channelId ?? null + ? streamRouting.typing ?? null : resolveTurnLocus(); if (typingChannel) this.showTurnTyping(streamRouting, typingChannel); // Locus and hybrid mode: move the indicator to the turn's locus when it @@ -20064,7 +21481,7 @@ export class AgentFramework { const followTurnLocus = (): void => { if (turnProseRouting !== 'locus' && turnProseRouting !== 'hybrid') return; const locus = resolveTurnLocus(); - if (!locus || locus === typingChannel) return; + if (!locus || sameChannel(locus, typingChannel)) return; this.showTurnTyping(streamRouting, locus); typingChannel = locus; }; @@ -20162,6 +21579,10 @@ export class AgentFramework { // deliveries have run. Explicit mode doesn't hold the turn // for them, so it checks at the end of its chain. if (cancelKind === 'turn_ended') { + // A tool ended the turn (skip_reply, sleep): the agent stopped on + // purpose, so a later autoContinue run counts from one again. + // (Optional chaining: prototype-built harnesses leave the map unset.) + this.autoContinueStreaks?.delete(agent.name); if (turnProseRouting === 'explicit') { void turnSpeechChain.then(() => this.requestProseContinuation(agent, streamRouting)); } else { @@ -20222,7 +21643,7 @@ export class AgentFramework { // The channel the typing indicator above shows, the conversation // the turn is attending: lets trace consumers tag every chunk // with it, without re-deriving routing. - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }; if (agent.toolResultGuard.hasSubmittedPending || agent.toolResultGuard.recovering) { guardHeld.push(trace); @@ -20253,7 +21674,7 @@ export class AgentFramework { content: '', blockType: 'text', blockIndex: 0, - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }); break; } @@ -20266,7 +21687,7 @@ export class AgentFramework { phase, blockType: block.type, blockIndex: index, - channelId: typingChannel ?? undefined, + channelId: typingChannel?.channelId, }); break; } @@ -20398,10 +21819,15 @@ export class AgentFramework { (event.context as { supportsInjectedMessages?: boolean }).supportsInjectedMessages ?? true; // Queued under this turn's routing, whenever it posts, and // streamed by this stream once published. + // This round's request was answered: what it carried is heard. + const roundRead = agent.getConsumedWatermark(); + if (firstRoundRead === undefined) firstRoundRead = roundRead; + agent.markAnswered(roundRead); + const roundPublished = publishedFrom(roundRead); const holdOpts: ProseRound = { routing: streamRouting, notice: presentsInjections ? 'now' : 'later', - onPublished: streamSpeech, + onPublished: roundPublished, }; // Routing publishes trimmed segments; held drafts keep the runs // exactly as written. @@ -20411,7 +21837,7 @@ export class AgentFramework { // An explicit round is admitted as it ends: its targets are // resolved once, for its queued delivery. const roundAdmitted = turnProseRouting === 'explicit' - ? roundSegments.map((seg) => this.admitProse(seg)) + ? roundSegments.map((seg) => this.admitProse(seg, streamRouting)) : []; const routeRound = (calls: readonly RoundCall[]): void => { if (!this.channelRegistry) return; @@ -20496,7 +21922,7 @@ export class AgentFramework { `[routing] ${agent.name}: mid-turn round [${toolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${roundLocus ?? '(default)'}`, ); for (const seg of roundSegments) { - enqueueSpeech(seg, roundLocus); + enqueueSpeech(seg, roundLocus, roundPublished); } } }; @@ -20564,6 +21990,13 @@ export class AgentFramework { case 'complete': { streamTerminal = true; adoptInjectedRound(); + // The final request was answered, and the prose routed below was + // written by it when rounds route live. On the fallback path it's + // the whole turn's, so it's credited with what the first request + // had read, whether or not a boundary moved the watermark since. + const finalRead = agent.getConsumedWatermark(); + agent.markAnswered(finalRead); + const endPublished = publishedFrom(liveProseRouting || firstRoundRead === undefined ? finalRead : firstRoundRead); const durationMs = Date.now() - startTime; let response = event.response; const guardCategory = (response.raw?.response as { @@ -20814,8 +22247,8 @@ export class AgentFramework { // The final text is delivered under this turn's routing, as its // queued rounds are. const finalRouting = streamRouting; - const admittedSpeechSegments = admitFinal ? finalSpeechSegments.map((seg) => this.admitProse(seg)) : []; - const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg)) : []; + const admittedSpeechSegments = admitFinal ? finalSpeechSegments.map((seg) => this.admitProse(seg, finalRouting)) : []; + const admittedSegments = admitFinal ? finalSegments.map((seg) => this.admitProse(seg, finalRouting)) : []; const du = response.details?.usage; // A refusal's note carries what completeTurn counts. @@ -20997,7 +22430,7 @@ export class AgentFramework { if (speechContent.length > 0 && this.channelRegistry) { const speechRuns = finalSpeechRuns; const speechSegments = finalSpeechSegments; - const textOnlyHold: ProseRound = { routing: finalRouting, notice: 'later', onPublished: streamSpeech }; + const textOnlyHold: ProseRound = { routing: finalRouting, notice: 'later', onPublished: endPublished }; if (speechSegments.length > 0) { if (turnProseRouting === 'disabled') { console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`); @@ -21037,13 +22470,13 @@ export class AgentFramework { // frozen pin is immune to both races. const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: text-only turn -> routing speech -> ${locus ? qualifiedChannelName(locus) : '(none)'}`, ); for (const segment of speechSegments) { try { const outcome = await this.channelRegistry.routeSpeech(agent.name, segment, locus); this.recordProseDelivery(streamRouting, outcome); - if (outcome?.delivered) streamSpeech(outcome, segment, outcome.messageId); + if (outcome?.delivered) endPublished(outcome, segment, outcome.messageId); } catch (err) { console.error('speech routing failed:', err); } @@ -21080,7 +22513,7 @@ export class AgentFramework { : turnSilenced || fallbackCause !== null; const trailingCause: 'send' | 'private' | null = silenceCause === 'private' || fallbackCause === 'private' ? 'private' : (silenceCause ?? fallbackCause); - const trailingHold: ProseRound = { routing: finalRouting, notice: 'later', onPublished: streamSpeech }; + const trailingHold: ProseRound = { routing: finalRouting, notice: 'later', onPublished: endPublished }; const runs = finalRuns; const segments = finalSegments; @@ -21134,14 +22567,14 @@ export class AgentFramework { // one channel must not land its postscript in another. const locus = resolveTurnLocus(); console.error( - `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(none)'}`, + `[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ? qualifiedChannelName(locus) : '(none)'}`, ); // Deliver sequentially (await each) so the segments land in order. for (const seg of segments) { try { const outcome = await this.channelRegistry.routeSpeech(agent.name, seg, locus); this.recordProseDelivery(streamRouting, outcome); - if (outcome?.delivered) streamSpeech(outcome, seg, outcome.messageId); + if (outcome?.delivered) endPublished(outcome, seg, outcome.messageId); } catch (err) { console.error('speech routing failed:', err); } @@ -21166,7 +22599,17 @@ export class AgentFramework { // A `!` continuation this turn asked for. This gives prose the // same continuation ability tool rounds have (wanted for // robotics-style loops where an end-of-turn pause is harmful). + // Otherwise autoContinue, for an agent that has it, continues a + // turn the model ended itself unless that end was a skip. + const askedToContinue = finalRouting.continuation; this.requestProseContinuation(agent, finalRouting); + if (!askedToContinue) { + // Closing words held, not sent, because the turn had already + // sent with a send tool: the continuation's row says so. + const heldAfterSend = turnProseRouting !== 'explicit' && turnSilenced && silenceCause === 'send' + && terminalContent.some((b) => b.type === 'text' && (b as { text: string }).text.trim().length > 0); + this.autoContinueAfterTurn(agent, finalRouting, trigger, response.stopReason, { heldAfterSend }); + } break; } @@ -21263,12 +22706,16 @@ export class AgentFramework { this.abandonAgentScripts(agent.name, 'stream error', streamRouting); + // A hold takes the turn's retry, or, once stop() has closed + // admission, the turn ends here (endFailureAtStop). if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) { + this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name); break; } const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger); if (statedWait === 'held') { + this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name); break; } @@ -21305,7 +22752,8 @@ export class AgentFramework { } return; } - await this.startAgentStream(agent, trigger, attempt + 1); + // Marked as its turn's retry, as at the start's failure. + await this.startAgentStream(agent, trigger && asTurnRetry(trigger), attempt + 1); } else { // The turn ends here, and its inference:failed doesn't say so, // since a retry usually follows one: an inference:aborted @@ -21501,7 +22949,10 @@ export class AgentFramework { getContextManager?: () => { getStrategy?: () => unknown }; }).getContextManager?.()?.getStrategy?.() as | { - reportRealInputTokens?: (n: number) => void; + reportRealInputTokens?: ( + n: number, + overhead?: Pick, + ) => void; reportKvUnifiedAccepted?: (args: { submissionId: string; acceptedAt?: number; @@ -21510,8 +22961,35 @@ export class AgentFramework { reportKvUnifiedFailed?: (submissionId: string) => void; } | undefined; + // The provider's total counts the system prompt and the tool + // definitions this request sent, and the strategy's estimate of + // its compile doesn't, so they go with the sample: left out, the + // multiplier learns them as a per-token rate, and a tool-heavy + // agent's samples can fall out of its band for good + // (context-manager #323). A strategy from before then ignores + // the second argument. + // A call whose report names thinking the provider dropped billed + // less than the request held: the strategy still counts those + // blocks, so the call's ratio would pull its calibration low. The + // strategy takes the first call it's given after a compile as + // that compile's one sample, and each later call of the stream + // carries what the stream appended, so once a call has dropped + // blocks, none of the stream's later calls is given either. A + // compile whose first call dropped blocks gives no sample, and the + // calibration keeps its value. If the provider drops an earlier + // model's blocks after a model switch (`model_binding_mismatch`), + // they drop on every call until they leave the context, and the + // calibration takes up the new model's tokenizer only after that. + const thinkingReport = event.round?.thinking; + const thinkingDropped = (thinkingReport?.dropped.length ?? 0) > 0; + if (thinkingDropped) thinkingDroppedInStream = true; try { - strat?.reportRealInputTokens?.(realTotal); + if (!thinkingDroppedInStream) { + strat?.reportRealInputTokens?.( + realTotal, + compiledRequest ? { system: compiledRequest.system, tools: compiledRequest.tools } : undefined, + ); + } } catch { /* calibration is best-effort */ } const kvSubmission = takeKvSubmission?.(); @@ -21539,6 +23017,9 @@ export class AgentFramework { cacheCreation: event.usage.cacheCreationTokens, cacheRead: event.usage.cacheReadTokens, }, + ...(thinkingReport && (thinkingDropped || thinkingReport.mismatchAllowed.length > 0) + ? { thinking: { dropped: thinkingReport.dropped, mismatchAllowed: thinkingReport.mismatchAllowed } } + : {}), }); break; } @@ -21626,10 +23107,16 @@ export class AgentFramework { const durationMs = Date.now() - startTime; // The stream won't go on: the agent stops holding it before the // failure is reported, as at the stream's error event, so a stop made - // while inference:failed is delivered finds no stream. + // while inference:failed is delivered finds no stream. It's reported + // once, before a provider hold takes the turn's retry, as at the + // stream's error event and at a start's failure: a listener's stop + // finds nothing held, and one made after it ends the held turn. agent.reset(); + this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack }); if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) { - this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack }); + // Once stop() has closed admission, nothing is held, and the turn + // ends here (endFailureAtStop). + this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime); logStream({ success: false, error: `Provider acceleration cooldown: ${err.message}`, request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs }); @@ -21643,7 +23130,7 @@ export class AgentFramework { // says why it ends. const statedWait = ownsProviderGate ? undefined : this.nonOwningProviderWait(agent, err, trigger); if (statedWait === 'held') { - this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack }); + this.endFailureAtStop(agent.name, myTurnToken ?? -1, startTime); logStream({ success: false, error: `Provider wait: ${err.message}`, request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs }); @@ -21651,12 +23138,6 @@ export class AgentFramework { lifecyclePhase = 'failed'; agent.reset(); if (this.agents.get(agent.name) === agent && agent.streamId === myStreamId) this.eventGate?.onInferenceEnded(agent.name); return; } - this.emitTrace({ - type: 'inference:failed', - agentName: agent.name, - error: err.message, - stack: err.stack, - }); this.settleAgent(agent.name, { stopReason: 'exhausted', speech: '', @@ -21841,7 +23322,11 @@ export class AgentFramework { // its ids when a successor turn has already taken the agent. A fork or // run deregistered while the host is quiesced leaves its queue to its // window's release at resume (drainDeferredFor). - if (frameReachedTerminal && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) { + // A frame that handed its turn to a continuation since dropped + // (endContinuedTurn, which couldn't land it while this frame held the + // token) lands it too: the turn is over. + const handedOffTurnOver = preserveEventGateForSuccessor && !this.hasTurn(agent.name); + if ((frameReachedTerminal || handedOffTurnOver) && this.deferredMessages.length > 0 && !this.pendingAssistantBlocks.has(agent.name)) { this.readdDrained(this.drainDeferredFor(agent.name), 'turn-end flush'); this.ackDeferredWrites(); } @@ -21864,8 +23349,11 @@ export class AgentFramework { // frame that ends the turn disposes it, after the flush above, so what // was held for the turn's end lands in the fork's store instead of // being dropped as addressed to no agent. A frame that hands the turn - // to a successor (a guard or budget restart) leaves the fork to it. - if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name)) { + // to a successor (a guard or budget restart) leaves the fork to it, and + // so does one whose failure a provider hold keeps for its retry: the + // turn isn't over (pendingContinuationAgents). + if (frameReachedTerminal && ownsPhysicalStream && this.closingConversationAgents.has(agent.name) + && !this.pendingContinuationAgents().has(agent.name)) { this.disposeConversationAgent(agent.name); } } @@ -21874,12 +23362,15 @@ export class AgentFramework { /** * Execute a tool call and return the result. * Routes to the appropriate handler (module registry or MCPL). - * Used by SubagentModule to dispatch tool calls for ephemeral agents. + * Its one caller in the framework is the operator's puppet + * (puppetToolCall), which puts the agent's inline cap on the call as + * resultMaxChars; a host may call it as well. * - * This public entry always carries AGENT origin. Ephemeral/promise-based - * callers are models, and provenance must identify which internal path a - * call entered through — a public method shared with model callers can - * never confer module trust, whatever the input claims. Module origin + * This public entry always carries AGENT origin. Its callers act for an + * agent, the puppet as the agent and a host for the model it runs, and + * provenance must identify which internal path a call entered through — a + * public method shared with model callers can never confer module trust, + * whatever the input claims. Module origin * exists only through the private closure handed to ModuleRegistry * (ctx.callTool → executeToolCallFrom with {kind:'module'}), which is exempt * from agent allowlists. Registered agents' allowedTools is enforced at @@ -21957,11 +23448,16 @@ export class AgentFramework { `puppet refused: the store is under live ${this.surgeryHold.verb} for ${this.surgeryHold.agentName} — retry when it completes`, ); } - if (agent.state.status !== 'idle' || this.activeTurnTokens.has(agentName)) { + if (agent.state.status !== 'idle' || this.hasTurn(agentName)) { const shown = agent.state.status === 'idle' ? 'idle+turn-alive' : agent.state.status; - throw new Error( - `puppet refused: agent ${agentName} is ${shown} (requires idle — ` + - `injecting a turn under an active stream corrupts wire ordering)`, + // A turn a provider hold keeps for its retry has no stream: the refusal + // names the hold, and how to go on sooner, as surgery's does. + const held = agent.state.status === 'idle' ? this.heldTurnRetry(agentName) : undefined; + throw new Error(held + ? `puppet refused: agent ${agentName} is idle+turn-alive (${held.says}; requires idle); ` + + heldTurnWayOn(held.statedWait) + : `puppet refused: agent ${agentName} is ${shown} (requires idle — ` + + `injecting a turn under an active stream corrupts wire ordering)`, ); } const onSurface = this.getToolsForAgent(agentName) @@ -22093,9 +23589,10 @@ export class AgentFramework { } if (isPresentationTool(call.name)) return this.editToolPresentation(call.callerAgentName ?? '__ephemeral__', call); - // Client-side programmatic tool calling for promise-based callers - // (SubagentModule ephemerals). Keyed by callerAgentName, so each - // ephemeral's scripts are its own. + // Client-side programmatic tool calling for callers that await a tool's + // result: the puppet and a host through executeToolCall, and a module + // through callTool. Keyed by callerAgentName, so each agent's scripts are + // its own. if (call.name === CODE_EXECUTION_TOOL_NAME && this.codeExecution) { return this.runCodeExecution(call.callerAgentName ?? '__ephemeral__', call); } @@ -22120,21 +23617,26 @@ export class AgentFramework { // here. if (call.name.startsWith('channel_') && this.channelRegistry) { const input = call.name === 'channel_publish' && call.callerAgentName - ? this.publishInputWithTurnLocus(call.input, call.callerAgentName) + ? this.publishInputWithTurnLocus(call.input, call.callerAgentName, this.callingTurnOf(call.callerAgentName, call)) : call.input; return this.channelRegistry.handleChannelToolCall(call.name, input, origin); } // drafts: the calling resident's own held drafts. A puppeted call has - // agent origin and acts as that resident, as the model's own call does - // through dispatchToolCall. A module holds no drafts. + // agent origin and acts on that resident's drafts, as the model's own + // call does through dispatchToolCall, but it isn't made inside the + // resident's turn, so it takes none of the turn's channels, engages none, + // and isn't noted as the resident's speech (callingTurnOf). A module + // holds no drafts. if (call.name === 'drafts' && this.channelRegistry && origin.kind === 'agent') { return this.draftsToolResult(origin.agentName, call.input); } - // The utils meta-tool — programmatic callers (code_execution scripts, - // ephemerals, ModuleContext.callTool) reach utilities the same way the - // model does. + // The utils meta-tool, for callers that await a tool's result. A call + // through executeToolCall (the puppet, a host) reaches what its agent's + // allowedTools permits, as the model's does, and an unidentified caller + // is unrestricted, as executeToolCall's doc says; a module, through + // callTool, is trusted and reaches every utility. if (call.name === 'utils') { return this.handleUtilsToolCall(call.callerAgentName ?? '__ephemeral__', call, { inProcess: true, trustedModule: origin.kind === 'module' }); } @@ -22147,7 +23649,9 @@ export class AgentFramework { if ((call.name === 'tune_out' || call.name === 'focus') && this.tuneOutCoordinator) { const agentName = call.callerAgentName ?? this.primaryAgentName ?? '__ephemeral__'; const input = (call.input ?? {}) as Record; - return call.name === 'focus' ? this.handleFocusTool(agentName, input) : this.handleTuneOutTool(agentName, input); + // The calling turn's own channels, as the channel tools read them here (origin.own). + const own = origin.kind === 'agent' ? origin.own ?? [] : []; + return call.name === 'focus' ? this.handleFocusTool(agentName, input, own) : this.handleTuneOutTool(agentName, input, own); } return this.moduleRegistry.handleToolCall(call); } @@ -22374,7 +23878,12 @@ export class AgentFramework { !this.liveTurns(agentName, undefined).includes(call.callerTurnId)) { return refuse("the turn this call was made in has ended, and only the primary agent's scripts outlive a turn, so no script starts for it. Nothing was run."); } - r = await ce.scripts.run(agentName, fields.code, yieldMs, { ...(origin ? { origin } : {}), watch }); + // What the agent has read as the run call starts the script: the + // reading its words were written with (publishReadThrough). + r = await ce.scripts.run(agentName, fields.code, yieldMs, { + ...(origin ? { origin } : {}), watch, + startReadThrough: this.agents.get(agentName)?.getConsumedWatermark() ?? null, + }); break; case 'wait': case 'stop': @@ -22459,22 +23968,47 @@ export class AgentFramework { } /** - * The first read-write mount without an include, or null. The framework - * keeps its own files there (`tool-results/`, `refs/`). A mount with an - * include holds only the files it names, so it would refuse them, and a - * setup whose only writable mount has one is treated as having none. + * The first read-write mount without an include that isn't configured + * direct, or null. The framework keeps its own files there + * (`tool-results/`, `refs/`). A mount with an include holds only the files + * it names, so it would refuse them, and a setup whose only writable mount + * has one, or is direct, is treated as having none. */ private firstWritableMountName(workspace: WorkspaceModule): string | null { const mounts = (workspace as unknown as { - getMounts?: () => Array<{ name: string; mode: string; include?: readonly string[] }>; + getMounts?: () => Array<{ name: string; mode: string; include?: readonly string[]; backend?: 'direct' }>; }).getMounts?.(); if (mounts) { - const rw = mounts.find((m) => m.mode !== 'read-only' && m.include === undefined); + // Not a mount configured direct, whether it runs direct or as + // 'chronicle' this session: it's a directory agents write directly + // when it runs direct, so these, the framework's files about the + // agent's calls, would stay there after an undo, and in a checkout, as + // a direct mount often is, `git status` would show them. + const rw = mounts.find((m) => m.mode !== 'read-only' && m.include === undefined && m.backend !== 'direct'); return rw?.name ?? null; } return null; } + /** + * Why firstWritableMountName found no mount, for the words that say so: + * none writable without an include, or only mounts configured direct, which + * it passes over. + */ + private noFileMountText(): string { + return this.directPassedOver() + ? 'no writable workspace mount without an include that keeps its files in the store' + : 'no writable workspace mount without an include'; + } + + /** Whether firstWritableMountName passed over a writable mount without an include because it's configured direct. */ + private directPassedOver(): boolean { + const mounts = (this.getWorkspaceModule() as unknown as { + getMounts?: () => Array<{ mode: string; include?: readonly string[]; backend?: 'direct' }>; + } | null)?.getMounts?.(); + return mounts?.some((m) => m.mode !== 'read-only' && m.include === undefined && m.backend === 'direct') ?? false; + } + /** * A script's word to its agent: a wake_agent() payload, or the ending of a * left script that failed, crashed or was stopped at its limit. It enters @@ -22502,16 +24036,18 @@ export class AgentFramework { const target = this.noticeTargetFor(agentName, `script ${scriptId} wake`); if (!target) throw new Error(`agent ${agentName} has no context to wake`); try { + const placement: { agent?: string; deferredId?: string } = {}; const messageId = this.addMessage('user', [{ type: 'text', text: shown }], { source: 'code-execution-script', scriptId, tags: ['script:wake'], - }, target); + }, { ...target, placement }); this.pendingRequests.push({ agentName, reason: 'script:wake', source: 'code_execution', timestamp: Date.now(), + ...this.readsDeferredFor(placement, agentName), }); this.emitTrace({ type: 'message:added', @@ -22576,7 +24112,7 @@ export class AgentFramework { for (const tc of toolResults) { const { text, shown, stored } = alone.get(tc.id)!; spilled.set(tc.id, byShare - ? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written) + ? await this.spillOrTruncate(text, share!.cap, `${dateLabel}-${tc.id}`, shown, share!.bound, stored.written, { readCap: maxChars! }) : stored); } const blocks: ContentBlock[] = toolResults.map(tc => ({ @@ -22636,7 +24172,7 @@ export class AgentFramework { } /** Where a tool result labelled `label` is written: `/tool-results/