Skip to content

feat(notifications): agent spoken narration via speak tool#3060

Open
jonmcwest wants to merge 5 commits into
mainfrom
posthog-code/agent-spoken-narration
Open

feat(notifications): agent spoken narration via speak tool#3060
jonmcwest wants to merge 5 commits into
mainfrom
posthog-code/agent-spoken-narration

Conversation

@jonmcwest

Copy link
Copy Markdown

What

Gives the agent a voice. A new speak local tool lets the agent narrate out loud — when it finishes your request, reaches a meaningful new phase, or is blocked and needs you (a question, decision, confirmation, or an error only you can resolve). You hear it even when you're in another tab.

How it fits the architecture

The flow follows the same "pure side-effect off the event stream" pattern as completion/permission notifications:

  • speak tool (packages/agent) — no-op agent-side (the agent subprocess/sandbox can't reach your speakers). It just surfaces a tool_call carrying the text + kind.
  • SpeechQueueService (packages/core) — portable orchestration. Serializes utterances so parallel sessions never talk over each other, coalesces stale per-task narration, lets needs_input lines jump the queue (never dropped), and caps the queue so a burst can't back up minutes of speech. Depends only on the host-neutral ISpeech capability + injected settings/name providers — no tRPC, Node, or Electron.
  • composeUtterance (packages/core) — the app owns the PostHog Code task '…' — prefix and the Hey <name>, greeting for needs-user lines, so the agent supplies only content. Idempotent against an agent that already added a prefix/greeting.
  • ISpeech adapter (apps/code) — ElevenLabs playback with a macOS say fallback (audio tags like [laughs] are stripped by the fallback).
  • Settings — users choose which kinds are spoken (and can mute progress), pick a voice, and set the API key in Notifications settings.

Testing

  • packages/core speech + composeUtterance: 18 tests pass
  • packages/ui speechRouting: 12 tests pass
  • packages/agent speak tool: 11 tests pass
  • Full pnpm typecheck clean across all 22 packages
  • Biome lint clean on touched files (no noRestrictedImports violations in core)

Created with PostHog Code

@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 8818df8.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Comments Outside Diff (2)

  1. packages/core/src/sessions/sessionService.ts, line 1414-1421 (link)

    P2 Orphaned speakCalls entries on session teardown

    unsubscribeFromChannel cleans up agentSpokeAt for the departing taskRunId, but not speakCalls. If a session ends while a speak tool call is mid-stream (registered in the map with a null value but not yet reaching terminal status), the entry for that tool-call ID persists in the map until reset() is called on logout. Because speakCalls is keyed by tool-call ID rather than taskRunId, the entries can't be filtered here cheaply — a small parallel set of tool-call IDs per run would make cleanup straightforward, or the map can accept the existing mild leak since reset() does clear it on logout.

  2. packages/core/src/speech/composeUtterance.ts, line 975-983 (link)

    P2 Greeting injection runs before the double-prefix guard

    The greeting is prepended to body before the "posthog code task" prefix check. If an agent-authored needs_input line (which triggers addressByName: true) already contains a task prefix — a violation of the instructions, but one that can happen with a noncompliant response — the replace(/^\s*hey\b[\s,]*/i) call has nothing to strip (the line starts with "PostHog…"), so body becomes "Hey Jon, PostHog Code task '…' — …", which then fails the early-return check and exits with a double-prefixed result. Moving the task-prefix check to run first (before any greeting logic is applied) would make the function order-independent. The existing test for "does not double-prefix" uses addressByName: false and so misses this case.

Reviews (1): Last reviewed commit: "fix(notifications): make agent speak on ..." | Re-trigger Greptile

Copy link
Copy Markdown
Author

Addressed both Greptile P2 comments in dd25b52:

  1. Orphaned speakCalls on teardownspeakCalls is now keyed by taskRunId (inner map keyed by tool-call id), so unsubscribeFromChannel drops any speak calls still mid-stream for a departing run instead of leaking them until reset().

  2. Greeting injection before the double-prefix guard — the ^posthog code task guard now runs before any greeting logic in composeUtterance, so an already-prefixed agent line can no longer be double-prefixed when addressByName is true. Added a regression test covering that case.

Also fixed the two failing CI checks: added enqueueSpeech to the cloud-task-update notification test harness deps, and allowlisted the host-resident ElevenLabs speech service (the API key intentionally stays in the host process, matching secure-store/encryption).

@jonmcwest jonmcwest requested review from frankh and pauldambra July 2, 2026 10:54
@jonmcwest jonmcwest force-pushed the posthog-code/agent-spoken-narration branch from e6a7b7c to 9e0bf61 Compare July 2, 2026 13:01

@pauldambra pauldambra left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

i'd be happy to approve this since i've seen it run but it's big enough we should probably get team code to review it

i think it has the natural next step of hooking up speech to text so you can reply when ph-ode talks to you

@jonmcwest jonmcwest requested a review from a team July 3, 2026 09:42
jonmcwest added 5 commits July 5, 2026 20:02
Add a `speak` local tool the agent calls to narrate out loud: when it
finishes a request, hits a meaningful new phase, or is blocked and needs
the user. The tool is a no-op agent-side; the desktop renderer observes
the surfaced tool_call and routes it to a core SpeechQueueService.

SpeechQueueService is portable orchestration living in core: it
serializes utterances so parallel sessions never talk over each other,
coalesces stale per-task narration, lets needs-user lines jump the queue
(and never drops them), and caps the queue so a burst can't back up
minutes of speech. It depends only on the host-neutral ISpeech
capability plus injected settings/name providers.

The app owns the "PostHog Code task '…' —" prefix and needs-user "Hey
<name>," greeting via composeUtterance, so the agent supplies only
content. Playback goes through an ElevenLabs adapter with a macOS `say`
fallback; users control which kinds are spoken (and can mute progress)
in Notifications settings.

Generated-By: PostHog Code
Task-Id: 6912b099-cb75-4f41-a337-1d2275c4be66
The `speak` "done" case was getting skipped: once the model writes its
text answer the turn feels delivered and it stops before the trailing
speak call. Advisory phrasing ("lean toward using it") lost to that
default.

Reframe as a hard end-of-turn rule and, crucially, tell the model to
call speak BEFORE its final text reply — mid-turn, while still in
tool-calling mode — which is the only reliable ordering.

Generated-By: PostHog Code
Task-Id: 6912b099-cb75-4f41-a337-1d2275c4be66
- Add enqueueSpeech to the cloud-task-update notification test harness deps
  so SessionService no longer throws "enqueueSpeech is not a function".
- Allowlist the host-resident ElevenLabs speech service (the API key stays
  in the host process, matching secure-store/encryption), fixing the host
  boundary check.
- composeUtterance: run the task-prefix guard before greeting injection so
  an already-prefixed agent line can't be double-prefixed when addressing by
  name; add a regression test.
- Key speakCalls by taskRunId so a session teardown drops any speak calls
  still mid-stream instead of leaking them until reset().

Generated-By: PostHog Code
Task-Id: 3553940c-2b6e-41dd-bc02-27f5df786a29
Adding the always-enabled `speak` tool to LOCAL_TOOLS means the local-tools
MCP server is now built on every session (desktop and cloud), not just when a
cloud-only git tool's gate passes.

- claude local-tools.test: a desktop run now exposes `speak` (and still omits
  the cloud-only signed-git tools), so assert that instead of an undefined
  server.
- codex-agent.test: default the mocked existsSync to true in beforeEach so the
  always-on local-tools MCP script resolves; the "no structured-output"
  no-op cases now assert `posthog_output` is absent rather than the whole
  mcpServers array being empty; and the injection case looks up posthog_output
  by name since the local-tools server is also present.

Generated-By: PostHog Code
Task-Id: 3553940c-2b6e-41dd-bc02-27f5df786a29
Consecutive permission requests each spoke "needs your input" even while
the user was staring at the chat clicking Approve: needs_input bypassed
focus routing unconditionally.

Tag each speech request with its source — the agent's intentional speak
tool call vs the deterministic turn/permission backstop. Backstop lines
never play when focus routing says "suppress" (app focused, viewing that
task); agent lines keep current behavior, including the needs_input
focus bypass. A prompt on a task you're not viewing still speaks.

Also adds the enqueueSpeech fake missing from the cloud-notification
test harness since the speak feature landed.

Generated-By: PostHog Code
Task-Id: 6912b099-cb75-4f41-a337-1d2275c4be66
@jonmcwest jonmcwest force-pushed the posthog-code/agent-spoken-narration branch from 9e0bf61 to 8818df8 Compare July 5, 2026 19:02
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