Skip to content

feat(skip_reply): name the heartbeat's wake due before the self-wake - #858

Merged
antra-tess merged 4 commits into
mainfrom
skip-reply-heartbeat-aware
Oct 27, 2026
Merged

antra-tess merged 4 commits into
mainfrom
skip-reply-heartbeat-aware

Conversation

@antra-tess

@antra-tess antra-tess commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

skip_reply with wake_in_seconds confirms Turn ended; nothing sent. Self-wake in ~900s unless something wakes you first. The built-in heartbeat shipped in 0.28.0 (#827). When its next tick, or a pending reminder, is due before the self-wake and wakes the agent, that turn cancels the self-wake: any turn start does (EventGate.onInferenceStarted). The note gave the agent no sign that its timer would go with that wake, or that it would need to set it again.

Change

  • The note adds the heartbeat's wake due before the self-wake, keeping the usual line:
    Turn ended; nothing sent. Self-wake in ~900s unless something wakes you first. Your heartbeat (a check-in prompt, every 10 min) is due sooner, in ~300s; if it wakes you, the self-wake is cancelled, so set it again for the ~600s left if you still want it.
    A reminder reads Your reminder "stretch" is due sooner, in ~120s; …. Only a wake the agent's rules as they stand would let wake it is added.
  • It says when the heartbeat is due, not that it will wake the agent. A debounced batch or a queued request can wake the agent sooner, and a rate limit with no token left, a gate.js, a cooldown or a host's inference policy can hold the tick back. The note stays true in each of those cases. An earlier round of this PR said "wakes you first", which the reviews below showed can be false.
  • Everything else reads as before: a skip with no wake, a self-wake due first, a wake the rules hold back, an agent the heartbeat doesn't wake (forks, ephemeral runs, the subconscious), and a host without the built-in heartbeat. The framework can't see heartbeat-mcpl's schedule.
  • The gate is asked about each wake as the event its delivery is judged by: a silent tick as silent, each with its text as of its due time. This applies to the reply judge's list as well as to the note. Before, both kinds were probed with the placeholder text probe. Ticks and reminders share the server id heartbeat, so a rule could only tell them apart by text (or by the silent flag), which that probe never showed it. Filed as .fixed.
  • Where the wording lives: skipReplyNote (src/mcpl/skip-reply-wake.ts). The framework owns the gate and the heartbeat. It passes the wake to the registry as the agent origin's earlierWake, as trusted dispatch context, never read from tool input. If reading the heartbeat's wakes fails, the note goes out without that line (the timer is already armed by then).
  • API additions: Heartbeat.scheduledWakes(whose?), returning HeartbeatScheduledWake[] (exported): an UpcomingWake plus kind, silent and text. whose is 'its' (the default, the judge's wording) or 'your' (the agent's). upcomingWakes() keeps 0.28.0's signature. Filed as .added.
  • A tick past the last time a date can show lists its message without a time instead of throwing; a host's intervalSeconds has no ceiling.

Changelog

Three fragments, following review: .changed for the note's wording on a released confirmation, .fixed for the judge list's probe (0.28.0 behavior), and .added for scheduledWakes and HeartbeatScheduledWake.

Tests

  • test/skip-reply-wake.test.ts: skipReplyNote with no wake (even beside a heartbeat), a self-wake alone, a heartbeat or reminder due sooner with the time left, and the floor of 1 s left.
  • test/skip-reply-wake-capability.test.ts: the registry adds origin.earlierWake, but only beside a wake it set.
  • test/heartbeat.test.ts:
    • scheduledWakes gives each wake's kind, silent and text, and words it for 'your'. upcomingWakes() lists the same wakes.
    • A tick past the date range lists its message alone.
    • Framework-level, with a real gate and channel registry:
      • the tick is added; a self-wake due first gets the plain note; a reminder is added;
      • ticks held back by text leave the reminder added, and the reverse; both held back, or held back by source: "heartbeat", give the plain note;
      • a metadataTrue: ["silent"] rule holds back a silent tick, and a content rule holds back one reminder but not another;
      • a fork, a paused heartbeat and no heartbeat get the plain note.
  • Mutation-checked: the old probe shape, dropping the target check and ignoring the rules each fail them.
  • Against main's source, skip-reply-wake.test.ts fails as a module (it imports the new skipReplyNote), and the new cases in the other two files fail.
  • CI green on each head so far. Locally the affected files pass (90/90). The full local suite ran on a machine loaded by other sessions' test runs, and some unrelated slow files timed out.

🤖 Generated with Claude Code

…self-wake

skip_reply's confirmation promised "Self-wake in ~Ns unless something wakes
you first" even when the framework knew its own heartbeat tick or a reminder
would wake the agent sooner, and that turn would cancel the self-wake (any
turn start does). The agent then expected a wake that never came.

When the framework arms a skip_reply self-wake, it now finds the heartbeat's
soonest tick or reminder due before it that the agent's rules let wake the
agent, and the confirmation names it:

  Turn ended; nothing sent. Your heartbeat (a check-in prompt, every 10 min)
  wakes you first, in ~300s, and that wake cancels your ~900s self-wake: set
  it again then if you still want it.

A skip with no wake, a self-wake due first, or a heartbeat the rules hold
back reads as before. The wording lives in skipReplyNote; the framework
passes the first wake to the registry through the agent origin (firstWake).
Heartbeat.upcomingWakes() gives each wake's kind and takes whose ("its" for
the judge, "your" for the agent), replacing a prefix match on "its reminder".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 3 medium severity findings can be fixed after the merge.

Reviewed commit 33d79f3 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This fits. It refines the wording of the existing skip_reply confirmation using the framework's own unreleased heartbeat, and each piece goes in the module that already owns that job.

More

It adds no new dependency, service or concept.

Findings

3 findings of medium or high severity, see the inline comments:

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread test/heartbeat.test.ts Resolved
Comment thread src/framework.ts Resolved
Comment thread src/framework.ts Resolved
… review round on #858

- Each tick and reminder is probed as the event deliverHeartbeat has the
  gate judge: a silent tick as silent, each with the text it will bring
  (HeartbeatUpcomingWake.silent/.text). Ticks and reminders share the server
  id `heartbeat`, so a rule tells them apart only by text, which the
  placeholder-text probe never showed it.
- No definite note while a gate.js may decide (EventGate.scriptMayDecide,
  GateScript.mayDecide): evaluate() consults it before the rules, and a
  probe can't.
- Tests: ticks held back while reminders pass and the reverse, a rule on the
  server id both share, a rule on the silent flag, a content rule that holds
  one reminder and not another; a fork, a paused heartbeat, no heartbeat,
  and a gate.js beside gate.json. Mutation-checked: the old probe shape,
  dropping the gate.js check, and dropping the target check each fail them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

antra-tess commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Review follow-up (86cbd8b):

  • Probe differs from how the tick is judged (medium): fixed. Each tick and reminder is now probed as the event deliverHeartbeat has the gate evaluate: a silent tick as silent, each with the text it will bring. The note makes no claim while a gate.js may decide. See the thread.
  • Rules case can't catch a probe on the wrong kind (medium): fixed, and it was worse than a test gap. A rule's source is the server id, and ticks and reminders share it, so only their text tells them apart. The old placeholder-text probe could never show a rule that. Tests now hold back each kind alone, by text.
  • No test for non-target agents, paused or absent heartbeat (medium): added: a fork, a paused heartbeat, no heartbeat, and a gate.js beside gate.json.

Mutation-checked: the old probe shape, dropping the gate.js check, and dropping the target check each fail the new tests. Targeted suites 61/61, and CI is green on 86cbd8b across the full matrix (Ubuntu Node 22.12, 24 and latest; macOS; Bun).

@nissa-seru, would your swarm take a look at this one when it has a moment?

@antra-tess

Copy link
Copy Markdown
Collaborator Author

/review deep

@github-actions

Copy link
Copy Markdown

A deep review of this pull request has been requested. It usually takes less than 30 minutes, though it may first wait for an earlier review of this pull request to finish. When it finishes, the review is posted on this pull request.

The run: https://github.com/anima-research/agent-framework/actions/runs/40591282074

@nissa-seru

Copy link
Copy Markdown
Contributor

@antra-tess The built-in heartbeat is in 0.28.0, so this changes released behaviour, and the Heartbeat additions are new public API on a released class. One sentence of the new note can also be wrong, and one clause of it can be clearer.

The heartbeat is released. The description says #​827 is unreleased and that the heartbeat is "still in a pending added fragment". #​827's commit, 7e4f7e2, is in 0.28.0 (59d60958). 0.28.0's CHANGELOG lists "The heartbeat moves into the framework" under Added (CHANGELOG.md:147), and its src/index.ts:75 exports Heartbeat. So hosts on 0.28.0 that run the built-in heartbeat already show the confirmation this pull request changes.

What that means for the changelog.

  • .changed fits the note: it changes the wording of a released confirmation.
  • kind, silent and text on each wake upcomingWakes() lists, and its optional whose, are additions to Heartbeat, and scriptMayDecide() (src/gate/event-gate.ts:3340) is an addition to EventGate, two classes 0.28.0 exports. HeartbeatUpcomingWake is a new exported type (src/index.ts:76). CONTRIBUTING says fragments are folded into CHANGELOG.md "loosely" in the Keep a Changelog style, whose Added is for new features, so we'd file these in an .added fragment beside the .changed one.
  • At 86cbd8b8, upcomingWakes() returns HeartbeatUpcomingWake[] (src/heartbeat/heartbeat.ts:341), where 0.28.0's returns UpcomingWake[] (:320 at 0.28.0). A subclass that overrides it with 0.28.0's signature no longer type-checks. With one added to src, on Linux with Node 24.22.1 and TypeScript 7.0.2, tsc --noEmit gives TS2416: Property 'upcomingWakes' in type … is not assignable to the same property in base type 'Heartbeat' at 86cbd8b8, and no error at 0.28.0. We know of no such subclass.

"Wakes you first" can be wrong. heartbeatWakeBefore (src/framework.ts:5543–5548 at 86cbd8b8) looks only at the heartbeat's own wakes. Unless a gate.js script may decide, it takes the first of them that the agent's rules let wake it and that is due before the self-wake. We ran a test at 86cbd8b8, on Linux with Node 24.22.1, @animalabs/membrane 0.8.0 and @animalabs/context-manager 0.23.1, built like this pull request's framework test, with a heartbeat every 600 s and a gate rule that batches channel messages for 300 ms. A channel message was batched, and the agent then called skip_reply with wake_in_seconds: 900. The note said "Your heartbeat (a check-in prompt, every 10 min) wakes you first, in ~600s, and that wake cancels your ~900s self-wake". The batch woke the agent 306 ms later, with the tick still about 600 s off, and the self-wake was no longer armed at 328 ms. 0.28.0's note, "Self-wake in ~900s unless something wakes you first" (src/mcpl/channel-registry.ts:5505 at 0.28.0), stays true in that case. We ran only a debounce batch.

"Set it again then." In the description's example, "wakes you first, in ~300s, and that wake cancels your ~900s self-wake: set it again then if you still want it", "it" reads as the ~900s self-wake. Set again in the heartbeat's turn, 300 s in, that wakes the agent about 300 s later than it first asked. Naming what's left saves the agent the subtraction: "…set it again then, for the ~600s left, if you still want it."

Rupert-2634, Crispin-2713 and Ada-1017 read this pull request's first commit, 33d79f37, and Daniel-2612 wrote this comment.

— Daniel-2612

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: fix here or file an issue for each, then merge. 2 should-fix issues should be fixed in this pull request or filed as an issue before merging.

Reviewed commit 86cbd8b by reading the code, in four stages: a plan by claude-opus-5-5; two readers on every task of it, gpt-6.1-sol and claude-haiku-5-5; their findings grouped into issues; and a judge, claude-opus-5-5, that tried to refute each issue. Nothing was run, so each issue is a lead from reading, and each blocker or should-fix says the run that would confirm it.

Between them, the reads of the readers that answered returned every changed line of the change (353 in all). A line counts when a read by a reader that answered returned it whole: an added line from the diff or from its file, a removed line from the diff. A search that matched it doesn't count. The head has no lockfile at its root, so its dependencies were installed from package.json's version ranges, and a dependency the readers read may be a later version than the author ran.

Should fix (2)

Minor (12)

12 minor issues, by file
  • Changelog fragment files a defect fix and new API surface under the single changed kind. changelog.d/skip-reply-heartbeat-aware.changed.md:3, src/framework.ts:5530, CHANGELOG.md:154 The fragment changelog.d/skip-reply-heartbeat-aware.changed.md files everything under Changed. Yet its second bullet fixes 0.28.0's released judge list, which probed with placeholder text and missed text-keyed rules (CHANGELOG.md:154). Its third bullet adds exported API. The description says changed fits because the heartbeat 'is still in a pending added fragment', but it shipped in 0.28.0 (CHANGELOG.md:147). Named in this pull request's description or commit messages.
  • Description and changelog wrongly treat the shipped built-in heartbeat as unreleased and pending in an added fragment. CHANGELOG.md:15, CHANGELOG.md:147, src/framework.ts:25999 and 7 more places The description calls the built-in heartbeat "#827, unreleased" and says it "is still in a pending added fragment". At head, CHANGELOG.md:147 lists it under the released 0.28.0 section (CHANGELOG.md:15), and changelog.d/ has no heartbeat fragment. So 0.28.0 users of the heartbeat will get the new skip_reply wording. The .changed.md category still fits, but its stated reason is false.
  • First-wake selection skips debounce and judge entries that probe as non-triggering, so an earlier turn can go unnamed. src/framework.ts:5546, src/gate/event-gate.ts:3311, src/gate/event-gate.ts:3519 and 7 more places src/framework.ts:5546 skips heartbeat wakes that probe as non-certain and names the first certain one. A tick matched by a debounce or judge rule probes false (src/gate/event-gate.ts:3311), yet evaluate batches it and can still start a turn (event-gate.ts:3160). So a debounced tick at ~60 s can cancel the self-wake before the reminder the note says 'wakes you first'.
  • Heartbeat confirmation reads cached policies that evaluate has not yet reloaded from gate.json. src/framework.ts:5530, src/framework.ts:5546, src/gate/event-gate.ts:3351 and 2 more places The confirmation probes the gate's cached compiledPolicies (src/gate/event-gate.ts:3351-3352) without reloadIfChanged. Delivery's evaluate reloads gate.json first (event-gate.ts:3037). After a direct edit to gate.json that holds back heartbeat pushes, the note can still promise the tick wakes the agent and cancels the self-wake. The tick is then refused when it fires.
  • Commit message says rules distinguish ticks from reminders only by text, though silent ticks are identified by metadata. src/framework.ts:5574, src/gate/event-gate.ts:3013, test/heartbeat.test.ts:668 By reading, the second commit's message says ticks and reminders share the server id, "so a rule tells them apart only by text". That's untrue for silent ticks: the gate event carries silent: true in its metadata (src/framework.ts:5574), and the change's own test holds a silent tick back with metadataTrue: ['silent'] while reminders still wake. A silent tick's content is empty (src/framework.ts:5567), so a text rule can't match it at all.
  • GateScript.mayDecide reports a removed gate.js as able to decide through its cached active flag. src/gate/gate-script.ts:168, src/gate/gate-script.ts:68, src/gate/gate-script.ts:70 and 7 more places By reading, after a loaded gate.js is deleted, GateScript.mayDecide (src/gate/gate-script.ts:169) keeps returning true through the cached active flag. That flag clears only when evaluate() reloads (gate-script.ts:70-71). Until another event is evaluated, skip_reply gives the plain note, although delivery would stop the worker and let the policies allow the heartbeat. The test's paused case is not affected: the test never loads gate.js.
  • Probe renders heartbeat text at the scheduled or overdue time while delivery renders it at the fire-time clock. src/heartbeat/heartbeat.ts:358, src/heartbeat/heartbeat.ts:365, src/heartbeat/heartbeat.ts:245 and 13 more places The probe text is built at the scheduled time (src/heartbeat/heartbeat.ts:365, tickText(at)), while delivery builds it at fire time (heartbeat.ts:582, 615). For an overdue or late reminder, delivery adds ", due …" (heartbeat.ts:245) and the probe text lacks it. A content rule on that header or on the timestamp can then probe differently than it decides. The changelog's "with the text it will bring" overstates this.
  • Description under-counts the added API surface and omits gate.js, tickText and silent/text additions from its rent accounting. src/heartbeat/heartbeat.ts:117, src/heartbeat/heartbeat.ts:120, src/heartbeat/heartbeat.ts:123 and 9 more places The description says the Heartbeat API adds "an optional parameter and an extra field". HeartbeatUpcomingWake adds kind, silent and text (src/heartbeat/heartbeat.ts:117-123), and there is a new public EventGate.scriptMayDecide() (src/gate/event-gate.ts:3340). The description's "reads as before" list leaves out the gate.js case. It also doesn't say the reply judge's list is now probed with each wake's real text. The changelog fragment covers all of this.
  • Unconditional tick date formatting throws RangeError for accepted intervals beyond the Date range. src/heartbeat/heartbeat.ts:353, src/heartbeat/heartbeat.ts:308, src/heartbeat/heartbeat.ts:751 and 2 more places A host default like intervalSeconds: 1e14 passes readSettings (src/heartbeat/heartbeat.ts:759) without the canHoldTime check the configure tool applies (src/heartbeat/heartbeat.ts:437). start() then puts nextFireAt past the Date range. By reading, upcomingWakes' new tickText call (src/heartbeat/heartbeat.ts:353) would throw RangeError from formatToParts. That would break the judge's list and skip_reply after its timer is armed (src/framework.ts:25999).
  • Note treats a gate-approved heartbeat request at its scheduled time as a guaranteed turn that cancels the self-wake. src/mcpl/skip-reply-wake.ts:70, src/framework.ts:5546, src/framework.ts:5617 and 20 more places The note (src/mcpl/skip-reply-wake.ts:65-66) says flatly that the heartbeat's wake cancels the self-wake. Only a fresh turn start clears it (src/gate/event-gate.ts:4021), and the heartbeat only queues a request. If a provider cooldown, a quiesce, or a host InferencePolicy that drops heartbeat requests (src/types/framework.ts:403) holds that request past the self-wake's deadline, the self-wake still fires. The effect is an extra wake the agent was told was cancelled.
  • Remaining-time assertions use a ten-second wall-clock bound that fails under load. test/heartbeat.test.ts:50, test/heartbeat.test.ts:635, test/heartbeat.test.ts:640 By reading, the remaining-time assertions at test/heartbeat.test.ts:635 and :640 measure real Date.now time (the mocked clock is reset at :50, and src/framework.ts:5545 reads Date.now). A stall of over 10 s between heartbeat start or reminder_add and the skip would fail them even when the note names the right wake. This is unlikely in practice.
  • Tests section says only the new tests fail on main, but a missing export breaks the whole skip-reply-wake test module. test/skip-reply-wake.test.ts:6 The description says "Against main's source, the new tests fail and the rest pass". By reading, test/skip-reply-wake.test.ts:6 imports skipReplyNote, which main doesn't export. In this ESM package that import fails when the module links, so the file's existing readSkipReplyWake and skipReplyWakeRefusal tests fail on main too.

In existing code (1)

1 issue in code that was there before this change, which doesn't weigh in the recommendation
  • Minor. Gate.js guard covers the skip_reply note but not the reply judge's heartbeat certainty. src/framework.ts:5513, src/framework.ts:5531, src/framework.ts:5544 and 3 more places With a gate.js that may skip heartbeat events, heartbeatWakesFor (src/framework.ts:5531) still marks the tick certain from the policy probe. The reply judge's list (framework.ts:5513, reply-judge.ts:193) then shows it with no 'maybe', while the skip_reply note withholds the same wake (framework.ts:5544). A judge could answer WAIT for a wake the script holds back. The base did the same. Already raised on this pull request.

Comment thread src/framework.ts Resolved
…p-review round on #858

Nissa's swarm and the deep review both found "wakes you first" can be false:
a debounced batch, a queued request, a used-up rate-limit token, a cooldown,
a policy hold or a gate.js can wake the agent sooner or hold the tick back,
and the probe can't foresee any of them. The note now keeps the usual line
and adds the heartbeat's wake as information, true however the gate decides:

  Turn ended; nothing sent. Self-wake in ~900s unless something wakes you
  first. Your heartbeat (...) is due sooner, in ~300s; if it wakes you, the
  self-wake is cancelled, so set it again for the ~600s left if you still
  want it.

- "for the ~Ns left" (Nissa's suggestion) names what remains of the timer.
- The gate.js guard and its API (EventGate.scriptMayDecide,
  GateScript.mayDecide) are gone: the hedged note doesn't need them.
- upcomingWakes() keeps 0.28.0's signature; the new list is
  Heartbeat.scheduledWakes(whose), typed HeartbeatScheduledWake, so a
  subclass overriding upcomingWakes still type-checks.
- A tick past the last time a date shows (a host interval has no ceiling)
  lists its message without a time, instead of throwing RangeError from the
  judge's list and skip_reply. heartbeatWakeBefore also catches, since the
  timer is armed before it runs.
- Changelog split: .changed (the note), .fixed (the judge's list probe, a
  0.28.0 behavior), .added (scheduledWakes, HeartbeatScheduledWake).
- Test time bounds loosened so a slow machine doesn't fail them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess antra-tess changed the title feat(skip_reply): name the heartbeat's wake when it comes before the self-wake feat(skip_reply): name the heartbeat's wake due before the self-wake Oct 27, 2026
@antra-tess

Copy link
Copy Markdown
Collaborator Author

@nissa-seru Thanks to Daniel-2612, Rupert-2634, Crispin-2713 and Ada-1017. All four points are taken in d765679:

  • The heartbeat is released. You're right; I had checked against a worktree that hadn't fetched tags. The description now says 0.28.0. The changelog is three fragments: .changed for the note, .fixed for the judge list's probe (0.28.0 behavior, see below), and .added for the API.
  • The upcomingWakes() signature. It's back to 0.28.0's UpcomingWake[]. The new list is a separate method, Heartbeat.scheduledWakes(whose?), typed HeartbeatScheduledWake. A subclass overriding upcomingWakes with 0.28.0's signature type-checks again. EventGate.scriptMayDecide() is gone too (see below).
  • "Wakes you first" can be wrong. Agreed, and the deep review found more cases of the same kind: a queued request, a rate-limit token used up before the tick. The note now keeps Self-wake in ~900s unless something wakes you first. and adds Your heartbeat (…) is due sooner, in ~600s; if it wakes you, the self-wake is cancelled, so set it again for the ~300s left if you still want it. In your batch run, that reads true: the batch woke the agent first, and the heartbeat line only says when it's due. With the claim softened, the gate.js guard wasn't needed, so it and scriptMayDecide went.
  • "Set it again then." Taken: "for the ~Ns left", the self-wake minus the heartbeat's due time, at least 1 s.

The probe change in the previous round still stands, and it now has its own .fixed fragment. Each tick and reminder is probed as the event its delivery is judged by: silent as silent, each with its own text. Before, the judge's list sent probe text for both kinds, so a rule telling them apart by text went unseen.

@antra-tess

Copy link
Copy Markdown
Collaborator Author

/review

@github-actions github-actions Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated review

Recommendation: merge and fast-follow. 1 medium severity finding can be fixed after the merge.

Reviewed commit d765679 with claude-opus-5-5, by reading the code. It did not run the code or look at the checks.

Does this change belong in the project?

It fits the project. This change refines two features the project already ships, skip_reply's self-wake and the built-in heartbeat, and puts each piece in the module that already owns it. It follows the project's conventions for changelog fragments, tests and the PR body.

5 reasons with sources
  • It improves behavior that already exists rather than adding new scope. The skip_reply self-wake note and the heartbeat's upcomingWakes for the reply judge both exist on main. The change adds a line to the note and probes the gate with the real event text instead of a placeholder. (src/mcpl/skip-reply-wake.ts, src/heartbeat/heartbeat.ts (base))
  • The code sits where the existing structure puts it. The base code already says the framework owns the EventGate and arms the self-wake before dispatch, while the registry writes the note. The change keeps that split. The framework works out which heartbeat wake comes first and passes it along with the agent's dispatch info as earlierWake. The wording lives in skip-reply-wake.ts next to readSkipReplyWake, and the schedule details stay in Heartbeat. (src/mcpl/channel-registry.ts handleToolSkipReply comment (base))
  • It refactors rather than duplicating anything. The reply judge's list and the new note both go through one helper, heartbeatWakesFor. upcomingWakes() now calls scheduledWakes() instead of running a second version of the same loop. (src/framework.ts)
  • The new public API is small and limited to the heartbeat. The only additions are Heartbeat.scheduledWakes and the exported type HeartbeatScheduledWake, both declared in an .added fragment. There are no new dependencies, services or concepts. (src/index.ts)
  • The changelog fragments and PR body follow the contributing guide. There are three fragments, each a single bullet with a valid category name. The body has Problem/Change/Tests sections, the AI footer, and an honest note about the local suite. The review's findings that some parts lack tests are matters for that code review, not for whether the change belongs. (CONTRIBUTING.md, changelog.d/README.md)

Findings

1 finding of medium or high severity, see the inline comment:

3 more findings, less severe
  • Low severity, documentation, src/heartbeat/heartbeat.ts:336-338. upcomingWakes() is called unchanged, but it now returns the full wake objects. The .added fragment and the PR say upcomingWakes() is unchanged. It now returns this.scheduledWakes() directly, so each entry carries kind, silent and the full text, including a reminder's complete untruncated message and header. It also formats that text with formatHeartbeatTime on every call. The declared type is the same, but callers that spread, serialize or log these entries now get extra data. Either map the result back to { at, certain, what } or drop the 'unchanged' claim.
  • Low severity, maintainability, src/framework.ts:26004-26006. Self-wake log line calls a reminder 'the heartbeat'. earlierWake can be a pending reminder as well as the next tick, because heartbeatWakeBefore picks the first certain entry from scheduledWakes, which includes reminders. The log line always says ; the heartbeat is due sooner, in ~Ns, so an operator reading [self-wake] logs can't tell a reminder from a tick. Log earlierWake.what (or the kind) instead.
  • Low severity, test coverage, src/framework.ts:5543-5552. The fallback when the heartbeat's wakes can't be read has no test. heartbeatWakeBefore catches errors from heartbeatWakesFor, which can come from the gate probe or from reminderText/formatHeartbeatTime. It logs the error and returns undefined, so the confirmation should still go out with the plain note. The PR describes this as the intended degradation, but no test makes the probe or scheduledWakes throw and checks that the skip_reply still gets its plain note and the self-wake stays armed.

For a deeper review by several readers and a judge, which takes longer, a maintainer can comment /review deep.

Comment thread src/framework.ts Resolved
#858

- The reply judge's list (upcomingWakesFor) gets its own test: a reminder
  held back by its text and a silent tick held back by its flag are
  "maybe"s, the others certain, in the judge's "its" wording.
- When the heartbeat's wakes can't be read, skip_reply sends the plain note
  with the self-wake armed, and the failure is logged (tested by a probe
  that throws).
- upcomingWakes() returns 0.28.0's fields only ({ at, certain, what }), as
  the changelog says it's unchanged.
- The [self-wake] log line names the wake due sooner (a tick or a
  reminder), not always "the heartbeat".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antra-tess

Copy link
Copy Markdown
Collaborator Author

Review follow-up (ab9a671), on the review of d765679:

  • Judge's list has no test of its own (medium): added. See the thread.
  • upcomingWakes() returns the full objects (low): fixed. It maps back to { at, certain, what }, so it is 0.28.0's shape as well as its type.
  • Log line calls a reminder "the heartbeat" (low): fixed. The [self-wake] line names the wake: ; due sooner, in ~120s: your reminder "stretch".
  • Fallback untested (low): added. With a probe that throws, skip_reply gets the plain note, the self-wake stays armed, and the failure is logged.

Affected files pass locally: heartbeat 31/31, skip-reply-wake-capability 19/19.

@antra-tess
antra-tess merged commit 65bf22b into main Oct 27, 2026
12 checks passed
tel-0s added a commit that referenced this pull request Oct 28, 2026
110 commits from main, 20 pull requests: #852 (supplied text can't read
as the framework's own), #853 (every check of whether an agent sees a row
asks its view), #827 (the heartbeat moves into the framework), #846
(exhausted turns re-armed), #830 (chat:system notices), #801 (the reply
judge), #840, #843, #839, #791, #845, #847, #849, #822, #854, #836, #855,
#857, #858, #859 and the 0.28.0 release. package.json takes main's
version and ranges (context-manager ^0.23.1, membrane ^0.8.0).

Conflicts:
- src/framework.ts, TurnRouting: both fields kept, the branch's
  addressedElsewhere and main's heard (and both in freshTurnRouting).
- src/framework.ts, createAgent: main's `sees` predicate, recorded in
  storeSees, is the branch's composition: heldFromResidents (the
  RESIDENT_VIEW_FILTER rule) and !isAnotherAgentsInjection; the view
  filter reads it.
- src/framework.ts, tool-boundary drain: the branch's releaseLandedMail
  and main's noteSpokenTo both run; the injection skip asks agentSees
  (main) where the branch asked heldFromResidents, which it subsumes.
- src/framework.ts, the fork wake and the routed fork wake: the branch's
  tag-only isAddressedMessage, with main's systemNoticeMark added.
- src/framework.ts, a coalesced batch's wake: tag-only addressed, with
  main's note on why it carries no systemNotice mark.
- src/framework.ts, isUnreadStoredMessage: main's form (the slot's readers
  each asked their own view) over the registry's sharedSlotReaders.
- src/framework.ts, noteArrival: main's agentSees and heard, the branch's
  arrival with `addressed`.
- src/framework.ts, noteForOtherResidents: the registry's readers, with
  main's noteSpokenTo.
- src/framework.ts, the channel tool dispatch: the branch's
  publishInputWithTurnLocus, with main's earlierWake on the origin.
- src/framework.ts, tune_out repeated on a tuned-out channel: the branch's
  awaited form, the name quoted by channelRegistry.quotedShown (#852).
- src/mcpl/channel-registry.ts, markAccepted: the branch's (admission's
  noteDelivered sets the locus); main's chat:system is a marker
  (isMarkerTagged), so noteDelivered's rule covers it, and its comment
  says so.
- src/mcpl/channel-registry.ts, tuneOutEndText: the branch's serverId
  form for a channel no name picks alone, a name written by quotedValue.
- src/tune-out/coordinator.ts: the branch's readOptions, all-except
  refusal, renderHeldLine/historyTool, reader-fork invocation and
  cancel_tuneout scope logic kept; main's quotedShown, NOTICE_TAG texts
  and suppliedValue author adopted in each.
- test/batched-wake-locus: main's new two-closed-channels test is placed
  in the branch's rig (framework read from rig.framework()); the branch's
  ambient-only tests kept.
- test/channel-registry-routing: the branch's noteDelivered comment, with
  main's chat:system added to the marker loop.

Main's changes integrated into the branch's structures:
- heartbeatTargets reads registry.untargetedRecipients() (an untargeted
  push's recipients) and heartbeatRefusal registry.homeChannel, in place
  of conversationAgentHomes.
- releaseAgentRuntime also drops #846's re-arm count and exhausting re-arm
  and #840's exhausted-turn record, and ends the run of stated waits
  (endStatedWaitRun).
- storeSees is recorded for a derived manager (its source's predicate,
  which context-manager's derive keeps as its view filter) and for a
  context reopened on the shared slot (the residents' view).
- The closed-channel invitation for a deliverTo correction reads the
  recipient admission chose, where main rewrites event.targetAgents.
- #852 on the branch's own texts: authorLine/presentAuthor write the
  author through suppliedValue; the addressed-elsewhere notice's author
  too; `Focus` and `Agent lifecycle` are NOTICE_TAG entries and their
  notices use them; tune-out's branch-only notices use NOTICE_TAG and
  name channels by shown/quoted names.

Tests read onto this branch, each named in a comment:
- heartbeat (two cases): the fake fork is a registry record
  (conversationForkSpec), not a conversationAgentHomes entry.
- prose-drafts (stated provider wait, two cases): scout is made a
  conversation agent through its registry record (homeChannel, no
  provider scheduling), as main does through conversationAgentHomes.
- channel-registry-routing (chat:system locus): each message is
  delivered with noteDelivered, where admission records the locus here.
- supplied-text-in-context (three cases): a channel message is stored and
  sent under its author's name (presentAuthor); the delivered body's
  digest is compared with the marked body before that decoration.
- tune-out-reader-queued: its fake registry gains quotedShown.

Tests: built with zero errors; 29 stale compiled tests removed. The
targeted run (dendrite-*, tune-out-*, batched-wake-locus,
channel-registry-routing, compression-after-release, stored-injections and
main's added or changed framework-area tests, 63 files) gave 2507 tests,
2491 pass, 14 fail, 2 skipped before the readings above; after them, 73
files (those 63 and all-except-unit, all-except-e2e, author-presentation,
module-rows-held-from-residents, refusals-are-error-results,
state-read-and-write-errors, inbound-source, reinit-after-branch-move,
channel-agent-words, channel-lifecycle-order) gave 2860 tests, 2858 pass,
0 fail, 2 skipped (main's runtime-conditional skips). The full suite was
not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nissa-seru pushed a commit to rufalupa666-netizen/agent-framework that referenced this pull request Oct 28, 2026
main moved thirteen merges past 8ff287d. anima-research#858 (skip_reply names the
heartbeat's earlier wake) conflicted first, and the scratch merge of all
thirteen gave ten blocks, nine in src/framework.ts and one in
test/present-while-acting.test.ts. Each keeps both sides:
- The imports keep anima-research#852's supplied-text helpers and notice tags beside
  readInboundSource.
- The request marks keep anima-research#846's REARMED beside the wake channels, the turns
  a request continues and the retake mark, with anima-research#846's helpers after them.
- The delivered receipt and the no-channel routing notice keep this
  branch's text inside anima-research#852's NOTICE_TAG. main's side of the routing notice
  was its July wording, which this branch's replaced.
- skip_reply's self-wake keeps anima-research#858's armedAt and earlierWake, and still
  passes the turn that armed it to armSelfWake.
- A status's locus keeps a private turn's null, with anima-research#852's suppliedValue
  for a channel's id.
- The MCPL tool-list notice keeps its notice variable, which
  FRAMEWORK_NOTICES marks, with anima-research#852's suppliedValue for each tool's name.
- dispatchChannelToolCall takes anima-research#858's earlierWake, under this branch's
  one-sentence comment.
- channel_open's result puts its routing word first, as anima-research#852 does so a
  result cut for its length keeps it, with this branch's private-turn
  wording.
- The test file keeps main's "channel_open's word ... survives a result
  cut" beside this branch's private channel_open tests.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants