feat(core): AgentWork primitive + heartbeat STATUS protocol replacement#176
Open
luokerenx4 wants to merge 3 commits intomasterfrom
Open
feat(core): AgentWork primitive + heartbeat STATUS protocol replacement#176luokerenx4 wants to merge 3 commits intomasterfrom
luokerenx4 wants to merge 3 commits intomasterfrom
Conversation
Introduces `src/core/agent-work.ts` — the missing core primitive for
"Alice does an async task outside chat". Trigger sources today
(heartbeat / cron / task-router) and trigger sources tomorrow (factor
mining, asset monitoring, ad-hoc scheduled DAGs) all share the same
shape: take a payload, run the AI, optionally gate the notification,
emit done/skip/error. AgentWork is that shape.
API:
class AgentWorkRunner {
constructor({ agentCenter, connectorCenter, ... })
run(req: AgentWorkRequest, emit: EmitFn): Promise<AgentWorkRunResult>
}
AgentWorkRequest carries:
prompt, session, preamble, metadata
inputGate? (active-hours-style pre-AI guard)
outputGate? (notify_user-style post-AI gate)
onDelivered?
emitNames + buildDonePayload + buildSkipPayload? + buildErrorPayload
The runner is stateless — construct once at startup, call run() per
request with the listener's per-call emit fn. Class form (rather than
free function) keeps `src/core/` style consistent with AgentCenter /
ConnectorCenter / NotificationsStore.
Also surfaces `toolCalls` on `ProviderResult` (additive change). The
existing pipeline already accumulates tool_use events as they stream
through; AgentCenter now packages them into the final done event so
AgentWork's outputGate can inspect "did the AI call notify_user?"
without re-streaming.
Test coverage: `src/core/agent-work.spec.ts` — 37 tests across:
- default behaviour (no gates) — happy path
- inputGate — null vs skip, AI-not-invoked, custom payload
- outputGate — deliver/skip/probe inspection
- notify_user-style tool inspection (load-bearing for heartbeat)
- AI invocation errors — throw, non-Error, emit failure
- notify failure — done with delivered=false, hook not called
- onDelivered hook — called/not-called/throws
- clock injection — durationMs honors injected now()
- source label flow-through
- concurrent runs (stateless runner)
Followup commits migrate cron / task-router / heartbeat to use this
primitive; this commit is just the primitive + plumbing, no consumers
yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the heartbeat STATUS regex protocol with a structured tool call. AI-decides-to-notify becomes "AI calls notify_user(text)"; runner-side outputGate inspects the captured tool calls and routes through dedup / connectorCenter.notify. The tool's `execute` is intentionally a no-op (returns the args back as acknowledgement). Why no side-effects: heartbeat applies dedup before push; if the tool itself called connectorCenter.notify, we'd have no way to gate on dedup without per-tool source state. The runner-side gate is the right control point. The tool just records intent + arguments. Globally registered in ToolCenter — every session sees it. But only sessions whose persona prompt teaches AI when to call it (today only: heartbeat) actually exercise it. cron / task-router / chat keep their existing "every reply pushes" behaviour because their prompts don't reference notify_user. Followup commit teaches heartbeat's persona about it and wires the runner-side gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nner
Three trigger sources collapse into thin configurations of the
AgentWork primitive. The shared body (subscribe → AI call → notify →
emit done/error) lives in AgentWorkRunner; each listener is now just
"how to translate a trigger event into an AgentWorkRequest".
heartbeat (src/task/heartbeat/heartbeat.ts):
- delete parseHeartbeatResponse() and the entire STATUS regex
protocol — Alice now signals notification intent via the
notify_user tool, not by emitting magic string tokens
- default persona prompt rewritten to teach notify_user instead of
STATUS / REASON / CONTENT format
- active-hours guard becomes the runner's inputGate
- notify_user inspection + dedup checks become the runner's
outputGate; dedup record happens via onDelivered
- HeartbeatDedup, isWithinActiveHours, the `__heartbeat__` cron job
lifecycle, hot enable/disable — all kept (heartbeat-specific)
- HeartbeatDedup.lastText is now public (load-bearing for the done
event's `reply` field)
- 410 → ~290 lines
cron (src/task/cron/listener.ts):
- 135 → ~110 lines
- public API (createCronListener, CronListener, CronListenerOpts)
preserved; just takes agentWorkRunner instead of agentCenter +
connectorCenter
- serial-execution lock + internal-job filter still here, since
those are cron-specific (factory's pre-AI hook is the inputGate
on a per-request basis; cron's `processing` lock is a listener-
instance concern that pre-dates the request)
task-router (src/task/task-router/listener.ts):
- 122 → ~100 lines
- same migration as cron
- public API preserved
main.ts:
- constructs AgentWorkRunner once, threads to all three listeners
- registers notify_user tool in toolCenter (globally available)
heartbeat.spec.ts: rewritten — STATUS-regex tests deleted, replaced
with notify_user-tool-call equivalents. New tests:
- delivers when AI invokes notify_user (replaces "should call AI
and write heartbeat.done")
- skips with reason=ack when AI does not call notify_user
(replaces "should skip HEARTBEAT_OK")
- skips with reason=empty when notify_user.text is blank
- explicit guard: STATUS-shaped raw text without notify_user is
NOT delivered (anti-regression)
- dedup: different texts not deduped
- active-hours: outside window does not invoke AI
- lifecycle / setEnabled / error handling preserved
Test count: 35 → 28 (the parseHeartbeatResponse standalone test
block, ~10 tests, deleted alongside the function it tested)
cron + task-router specs: minimal setup change to construct via
AgentWorkRunner; assertions unchanged.
Full suite: 1622/1622 passing (was 1592 before).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three architectural changes that landed together because they're
load-bearing on each other:
AgentWorkcore primitive — the missing abstractionfor "Alice does an async task outside chat". Three trigger sources
today (heartbeat / cron / task-router) and any future ones plug
into the same shape:
payload + work + gates + emit.notify_usertool — Alice's structured way to signal"deliver this to the user" intent during autonomous work, replacing
the legacy
STATUS: HEARTBEAT_OK | CHAT_YES + CONTENT: ...regexprotocol that heartbeat used.
STATUS regex parser dies, copy-paste between cron and task-router
collapses, heartbeat persona prompt rewritten to teach the tool.
The
payload + workshape was identified in conversation — heartbeat/ cron / task-router are isomorphic at the abstraction level (same
trigger → AI → notify → emit pipeline, only labels differ); they
just lacked a primitive to share.
Per-session contributions
2026-05-10 — AgentWork + notify_user
src/core/agent-work.ts(~250 lines) —AgentWorkRunnerclasswith input/output gates, error handling, emit fan-out
src/core/agent-work.spec.ts(37 tests) — gate combinations,error paths, tool-call observation, hook misbehaviour, concurrent runs
src/tool/notify-user.ts— intent-signal tool, no side-effects(runner-side outputGate handles dedup + delivery)
src/ai-providers/types.ts—ProviderResult.toolCallsoptional field (additive)
src/core/agent-center.ts— accumulates tool_use events,packages into final done event
src/task/heartbeat/heartbeat.ts(410 → 290 lines) —parseHeartbeatResponsedeleted; default persona prompt rewrittento teach notify_user; active-hours becomes inputGate; dedup +
notify_user inspection becomes outputGate
src/task/heartbeat/heartbeat.spec.ts— 28 tests(was 35; 10 STATUS-regex tests deleted with the function)
src/task/cron/listener.ts(135 → 110 lines)src/task/task-router/listener.ts(122 → 100 lines)src/main.ts: AgentWorkRunner constructed once, sharedacross all three listeners; notify_user registered in ToolCenter
c7d011d,014b844,b857d7aFull commit log
Test plan
npx tsc --noEmitcleanpnpm test— 1622 / 1622 passing (+30 net: 37 new agent-work, +-7 heartbeat after STATUS deletion + notify_user-based rewrite)vite buildcleanpnpm buildcleanAI-portfolio-intuition memory note flags this category for
careful visual verification):
runNowon__heartbeat__job).Verify Alice either calls notify_user (notification appears in
inbox + connectors) or stays silent (skip emit, no notification).
event with
reason: 'outside-active-hours'and AI never invoked.verify second is skipped with
reason: 'duplicate'.as before (cron behaviour unchanged).
task.requestedwebhook — verify notification just asbefore (task-router behaviour unchanged).
parseHeartbeatResponseis gone:git grep parseHeartbeatResponsereturns nothing.git grep "STATUS:.*HEARTBEAT_OK"returns nothing in src/.Out of scope (deferred)
long-running async task) — discussed extensively; the right
long-term shape but too disruptive for this PR. AgentWork is
structurally compatible.
no system-level i18n introduced.
task.requestedevent + module namekept as-is; semantic is "router that submits webhook events as
AgentWork".
those sessions still doesn't reference notify_user, so behaviour
is identical to before this PR.
🤖 Generated with Claude Code