Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ All notable user-facing changes to PageSpace are documented here. Format follows
- **The assistant on the call is the one you picked, with the instructions and the tools its owner
gave it** — an agent you built to answer a particular way answers that way out loud too, and one
whose tools you restricted cannot reach past them just because the conversation is spoken.
- **You can now delegate out loud, not just talk** — the assistant on a call now knows how to
operate your workspace the same way it does when you type at it: how tasks, agents, automations
and search work, which skills it can load, and the tools it does not list up front. Ask it about
your calendar, to file a task, or to set something running, and it goes and does it instead of
saying it cannot. It also stops asking permission first: say what you want and it acts, then tells
you what it did. Work that would take minutes gets handed to an agent or a task rather than
leaving you listening to silence, and it will say where it went. "This page" and "here" mean what
you are looking at, so it never asks you to read out an id.
- **A call you cannot have does not start** — running out of credit, or already having as many calls
open as your plan allows, now says so and stops, instead of connecting anyway and leaving you
talking to something nobody was counting.
Expand Down
32 changes: 18 additions & 14 deletions apps/web/src/app/api/voice/realtime/call/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const {
mockIsBillingEnabled,
mockGetUserSettings,
mockRunCallHandshake,
mockBuildRealtimeTools,
mockSignHeaders,
mockLoadVoiceBinding,
} = vi.hoisted(() => ({
Expand All @@ -25,7 +24,6 @@ const {
mockIsBillingEnabled: vi.fn(),
mockGetUserSettings: vi.fn(),
mockRunCallHandshake: vi.fn(),
mockBuildRealtimeTools: vi.fn(),
mockSignHeaders: vi.fn(),
}));

Expand All @@ -45,9 +43,10 @@ vi.mock('@pagespace/lib/auth/broadcast-auth', () => ({
createSignedBroadcastHeaders: mockSignHeaders,
}));
vi.mock('@/lib/ai/realtime/call-handshake', () => ({ runCallHandshake: mockRunCallHandshake }));
vi.mock('@/lib/ai/realtime/tools', () => ({ buildRealtimeTools: mockBuildRealtimeTools }));
vi.mock('@/lib/ai/realtime/binding-loader', () => ({ loadVoiceBinding: mockLoadVoiceBinding }));
vi.mock('@/lib/ai/realtime/voice-runtime-deps', () => ({ voiceBindingDeps: {} }));
// A FACTORY, not a value: the binding deps take the caller's auth principal,
// because the active-plan lookup behind them needs a principal-aware page check.
vi.mock('@/lib/ai/realtime/voice-runtime-deps', () => ({ voiceBindingDeps: () => ({}) }));
vi.mock('@/lib/ai/core/ai-tools', () => ({ buildPageSpaceTools: () => ({}) }));
vi.mock('@pagespace/lib/audit/audit-log', () => ({ auditRequest: vi.fn() }));
vi.mock('@pagespace/lib/logging/logger-config', () => ({
Expand Down Expand Up @@ -77,8 +76,7 @@ describe('POST /api/voice/realtime/call', () => {
mockGetManagedKey.mockReturnValue({ apiKey: 'sk-managed' });
mockIsBillingEnabled.mockReturnValue(true);
mockGetUserSettings.mockResolvedValue({ subscriptionTier: 'pro' });
mockBuildRealtimeTools.mockReturnValue(TOOLS);
mockLoadVoiceBinding.mockResolvedValue({ seed: [], instructions: 'Speak out loud.' });
mockLoadVoiceBinding.mockResolvedValue({ seed: [], instructions: 'Speak out loud.', tools: TOOLS });
mockSignHeaders.mockReturnValue({ 'X-Broadcast-Signature': 't=1,v1=sig' });
mockRunCallHandshake.mockResolvedValue({
ok: true,
Expand Down Expand Up @@ -331,28 +329,34 @@ describe('POST /api/voice/realtime/call', () => {
enabledTools: ['read_page'],
};

it("should advertise only the bound agent's allowed tools", async () => {
it("should advertise the binding's own tools rather than building a second set", async () => {
// The tools and the instructions come from ONE exposure, computed where
// the allowlist is resolved. A route that rebuilt them from the assistant
// could advertise a set the prompt does not describe — and would repeat a
// whole registry build on a handshake the caller is waiting through.
const agentTools = [
{ type: 'function' as const, name: 'read_page', description: 'r', parameters: {} },
];
mockLoadVoiceBinding.mockResolvedValue({
seed: [],
instructions: 'You are "Release Notes Bot".',
tools: agentTools,
assistant,
});

await POST(callRequest({ sdp: 'v=0 offer', conversationId: 'conv1' }));

expect(mockBuildRealtimeTools).toHaveBeenCalledWith(expect.anything(), ['read_page']);
});

it('given no bound agent, should advertise the registry unrestricted', async () => {
await POST(callRequest({ sdp: 'v=0 offer' }));

expect(mockBuildRealtimeTools).toHaveBeenCalledWith(expect.anything(), null);
expect(mockRunCallHandshake).toHaveBeenCalledWith(
expect.objectContaining({ tools: agentTools }),
expect.anything(),
);
});

it('should hand the instructions and the assistant to the handshake', async () => {
mockLoadVoiceBinding.mockResolvedValue({
seed: [],
instructions: 'You are "Release Notes Bot".',
tools: TOOLS,
assistant,
});

Expand Down
26 changes: 11 additions & 15 deletions apps/web/src/app/api/voice/realtime/call/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ import { PAID_TIERS } from '@/lib/subscription/rate-limit-middleware';
import type { SubscriptionTier } from '@pagespace/lib/services/subscription-utils';
import { createSignedBroadcastHeaders } from '@pagespace/lib/auth/broadcast-auth';
import { resolveRealtimeModel } from '@/lib/ai/realtime/session';
import { buildRealtimeTools } from '@/lib/ai/realtime/tools';
import { buildPageSpaceTools } from '@/lib/ai/core/ai-tools';
import { runCallHandshake } from '@/lib/ai/realtime/call-handshake';
import { loadVoiceBinding } from '@/lib/ai/realtime/binding-loader';
import { voiceBindingDeps } from '@/lib/ai/realtime/voice-runtime-deps';
Expand Down Expand Up @@ -126,7 +124,7 @@ export async function POST(request: Request) {
// binding is derived behind that conversation's own access check. An empty
// binding is a normal outcome (a fresh thread, or one this caller may not
// read) — never a reason to fail the call.
const binding = await loadVoiceBinding(voiceBindingDeps, {
const binding = await loadVoiceBinding(voiceBindingDeps(auth), {
userId,
...(conversationId === undefined ? {} : { conversationId }),
});
Expand All @@ -139,19 +137,17 @@ export async function POST(request: Request) {
// the env override is the entire per-deployment model escape hatch, and
// a default applied deeper down would silently swallow it.
model: resolveRealtimeModel(process.env),
// Built here, at the edge, because the registry has env-dependent
// branches (the code-execution kill switch) that are the caller's
// decision. The realtime server cannot build these itself — the
// registry lives in this app — so they ride the signed internal hop.
// Built with the instructions, from ONE exposure, and carried here on
// the binding. They are two projections of a single decision — what
// this agent may reach — and a session that advertises `tool_search`
// while its prompt never names it is exactly the bug this replaced.
// Building them separately here also meant a second full registry
// build on a handshake the caller is waiting on.
//
// Filtered by the bound agent's own allowlist, so voice advertises what
// the text surface would advertise for the same agent. Advertising the
// whole deployment registry told the model it could call tools the
// agent's owner had switched off.
tools: buildRealtimeTools(
buildPageSpaceTools(),
binding.assistant?.enabledTools ?? null,
),
// Still built in this app rather than the realtime server: the registry
// lives here and has env-dependent branches (the code-execution kill
// switch), so the definitions ride the signed internal hop.
tools: binding.tools,
subscriptionTier: tier,
internalRealtimeUrl: process.env.INTERNAL_REALTIME_URL,
signHeaders: createSignedBroadcastHeaders,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const SPEC = path.join(repoRoot(), 'docs/2.0-architecture/agent-sessions.md');
* settle, telemetry) is where most of it lives and is the extraction that
* would pay first; see the entry's docblock.
*/
const RECORDED_IDENTICAL_LINES = 165;
const RECORDED_IDENTICAL_LINES = 164;

/** Substantive lines: no blanks, no comments, trimmed. */
const substantiveLines = (file: string): string[] =>
Expand Down
91 changes: 30 additions & 61 deletions apps/web/src/lib/ai/chat-pipeline/global-chat-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { askUserTools, ASK_USER_TOOL_NAME } from '@/lib/ai/tools/ask-user-tools'
import { resolveMessageId } from '@/lib/ai/streams/resolveMessageId';
import { canUseAskUser } from '@/lib/ai/core/ask-user-gating';
import { readAgentDispatchDepth } from '@/lib/ai/core/agent-dispatch-depth';
import { ASK_USER_SECTION, buildGlobalAssistantInstructions } from '@/lib/ai/core/inline-instructions';
import { buildLocationTurnPrompt } from '@/lib/ai/core/location-prompt';
import { buildActivePlanPrompt, getActivePlan } from '@/lib/ai/core/plan-binding';
import { resolveHomeDriveHint } from '@/lib/ai/core/home-drive-hint';
Expand Down Expand Up @@ -64,8 +63,7 @@ import {
import { planCommandExecutions } from '@/lib/ai/core/command-resolver';
import { respondWithHelpAnswer } from '@/lib/ai/core/help-responder';
import { buildTimestampSystemPrompt } from '@/lib/ai/core/timestamp-utils';
import { buildSystemPrompt, buildNonCoreToolNamesPrompt, TOOL_DISCOVERY_PROMPT } from '@/lib/ai/core/system-prompt';
import { isCodeExecutionEnabled } from '@pagespace/lib/services/sandbox/can-run-code';
import { buildNonCoreToolNamesPrompt } from '@/lib/ai/core/system-prompt';
import { buildAgentAwarenessPrompt } from '@/lib/ai/core/agent-awareness';
import { filterToolsForReadOnly, filterToolsForWebSearch, filterToolsForImageGen, filterToolsForSandboxTier } from '@/lib/ai/core/tool-filtering';
import { resolveSandboxToolEligibilityForConversation } from '@/lib/ai/core/sandbox-tool-eligibility';
Expand Down Expand Up @@ -111,6 +109,7 @@ import { createToolSearchTool } from '@/lib/ai/tools/tool-search-tool';
import { buildBuiltinSkillCatalog, listEligibleSkills } from '@/lib/ai/core/skill-catalog';
import { loadUserCommandCatalog } from '@/lib/commands/command-catalog-loader';
import {
buildAgentSystemPrompt,
buildVolatileTurnContext,
appendTurnContextToLastUserMessage,
withCacheBreakpoints,
Expand Down Expand Up @@ -825,16 +824,12 @@ export async function runGlobalChatTurn(ctx: GlobalChatTurnContext): Promise<Res
});
}

// Build system prompt. Note: "current page/drive" is turn-volatile — it's
// built separately as `locationPrompt` below and injected via
// buildVolatileTurnContext, NOT baked in here, so this string stays
// byte-identical across turns regardless of where the user navigates.
const baseSystemPrompt = buildSystemPrompt(
readOnlyMode,
personalization ?? undefined,
isCodeExecutionEnabled()
);

// The system prompt itself is assembled once, below, by
// `buildAgentSystemPrompt` — every input it needs is gathered first. Note
// that "current page/drive" is turn-volatile: it is built separately as
// `locationPrompt` and injected via buildVolatileTurnContext, NOT baked
// into the system prompt, so that string stays byte-identical across turns
// regardless of where the user navigates.
const hasLocation = Boolean(locationContext?.currentPage || locationContext?.currentDrive);
// Session-only surface (AUTH_OPTIONS_WRITE allows 'session' only), so the scope
// ceiling is always empty here. Passed explicitly anyway so this stays correct
Expand Down Expand Up @@ -874,48 +869,14 @@ export async function runGlobalChatTurn(ctx: GlobalChatTurnContext): Promise<Res
}
}

// Add global assistant specific instructions (including tool discovery — only this route has tool_search).
// Stable order: base → TOOL_DISCOVERY → global instructions → drivePrompt → agentAwareness → pageTree → nonCoreToolNames.
// Volatile sections (timestamp/location/mention/command) are NOT concatenated here; they are
// appended to the last user message at assembly time so the system prefix stays
// byte-identical across turns and provider prefix caches are not invalidated —
// including turns where only the user's current page/drive changed.
// The Global Assistant's own guidance — the exploration rules and the
// conversation-type report — now lives beside the page surface's assembly in
// `buildAgentSystemPrompt`, so the two can be read against each other.
// Workspace knowledge (page types, tasks, agents, automation, search,
// mentions) comes from the SHARED inline-instructions sections appended
// at finalSystemPrompt assembly below — this route previously carried a
// bespoke copy that drifted (it claimed tasks create linked DOCUMENT
// pages; they create TASK_LIST children). Only genuinely
// global-assistant-specific guidance remains inline here.
const systemPrompt = baseSystemPrompt + '\n\n' + TOOL_DISCOVERY_PROMPT + `

You are the Global Assistant for PageSpace - accessible from both the dashboard and sidebar.

SMART EXPLORATION RULES:
1. When in a drive context (see your current LOCATION context for the driveId) - ALWAYS explore it first:
- ALWAYS use list_pages on the current drive when:
• User asks about the drive, its contents, or what's available
• User wants to create, write, or modify ANYTHING
• User mentions something that MAY exist in the drive
• User asks general questions about content or organization
• You need to understand the workspace structure
- Start with list_pages on the current drive BEFORE other actions
2. Context-first approach:
- Default scope: current drive/location (see LOCATION context) is your primary workspace
- Only explore OTHER drives when explicitly mentioned
- When user says "here" or "this", they mean current context
- If LOCATION context shows no drive, you're in the dashboard — use list_drives when you need to work across multiple workspaces; always check existing drives before suggesting new drive creation — never fall back to the Home drive as a guess
3. Efficient exploration pattern:
- FIRST: list_pages on the current drive — omit driveId and it uses the drive in your LOCATION context
- THEN: read specific pages as needed
- ONLY IF NEEDED: explore other drives/workspaces
4. Proactive assistance:
- Don't ask "what's in your drive" - use list_pages to discover
- Suggest creating AI_CHAT and CHANNEL pages for organization
- Be autonomous within current context

CONVERSATION TYPE: ${conversation.type.toUpperCase()}${conversation.contextId ? ` (Context: ${conversation.contextId})` : ''}` +
(canUseAskUser({ role: auth.role }) ? `\n\n${ASK_USER_SECTION}` : '') +
drivePromptSection;
// mentions) comes from the SHARED inline-instructions sections: this route
// previously carried a bespoke copy that drifted (it claimed tasks create
// linked DOCUMENT pages; they create TASK_LIST children), which is the
// reason there is one assembly now rather than three.

// The PAYER's sandbox eligibility for this Global Assistant turn —
// derived from the conversation's BOUND SESSION, never the location
Expand Down Expand Up @@ -1022,13 +983,21 @@ CONVERSATION TYPE: ${conversation.type.toUpperCase()}${conversation.contextId ?
const activePlanPrompt = buildActivePlanPrompt(await getActivePlan(conversationId, userId));

const nonCoreToolNamesPrompt = buildNonCoreToolNamesPrompt(Object.keys(nonCoreTools));
const finalSystemPrompt = systemPrompt
+ '\n' + buildGlobalAssistantInstructions(availableToolNames)
+ (agentAwarenessPrompt ? '\n\n' + agentAwarenessPrompt : '')
+ pageTreePrompt
+ (nonCoreToolNamesPrompt ? '\n\n' + nonCoreToolNamesPrompt : '')
+ skillCatalogPrompt
+ activePlanPrompt;
const finalSystemPrompt = buildAgentSystemPrompt({
surface: 'global',
readOnly: readOnlyMode,
personalization,
allowedToolNames: availableToolNames,
skillCatalog: skillCatalogPrompt,
activePlan: activePlanPrompt,
pageTree: pageTreePrompt,
conversationType: conversation.type,
conversationContextId: conversation.contextId,
includeAskUser: canUseAskUser({ role: auth.role }),
drivePromptSection,
agentAwareness: agentAwarenessPrompt,
nonCoreToolNames: nonCoreToolNamesPrompt,
});

let finalTools: ToolSet = {
...coreTools,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/ai/chat-pipeline/handle-chat-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
* - `runPageChatTurn` is ~2,080 lines in ONE function; `runGlobalChatTurn`
* ~1,460. Both interleave decision and effect throughout, so neither has an
* extractable core that can be unit-tested without a DB and a provider.
* - 165 substantive lines of 40+ characters are byte-identical between them
* - 164 substantive lines of 40+ characters are byte-identical between them
* (of 994 and 785 respectively). Measured, not estimated — strip comments
* and blanks, compare the sets.
* - The longest identical runs are NOT scattered. They cluster at
Expand Down
Loading