Skip to content

feat(core): AgentWork primitive + heartbeat STATUS protocol replacement#176

Open
luokerenx4 wants to merge 3 commits intomasterfrom
dev
Open

feat(core): AgentWork primitive + heartbeat STATUS protocol replacement#176
luokerenx4 wants to merge 3 commits intomasterfrom
dev

Conversation

@luokerenx4
Copy link
Copy Markdown
Contributor

Summary

Three architectural changes that landed together because they're
load-bearing on each other:

  1. Introduces AgentWork core primitive — the missing abstraction
    for "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.
  2. Adds notify_user tool — Alice's structured way to signal
    "deliver this to the user" intent during autonomous work, replacing
    the legacy STATUS: HEARTBEAT_OK | CHAT_YES + CONTENT: ... regex
    protocol that heartbeat used.
  3. Migrates all three trigger sources to the new primitive. The
    STATUS regex parser dies, copy-paste between cron and task-router
    collapses, heartbeat persona prompt rewritten to teach the tool.

The payload + work shape 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

  • New: src/core/agent-work.ts (~250 lines) — AgentWorkRunner class
    with input/output gates, error handling, emit fan-out
  • New: src/core/agent-work.spec.ts (37 tests) — gate combinations,
    error paths, tool-call observation, hook misbehaviour, concurrent runs
  • New: src/tool/notify-user.ts — intent-signal tool, no side-effects
    (runner-side outputGate handles dedup + delivery)
  • Modified: src/ai-providers/types.tsProviderResult.toolCalls
    optional field (additive)
  • Modified: src/core/agent-center.ts — accumulates tool_use events,
    packages into final done event
  • Rewritten: src/task/heartbeat/heartbeat.ts (410 → 290 lines) —
    parseHeartbeatResponse deleted; default persona prompt rewritten
    to teach notify_user; active-hours becomes inputGate; dedup +
    notify_user inspection becomes outputGate
  • Rewritten: src/task/heartbeat/heartbeat.spec.ts — 28 tests
    (was 35; 10 STATUS-regex tests deleted with the function)
  • Thinned: src/task/cron/listener.ts (135 → 110 lines)
  • Thinned: src/task/task-router/listener.ts (122 → 100 lines)
  • Wired in src/main.ts: AgentWorkRunner constructed once, shared
    across all three listeners; notify_user registered in ToolCenter
  • Key commits: c7d011d, 014b844, b857d7a

Full commit log

b857d7a refactor(task): migrate heartbeat / cron / task-router to AgentWorkRunner
014b844 feat(tool): add notify_user — intent signal for autonomous notifications
c7d011d feat(core): AgentWork primitive + ProviderResult.toolCalls plumbing

Test plan

  • npx tsc --noEmit clean
  • pnpm test — 1622 / 1622 passing (+30 net: 37 new agent-work, +-7 heartbeat after STATUS deletion + notify_user-based rewrite)
  • vite build clean
  • pnpm build clean
  • Manual (required — this changes Alice's notification protocol;
    AI-portfolio-intuition memory note flags this category for
    careful visual verification):
    • Trigger heartbeat manually (runNow on __heartbeat__ job).
      Verify Alice either calls notify_user (notification appears in
      inbox + connectors) or stays silent (skip emit, no notification).
    • Trigger heartbeat outside active-hours config — verify skip
      event with reason: 'outside-active-hours' and AI never invoked.
    • Two heartbeat fires with same notify_user text within window —
      verify second is skipped with reason: 'duplicate'.
    • Run a user-defined cron job — verify notification shows up just
      as before (cron behaviour unchanged).
    • Send a task.requested webhook — verify notification just as
      before (task-router behaviour unchanged).
    • Verify parseHeartbeatResponse is gone:
      git grep parseHeartbeatResponse returns nothing.
    • Verify STATUS regex is gone:
      git grep "STATUS:.*HEARTBEAT_OK" returns nothing in src/.

Out of scope (deferred)

  • Workspace primitive (VS Code-style directory + state per
    long-running async task) — discussed extensively; the right
    long-term shape but too disruptive for this PR. AgentWork is
    structurally compatible.
  • i18n for AI output — addressed per-prompt at instruction time;
    no system-level i18n introduced.
  • task-router renamingtask.requested event + module name
    kept as-is; semantic is "router that submits webhook events as
    AgentWork".
  • cron / task-router prompt changes — defaults unchanged; AI in
    those sessions still doesn't reference notify_user, so behaviour
    is identical to before this PR.

🤖 Generated with Claude Code

Ame and others added 3 commits May 10, 2026 19:34
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>
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.

1 participant