feat(voice): a call gets the real system prompt, so it can be delegated to - #2406
Conversation
…ed to
A voice call could hold a conversation and reach about ten tools. It could not
be delegated to, and the reason was not tone.
`buildRealtimeToolSet` hand-rolled the search-mode exposure split
(`splitToolsForExposure` + the two scaffolding factories) instead of calling
`applyToolExposureMode`, and so kept only half of what that function returns.
The other half is the discovery prompt naming every deferred tool. They are one
expression there precisely so the advertised tools and the text describing them
cannot disagree — and voice dropped the text. `tool_search` and `execute_tool`
rode every session while nothing in the instructions ever named them, so
`create_task`, `spawn_session`, the calendar family and the workflow tools were
loaded and undiscoverable. The model answered "I can't do that" about tools it
was holding.
The prompt around them was thin for the same reason nobody noticed: voice built
its own five-bullet string rather than the assembly the typed surface uses, so
it also carried no workspace knowledge, no skill catalog, no agent memory and no
plan pointer.
WHAT THIS DOES
- `buildAgentSystemPrompt` (core/prompt-assembly.ts) — the stable system prefix,
extracted from page-chat-turn.ts and global-chat-turn.ts with both surfaces
side by side. The order and the blank-slate branch are the parts that lived in
no single place and drifted; global-chat-turn's own docblock records where
that ended up ("it claimed tasks create linked DOCUMENT pages").
- realtime/system-context.ts gathers that assembly's inputs for whichever
surface a call is bound to. Every read is individually best-effort and names
itself in the log: a dead plan pointer costs the pointer, not the call.
- realtime/instructions.ts is now only what changes because the words are HEARD,
appended last as an explicit override block. gpt-realtime degrades on
conflicting instructions specifically, so the conflicts are named and resolved
("Skip preambles" does NOT apply here) rather than left to the model.
- realtime/tools.ts calls applyToolExposureMode and carries both halves out.
TWO BEHAVIOR CHANGES BEYOND THE REFACTOR, BOTH DELIBERATE
- A whitespace-only custom systemPrompt is no longer a prompt. It was truthy on
the typed surface too, so one stray space in the field suppressed the default
persona AND the workspace knowledge, leaving an agent whose entire brief was
" ".
- An agent allowed only core tools no longer gets tool_search/execute_tool over
an empty catalog. That is applyToolExposureMode's own rule, now shared instead
of re-decided: two tools whose every call fails are worse than two tools
absent.
The page/global duplication ratchet moved DOWN to 164 and is lowered in all
three recorded homes. Deliberately still omitted from a call: the page tree, the
drive prompt and the cross-drive member context — instructions ride a single
session.update at socket open and there is no path that sends a second, so a
drive's instructions frozen at connect time would go wrong the moment the caller
walked to another drive. The tools read the live location instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
🚧 Files skipped from review as they are similar to previous changes (3)
📝 WalkthroughWalkthroughThe PR centralizes page and global prompt assembly, extends the shared path to realtime voice calls, applies request-scoped tool exposure and context loading, and adds coverage for prompt content, voice behavior, authorization paths, failures, and stable output. ChangesUnified prompt assembly
Estimated code review effort: 4 (Complex) | ~45 minutes Mergeability Score: ⚪ Minimal · up to The change gives voice calls the shared system prompt and matching tool discovery/exposure while removing an unnecessary pre-call scan; no actionable merge-blocking risk remains after normal checks and review. Sequence Diagram(s)sequenceDiagram
participant Caller
participant VoiceCallRoute
participant loadVoiceBinding
participant buildVoiceCallContext
participant buildAgentSystemPrompt
participant VoiceRuntime
Caller->>VoiceCallRoute: authenticated realtime call
VoiceCallRoute->>loadVoiceBinding: request-scoped dependencies
loadVoiceBinding->>buildVoiceCallContext: conversation and agent metadata
buildVoiceCallContext->>buildAgentSystemPrompt: tools, memory, plans, and personalization
buildAgentSystemPrompt-->>buildVoiceCallContext: stable agent system prompt
buildVoiceCallContext-->>loadVoiceBinding: voice instructions and realtime tools
loadVoiceBinding-->>VoiceRuntime: voice binding
VoiceRuntime-->>Caller: spoken response or delegated action result
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e3e009aac2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| ): Promise<string> => { | ||
| const allowlist: ToolAllowlist = request.agent?.enabledTools ?? null; | ||
| const exposure = buildRealtimeToolExposure(deps.buildTools(), allowlist); | ||
| const allowedToolNames = Object.keys(exposure.tools); |
There was a problem hiding this comment.
Derive prompt capability names before search exposure
When an enabled capability is deferred, such as create_task, spawn_session, or edit_sheet_cells, exposure.tools contains only core tools plus the search scaffolding. Using those keys as allowedToolNames makes buildInlineInstructions and buildBuiltinSkillCatalog treat the deferred capabilities as disabled, so page agents lose task, delegation, and automation guidance and omit skills such as task management and spreadsheets even though the non-core catalog says those tools are callable. Derive this list from the allowlist-filtered registry before applying search exposure.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct, and this was the sharpest finding on the PR — thank you. Fixed in aa5e724.
Object.keys(exposure.tools) is the post-split set, so every deferred capability read as disabled: buildInlineInstructions dropped TASK MANAGEMENT, AGENTS and AUTOMATION, and buildBuiltinSkillCatalog dropped task-management and spreadsheets (their requiredTools are create_task/update_task and edit_sheet_cells). writing-documents survived on core tools alone and kept the SKILLS section looking populated, which is what made it easy to miss.
Rather than deriving the list at the call site, buildRealtimeToolExposure now returns allowedToolNames itself — captured after the allowlist filter and before the split, the same point page-chat-turn.ts:1306 captures it. The wrong list is no longer reachable from the caller.
Pinned by a test that asserts the guidance sections on the page branch specifically (the Global Assistant's builder states them unconditionally and would have passed either way), and mutation-checked: restoring Object.keys(exposure.tools) turns it red.
| request.conversationId | ||
| ? softly( | ||
| deps, | ||
| 'activePlan', | ||
| () => deps.loadActivePlan(request.conversationId as string, request.userId), |
There was a problem hiding this comment.
Refresh active-plan instructions after plan mutations
For a call that invokes set_plan or clear_plan, this lookup freezes the pre-call plan binding into session instructions that are never updated. Subsequent utterances in the same call therefore either lack the newly selected plan or continue receiving the old system-level ACTIVE PLAN directive after it was cleared, which can make the assistant re-read and continue working against a stale plan. Mutable plan state needs a session.update path or must be obtained dynamically instead of being captured only at handshake.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real, and specific to this surface — thank you. Addressed in aa5e724, though not the way you suggest, and I want to be explicit about the tradeoff.
The ACTIVE PLAN block is a directive rather than a fact ("keep working against this page, re-read it before continuing, call clear_plan when finished"), so a stale one is actively wrong and not merely missing. You are right that a clear_plan mid-call leaves the model being told to resume a plan the caller just ended.
A session.update path is the correct fix and is genuinely out of scope here: it needs a new contract message, a sender in apps/realtime, and care around the empty-instructions footgun at session.ts:72-90, where an empty instructions replaces the session persona rather than leaving it alone.
What I did instead uses the one channel that can already correct the record mid-call — the model's own tool result:
Everything above was assembled when this call started and is never re-sent. If you change any of it during the call — bind or clear a plan, edit your memory page, move or rename something — YOUR OWN TOOL RESULT IS WHAT IS CURRENT... Never re-follow a standing instruction about something you have since changed.
That generalises past the plan to agent memory and anything else in the same class. It is a mitigation, not a mechanism, and I would rather say so than claim it is equivalent. If you think the residual risk warrants blocking on the real session.update path, say so and I will scope it.
| const agentAwareness = await softly( | ||
| deps, | ||
| 'agentAwareness', | ||
| () => deps.loadAgentAwareness(request.userId), |
There was a problem hiding this comment.
Avoid scanning every drive during the voice handshake
On every unbound or global call, this read blocks before runCallHandshake performs the SDP exchange. buildAgentAwarenessPrompt first selects every non-trashed drive in the deployment, then awaits getUserDriveAccess serially for each one and performs additional sequential agent permission checks, so voice setup latency and database work grow with all tenants' drives rather than this user's drives. On a populated installation this can substantially delay call establishment; use a membership-scoped query or move this work off the handshake's critical path.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed by reading it, and worse than "grows with drives" — buildAgentAwarenessPrompt selects every non-trashed drive in the deployment, then awaits getUserDriveAccess per drive serially, then canUserViewPage per agent serially. All of it before the SDP exchange, with the caller holding a dead line waiting to be heard.
Dropped from the call path in aa5e724. The capability is not lost: the AGENTS guidance is still in the prompt, and list_agents / multi_drive_list_agents fetch the list on the one turn that actually needs it rather than on every call that might — which is the better trade on a surface where the standing prompt cannot be re-sent anyway.
I have deliberately not rewritten the query itself. It is inefficient on the typed surface too, but that is a shared function on a non-latency-critical path and it deserves its own change rather than riding along here. Flagging it as worth a follow-up.
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
apps/web/src/lib/ai/realtime/__tests__/tools.test.ts (1)
262-324: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for the new
nonCoreToolNamesfield.This block pins
toolDiscoveryPromptwell, including the registry-wide invariant at Lines 307-323.RealtimeToolExposurealso exposesnonCoreToolNames, andsystem-context.tssends that value to the global surface as the standalone catalog. No test asserts it.Pin two properties: the catalog names the deferred tools, and it is
''when nothing is deferred.♻️ Suggested additional cases
it('given nothing to defer, should return no prompt rather than an empty instruction', () => { expect(buildRealtimeToolExposure({ read_page: fakeTool() }).toolDiscoveryPrompt).toBe(''); expect(buildRealtimeToolExposure({}).toolDiscoveryPrompt).toBe(''); }); + + it('should return the catalog alone for the surface that states the "how" earlier', () => { + // The global surface takes nonCoreToolNames on its own and states + // TOOL_DISCOVERY_PROMPT itself, so the two halves must stay consistent. + const { nonCoreToolNames } = buildRealtimeToolExposure(smallSet()); + + expect(nonCoreToolNames).toContain('rename_drive'); + expect(nonCoreToolNames).not.toContain('read_page'); + }); + + it('given nothing to defer, should return an empty catalog', () => { + expect(buildRealtimeToolExposure({ read_page: fakeTool() }).nonCoreToolNames).toBe(''); + expect(buildRealtimeToolExposure({}).nonCoreToolNames).toBe(''); + });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/lib/ai/realtime/__tests__/tools.test.ts` around lines 262 - 324, Add tests in the “buildRealtimeToolExposure — the discovery prompt” suite for the `nonCoreToolNames` field: verify it includes every deferred tool name from a registry, and verify it equals `''` when the registry has no deferred tools. Use the existing `smallSet()` and `buildPageSpaceTools`/allowlist scenarios where appropriate, without changing the existing prompt assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/web/src/lib/ai/realtime/instructions.ts`:
- Around line 85-86: The voice instructions unconditionally require tool_search
even for core-only agents that do not expose discovery tools. In
apps/web/src/lib/ai/realtime/instructions.ts:85-86, pass discovery availability
into buildVoiceInstructions and omit or replace that rule when tool_search is
unavailable; update
apps/web/src/lib/ai/realtime/__tests__/instructions.test.ts:72-76 to assert the
rule only with discovery tools, and add a core-only registry case in
apps/web/src/lib/ai/realtime/__tests__/system-context.test.ts:75-105 verifying
tool_search, execute_tool, and their guidance are absent.
In `@apps/web/src/lib/ai/realtime/system-context.ts`:
- Around line 166-184: The global realtime prompt currently invokes
buildAgentSystemPrompt with an empty deferred catalog while the builder still
emits discovery instructions. Update the global branch around
buildAgentSystemPrompt, or the shared builder’s global-surface logic, to include
discovery text only when exposure.nonCoreToolNames is non-empty, preserving the
existing prompt when deferred tools are available.
- Around line 118-121: Update the realtime context construction around
buildRealtimeToolExposure and allowedToolNames to preserve the pre-exposure
names from deps.buildTools() for buildBuiltinSkillCatalog and the inline/global
instruction builders, while retaining the exposed names for actual exposure
behavior. Build the eligible skill catalog before exposure and pass it as
searchableSkills to buildRealtimeToolExposure so tool_search can resolve
advertised skills.
---
Nitpick comments:
In `@apps/web/src/lib/ai/realtime/__tests__/tools.test.ts`:
- Around line 262-324: Add tests in the “buildRealtimeToolExposure — the
discovery prompt” suite for the `nonCoreToolNames` field: verify it includes
every deferred tool name from a registry, and verify it equals `''` when the
registry has no deferred tools. Use the existing `smallSet()` and
`buildPageSpaceTools`/allowlist scenarios where appropriate, without changing
the existing prompt assertions.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 1a342ec5-48ad-4347-b3b7-d11318529564
📒 Files selected for processing (21)
CHANGELOG.mdapps/web/src/app/api/voice/realtime/call/__tests__/route.test.tsapps/web/src/app/api/voice/realtime/call/route.tsapps/web/src/lib/ai/chat-pipeline/__tests__/turn-duplication-ratchet.test.tsapps/web/src/lib/ai/chat-pipeline/global-chat-turn.tsapps/web/src/lib/ai/chat-pipeline/handle-chat-turn.tsapps/web/src/lib/ai/chat-pipeline/page-chat-turn.tsapps/web/src/lib/ai/core/__tests__/agent-system-prompt.test.tsapps/web/src/lib/ai/core/prompt-assembly.tsapps/web/src/lib/ai/core/system-prompt.tsapps/web/src/lib/ai/realtime/__tests__/binding-loader.test.tsapps/web/src/lib/ai/realtime/__tests__/instructions.test.tsapps/web/src/lib/ai/realtime/__tests__/system-context.test.tsapps/web/src/lib/ai/realtime/__tests__/tools.test.tsapps/web/src/lib/ai/realtime/binding-loader.tsapps/web/src/lib/ai/realtime/instructions.tsapps/web/src/lib/ai/realtime/system-context.tsapps/web/src/lib/ai/realtime/tools.tsapps/web/src/lib/ai/realtime/voice-runtime-deps.tsapps/web/src/lib/ai/tools/tool-exposure.tsdocs/2.0-architecture/agent-sessions.md
Six review findings, all of them real. The first two are the same bug and it
undercut the change this PR exists to make.
CAPABILITY NAMES WERE READ AFTER THE SPLIT, NOT BEFORE IT
`allowedToolNames` came from `Object.keys(exposure.tools)`. After the exposure
split that object holds the core tools and the two scaffolding tools and nothing
else, so every capability the split deferred looked disabled to the things gated
on that list. `buildInlineInstructions` dropped TASK MANAGEMENT, AGENTS and
AUTOMATION; `buildBuiltinSkillCatalog` dropped `task-management` and
`spreadsheets` while `writing-documents` survived and kept the section looking
populated. The catalog went on advertising create_task and spawn_session as
callable with every word of guidance about them removed — the model told the
tools exist and nothing about when to use them.
`page-chat-turn.ts:1306` captures the same list before exposure with a comment
saying why. The exposure now returns `allowedToolNames` itself, so the wrong
list is no longer reachable from the call site, and returns `eligibleSkills`
with it — computed internally rather than accepted as a parameter no caller was
passing, which is why `tool_search` could not resolve a skill the prompt
advertised.
RULES THAT NAMED TOOLS THAT WERE NOT THERE
An agent allowed only core tools is registered no scaffolding, by design. The
override still ordered it to call `tool_search` before refusing anything, and
still offered `spawn_session` and `create_task` as hand-offs. Both are now
conditioned on the exposed and reachable sets respectively, which is why the
override needs `VoiceToolReach` and why `buildVoiceSystemContext` — the only
place holding both halves — now returns the finished instructions rather than
just the system prompt. The shared builder had the same problem on the global
surface, stating TOOL_DISCOVERY_PROMPT with nothing deferred; gated on the
catalog it introduces, which is unreachable for the text routes.
A WHOLE-DEPLOYMENT SCAN ON THE HANDSHAKE
`buildAgentAwarenessPrompt` selects every non-trashed drive in the deployment,
then awaits an access check per drive and a view check per agent, one after
another — all of it before the SDP exchange, with the caller holding a dead line
waiting to be heard. The eager list is dropped from the call path. The AGENTS
guidance stays, and `list_agents` fetches the list on the one turn that needs it
rather than on every call that might. The query itself is inefficient on the
typed surface too; that is not this PR's to fix.
INSTRUCTIONS THAT CANNOT BE RE-SENT
A call's instructions are assembled once at socket open, so the ACTIVE PLAN
pointer — a directive, not a fact ("keep working against this page, re-read it
before continuing") — would go on being followed after a `clear_plan` on the
same call. The one channel that can correct the record mid-call is the model's
own tool result, so the override now names it as authoritative for anything the
model changes while talking. That covers the plan, the memory page, and
everything else in the same class.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/src/lib/ai/realtime/binding-loader.ts (1)
103-108: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winMake the fallback independent of
buildInstructions.
unboundawaitsdeps.buildInstructionsat Line 108. If that promise rejects,loadVoiceBindingcatches it at Lines 207-215 and callsunboundagain. The same promise can reject again, so the call fails instead of falling back.Catch instruction-building failures once and return a non-throwing baseline instruction value. Do not route this fallback through
deps.buildInstructionsagain.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/web/src/lib/ai/realtime/binding-loader.ts` around lines 103 - 108, Update unbound so its fallback does not call or await deps.buildInstructions; return the baseline non-throwing instruction value directly while preserving the empty seed. Ensure the loadVoiceBinding recovery path can invoke unbound without repeating the failing instruction-building operation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@apps/web/src/lib/ai/realtime/binding-loader.ts`:
- Around line 103-108: Update unbound so its fallback does not call or await
deps.buildInstructions; return the baseline non-throwing instruction value
directly while preserving the empty seed. Ensure the loadVoiceBinding recovery
path can invoke unbound without repeating the failing instruction-building
operation.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c383f35c-09ff-4c55-8a79-d428d6c4e5e6
📒 Files selected for processing (10)
apps/web/src/lib/ai/core/__tests__/agent-system-prompt.test.tsapps/web/src/lib/ai/core/prompt-assembly.tsapps/web/src/lib/ai/realtime/__tests__/binding-loader.test.tsapps/web/src/lib/ai/realtime/__tests__/instructions.test.tsapps/web/src/lib/ai/realtime/__tests__/system-context.test.tsapps/web/src/lib/ai/realtime/binding-loader.tsapps/web/src/lib/ai/realtime/instructions.tsapps/web/src/lib/ai/realtime/system-context.tsapps/web/src/lib/ai/realtime/tools.tsapps/web/src/lib/ai/realtime/voice-runtime-deps.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- apps/web/src/lib/ai/core/tests/agent-system-prompt.test.ts
- apps/web/src/lib/ai/realtime/tests/binding-loader.test.ts
- apps/web/src/lib/ai/realtime/tests/instructions.test.ts
- apps/web/src/lib/ai/realtime/tests/system-context.test.ts
- apps/web/src/lib/ai/realtime/voice-runtime-deps.ts
- apps/web/src/lib/ai/core/prompt-assembly.ts
…shape Three cleanups on the review fixes, all found by rereading the diff as a reviewer would: - `RealtimeToolExposure.eligibleSkills` was returned and never read. The value is real — it is the corpus `tool_search` gets — but it is consumed inside the exposure, so exporting it was documentation pretending to be an interface. - `buildVoiceSystemContext` built its argument object inline inside the call that wrapped it, which read as one expression doing two things. Each branch now names its system prompt and then caps it. - `HAND_OFFS` was rebuilt on every call and sat between a docblock and the function it documented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
Making delegation conditional on reachable tools dropped a clause the original override had: work that should happen LATER, via a trigger or a workflow. A call is exactly where that matters — the caller cannot sit and wait, so "later" is often the right answer and the model had stopped being told it was available. Restored as a third entry, keyed on either set_task_trigger or create_workflow rather than one tool: the phrase names a destination, not a call, so an agent that can set a trigger but not build a workflow should still hear it. Caught by reading the generated prompt end to end rather than by a test — worth noting, since every existing test asserts fragments and none of them would have missed a whole missing clause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
The last piece of the extraction. `complete-request-builder.ts` renders a page titled "the exact context window", and it was assembling its own prompt — the third copy, and one that had already drifted: - it omitted the Global Assistant's exploration guidance ENTIRELY, so an admin reading it never saw the rules that decide which drive "here" means; - it called the capability builders with no arguments, which is the no-filtering-context sentinel, so the sections it showed belonged to no caller in particular; - it ordered the blocks in a way neither route used. It now calls `buildAgentSystemPrompt`, and honours its own contextType instead of always previewing the global surface. The blocks it genuinely cannot know — plan pointer, agent memory, drive context — are passed empty with the reason stated, rather than being silently absent. Pinned by a byte-identity test against the shared builder, because this file's whole claim is exactness and "looks about right" is the failure mode. One test of my own was wrong before it was right: I asserted a read-only preview would drop `create_task`, which the global surface never gated. Fixed to assert what the change actually does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
The route built the advertised tool definitions and `system-context` built the
prompt describing them, each calling the exposure separately with the same
inputs. Deterministic, so they agree — today. It is also the precise
arrangement that produced the bug this PR exists to fix: a session advertising
`tool_search` while the prompt never named it.
They are two projections of one decision (what this agent may reach), so the
exposure is computed once and both come out of it. `buildVoiceCallContext`
returns `{ instructions, tools }`, the binding carries them together, and the
route forwards what it was given instead of rebuilding.
Also removes a second full registry build and exposure split from the
handshake — the path the caller is waiting through, and the one codex flagged
for latency.
Pinned by a test that every advertised tool name appears in the prompt that
ships with it, and its converse for a core-only agent: no scaffolding
advertised, none promised.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
`buildVoiceCallContext` returns the tools alongside the instructions, and the header still said the module produced a system prompt. Also states the reason the two travel together, which is the whole reason the module is shaped this way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
The context ceiling was measured with nothing loaded — the case least likely to breach it. The realistic heavy call is a bound agent carrying a memory page at its own ~2k-token cap, a plan pointer, and personalization the user wrote, and that is what has to fit beside 4k of seed and the audio for the length of the call. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
Consolidating the exposure left `buildRealtimeTools` with no production caller — only tests kept it compiling, which is exactly the kind of dead export knip cannot see, since a test importing it counts as a use. Replaced with `toRealtimeTools(toolSet)`: the projection both sides actually need, taking the already-exposed set rather than a registry and an allowlist. Re-deriving the exposure inside it is what would put the advertised list and the prompt describing it back on separate computations, which is the thing this PR spent two rounds removing. The tests keep every case — schema conversion guard, flat wire shape, allowlist behaviour — through a two-line local helper, so coverage is unchanged and only the seam moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
…ing' The entry claimed a call carries "the same instructions and the same knowledge of your workspace" as the typed surface. It carries the same OPERATING knowledge — how tasks, agents, automations and search work, the skill catalog, the tools behind the discovery catalog — but deliberately not the page tree, the drive prompt or the cross-drive member context, each omitted for a reason recorded in system-context.ts. Claiming parity we do not have is the kind of thing a changelog should not do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
A voice call could hold a conversation and reach about ten tools. It could not be delegated to, and the reason was not tone.
The bug
buildRealtimeToolSethand-rolled the search-mode exposure split —splitToolsForExposureplus the two scaffolding factories — instead of callingapplyToolExposureMode. That function returns the tool set and the discovery prompt naming every deferred tool, as one expression, precisely so the advertised tools and the text describing them cannot disagree. Voice reproduced the tool half and dropped the text half.So
tool_searchandexecute_toolrode every session while nothing in the instructions ever named them, and everything outsideCORE_TOOL_NAMES—create_task,spawn_session, the calendar family, the workflow tools — was loaded and undiscoverable. The model answered "I can't do that" about tools it was holding.The prompt around them was thin for the same underlying reason: voice built its own five-bullet string rather than the assembly the typed surface uses, so a call also carried no workspace knowledge, no skill catalog, no agent memory and no plan pointer.
What this does
buildAgentSystemPrompt(core/prompt-assembly.ts) — the stable system prefix, extracted frompage-chat-turn.tsandglobal-chat-turn.tswith both surfaces side by side. The order, and the blank-slate branch for an agent carrying its own prompt, lived in no single place and drifted;global-chat-turn.ts's own docblock records where that ended up ("it claimed tasks create linked DOCUMENT pages; they create TASK_LIST children"). Text output is unchanged — characterization tests reproduce the old expression by hand, and the moved Global Assistant literal was diffed byte-for-byte againstHEAD.realtime/system-context.tsgathers that assembly's inputs for whichever surface a call is bound to, and caps it with the spoken override. Every read is individually best-effort and names itself in the log, so a dead plan pointer costs the pointer, not the call.realtime/instructions.tsis now only what changes because the words are heard, appended last as an explicit override block.gpt-realtimedegrades on conflicting instructions specifically, so conflicts are named and resolved ("Skip preambles" does NOT apply here) rather than left for the model to arbitrate.realtime/tools.tscallsapplyToolExposureModeand carries the whole exposure out: the tool set, both halves of the discovery text, and the pre-split capability names.core/complete-request-builder.ts— the admin "exact context window" viewer was the third copy, and had already drifted: it omitted the Global Assistant's exploration guidance entirely and called the capability builders with the no-filtering sentinel. It now calls the shared builder and honours its owncontextType, pinned by a byte-identity test.A call now also stops asking permission before acting, hands multi-minute work to
spawn_session/create_taskinstead of leaving the caller in silence, and knows that "this page" resolves without an id.Review round 1 (aa5e724)
Six findings from
chatgpt-codex-connectorandcoderabbitai, all valid:Object.keys(exposure.tools)is the post-split set, so every deferred capability looked disabled:buildInlineInstructionsdropped TASK MANAGEMENT / AGENTS / AUTOMATION, and the skill catalog droppedtask-managementandspreadsheets, whilewriting-documentssurvived on core tools and kept the section looking populated. The exposure now returnsallowedToolNamesitself, captured wherepage-chat-turn.ts:1306captures it.tool_searchhad no skills to search.searchableSkillswas a parameter nobody passed. Now computed inside the exposure from the pre-split names; the parameter is gone.tool_search, and still offeredspawn_session/create_task. Both are now conditioned on the exposed and reachable sets. The shared builder had the same problem on the global surface and is gated on the catalog it introduces.buildAgentAwarenessPromptselects every non-trashed drive, then awaits an access check per drive and a view check per agent serially — before the SDP exchange. Dropped from the call path;list_agentsfetches the list on the turn that needs it. The query's own inefficiency is pre-existing on the typed surface and left alone.set_task_triggerorcreate_workflow.clear_planmid-call would leave the model told to resume a plan the caller just ended. Asession.updatepath is the real fix and is out of scope; the override now names the model's own tool result as authoritative for anything it changes during the call, which generalises to agent memory too. Documented as a mitigation, not a mechanism.Follow-on hardening
The exposure was being computed twice per call — once in
route.tsfor the advertised tool definitions, once insystem-context.tsfor the prompt describing them. Deterministic, so they agreed; also exactly the arrangement that produced the original bug.buildVoiceCallContextnow returns{ instructions, tools }from one exposure, the binding carries both, and the route forwards what it was given. That also takes a second full registry build off the handshake the caller is waiting through.Pinned by a test asserting every advertised tool name appears in the prompt shipped with it, and its converse for a core-only agent.
Two behavior changes beyond the refactor, both deliberate
systemPromptis no longer a prompt. It was truthy on the typed surface too, so one stray space in the field suppressed the default persona and the workspace knowledge, leaving an agent whose entire brief was" ".tool_search/execute_toolover an empty catalog. That isapplyToolExposureMode's own rule, now shared instead of re-decided: two tools whose every call fails are worse than two tools absent.Deliberately still omitted from a call
The page tree, the drive prompt, and the cross-drive member context. Instructions ride a single
session.updateat socket open and there is no path that sends a second, so a drive's instructions frozen at connect time would go wrong the moment the caller walked to another drive. The tools read the live location instead. Reasons are recorded insystem-context.ts.Verification
typecheck(incl. build + lint),lint,knipgreen.create_task, which the global surface never gated). Each was tightened until the mutation went red, or corrected.Not verified: a real call. Everything here is unit-level, and a prompt is only really tested by talking to it. The check that matters is "What's on my calendar tomorrow?" → expect
tool_search→execute_toolrather than a refusal.If it over-fires — calling tools before the caller finishes a sentence — the remedy is to soften rather than delete: lower-case the capitalized
DO NOT ASK PERMISSIONand leave the rest standing.🤖 Generated with Claude Code
https://claude.ai/code/session_01DEofnKqGYY8Gs8CjwkGPXd
Summary by CodeRabbit
New Features
Documentation