Skip to content

Commit 2a58fd4

Browse files
committed
refactor(P060): bridge owns the schemas; client-tools is a thin adapter
The bridge is the single source of truth for the iframe contract: each operation has a Zod schema (with description) in embed-bridge/schemas.ts, and IframeBridge methods take z.infer<typeof X> directly. The bridge implementation is a one-line postMessage pass- through per method — no key conversion (input shapes mirror the wire's snake_case payloads). The client-tools adapter is now just two files: - schemas.ts: enumerates the LLM tool names exposed to the model. - tools.ts: maps each tool name to { description, inputSchema } pulled verbatim from the bridge schema's `.describe()`. No duplicated text. Consumers (routes/api/chat.ts and lib/byok/transport.ts) drop ~50 lines of inline tool registration each; they now just spread LLM_STATIC_TOOLS into withFinalisationTool. Adding a new LLM-exposed bridge operation: - one schema in embed-bridge/schemas.ts - one method on IframeBridge + one impl line in bridge.ts - one tool entry in client-tools/tools.ts - one switch arm in client-tools/factory.ts dispatch.ts is gone (the switch lives in factory.ts directly).
1 parent c772bbc commit 2a58fd4

14 files changed

Lines changed: 292 additions & 424 deletions

File tree

copilot/src/components/chat/chat_pane.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,7 @@ export const ChatPane = ({
531531
if (activeBridge === null) {
532532
return
533533
}
534-
void activeBridge.submit({ downloadCopy: false })
534+
void activeBridge.submit({ download_copy: false })
535535
}, [])
536536

537537
const handleDownloadRequested = useCallback((): void => {

copilot/src/lib/byok/transport.ts

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,8 @@
11
import { convertToModelMessages, streamText, type UIMessage } from 'ai'
22
import { buildSystemPrompt } from '../../server/tools'
33
import {
4-
DeleteFieldsInput,
5-
DeletePagesInput,
6-
DetectFieldsInput,
74
FINALISATION_ACTION,
8-
FocusFieldInput,
9-
GetDocumentContentInput,
10-
GetFieldsInput,
11-
GoToPageInput,
12-
MovePageInput,
13-
RotatePageInput,
14-
SelectToolInput,
15-
SetFieldValueInput,
5+
LLM_STATIC_TOOLS,
166
withFinalisationTool,
177
} from '../embed-bridge-adapters/client-tools'
188
import { formatStreamError } from '../error-classifier'
@@ -93,58 +83,7 @@ export const runByokStream = async ({ config, init }: RunByokStreamArgs): Promis
9383
abortSignal: init?.signal ?? undefined,
9484
maxRetries: 0,
9585
maxOutputTokens: MAX_OUTPUT_TOKENS,
96-
tools: withFinalisationTool({
97-
get_fields: {
98-
description: 'Lists every fillable field currently on the document.',
99-
inputSchema: GetFieldsInput,
100-
},
101-
get_document_content: {
102-
description: 'Extracts the textual content of the document page by page.',
103-
inputSchema: GetDocumentContentInput,
104-
},
105-
detect_fields: {
106-
description:
107-
'Asks the editor to auto-detect and create missing fields. Call this when get_fields returned 0 fields.',
108-
inputSchema: DetectFieldsInput,
109-
},
110-
delete_fields: {
111-
description:
112-
'Deletes fields from the document. field_ids targets specific fields by id; page targets a single page (1-indexed); both omitted clears all fields. Destructive — only call when the user explicitly asks to delete fields.',
113-
inputSchema: DeleteFieldsInput,
114-
},
115-
select_tool: {
116-
description:
117-
'Switches the editor tool (TEXT, BOXED_TEXT, CHECKBOX, SIGNATURE, PICTURE, or null for cursor).',
118-
inputSchema: SelectToolInput,
119-
},
120-
set_field_value: {
121-
description: 'Writes a value into a single field. Always focus_field first.',
122-
inputSchema: SetFieldValueInput,
123-
},
124-
focus_field: {
125-
description: 'Scrolls to and visually highlights a field.',
126-
inputSchema: FocusFieldInput,
127-
},
128-
go_to_page: {
129-
description: 'Scrolls the editor to a given 1-based page.',
130-
inputSchema: GoToPageInput,
131-
},
132-
move_page: {
133-
description:
134-
'Reorders pages: from_page and to_page are 1-indexed visible page positions. Destructive — only call when the user explicitly asks to reorder a page.',
135-
inputSchema: MovePageInput,
136-
},
137-
delete_pages: {
138-
description:
139-
'Permanently removes one or more visible pages (1-indexed) and any fields placed on them. Pass pages as a non-empty array. At least one visible page must remain — passing every visible page returns event_not_allowed. Destructive — only call when the user explicitly asks to delete pages.',
140-
inputSchema: DeletePagesInput,
141-
},
142-
rotate_page: {
143-
description:
144-
'Rotates a visible page (1-indexed) 90° clockwise per call (repeat for 180° / 270°). Destructive — only call when the user explicitly asks to rotate a page.',
145-
inputSchema: RotatePageInput,
146-
},
147-
}),
86+
tools: withFinalisationTool(LLM_STATIC_TOOLS),
14887
onError: ({ error }) => {
14988
monitoring.error('byok.stream_error', { detail: normalizeError(error) })
15089
},

copilot/src/lib/embed-bridge-adapters/client-tools/dispatch.ts

Lines changed: 0 additions & 74 deletions
This file was deleted.

copilot/src/lib/embed-bridge-adapters/client-tools/factory.ts

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
import type { BridgeResult, IframeBridge } from '../../embed-bridge'
2-
import { dispatch, type ToolInput } from './dispatch'
1+
import {
2+
type BridgeResult,
3+
DeleteFieldsInput,
4+
DeletePagesInput,
5+
FocusFieldInput,
6+
GetDocumentContentInput,
7+
GoToInput,
8+
type IframeBridge,
9+
MovePageInput,
10+
RotatePageInput,
11+
SelectToolInput,
12+
SetFieldValueInput,
13+
} from '../../embed-bridge'
314
import { composeMiddleware, type ToolMiddleware } from './middleware'
4-
import { type ClientToolName, CLIENT_TOOL_SCHEMAS, isClientToolName } from './schemas'
15+
import { type ClientToolName, isClientToolName } from './schemas'
16+
17+
export type ToolInput = Record<string, unknown>
518

619
export type CreateClientToolsArgs = {
720
// The iframe bridge the dispatcher will drive. Usually comes from the React
@@ -20,18 +33,12 @@ export type CreateClientToolsArgs = {
2033
}
2134

2235
export type ClientTools = {
23-
// Zod input schemas keyed by tool name. Spread into streamText({ tools })
24-
// alongside descriptions.
25-
schemas: typeof CLIENT_TOOL_SCHEMAS
2636
// System prompt passed into createClientTools, re-exported verbatim for
2737
// the consumer to pass to their LLM.
2838
systemPrompt: string
29-
// Main entry: middleware stack + bridge dispatch. The caller is expected
30-
// to narrow toolName via `isClientToolName` BEFORE calling execute (the
31-
// Vercel AI SDK guarantees the LLM only fires registered tools, so the
32-
// narrow is a one-line type assertion at the consumer). Pushing the
33-
// narrow up means there is no redundant runtime check here, and the
34-
// dispatcher stays a pure router with `satisfies never` exhaustiveness.
39+
// Main entry. The caller narrows toolName via `isClientToolName` once at
40+
// the consumer boundary; the Vercel AI SDK guarantees the LLM only fires
41+
// registered tools.
3542
execute: (toolName: ClientToolName, input: ToolInput) => Promise<BridgeResult<unknown>>
3643
// Type guard re-export so the consumer can branch on tool names without
3744
// importing `schemas.ts` separately.
@@ -43,9 +50,48 @@ export const createClientTools = ({
4350
systemPrompt,
4451
middleware = [],
4552
}: CreateClientToolsArgs): ClientTools => {
46-
const composed = composeMiddleware(middleware, ({ toolName, input }) => dispatch(bridge, toolName, input))
53+
// One arm per tool. Each arm parses the LLM-supplied input via the
54+
// bridge schema (single source of truth, lives in
55+
// embed-bridge/schemas.ts) and forwards the typed payload to the matching
56+
// bridge method. `satisfies never` keeps the switch exhaustive over
57+
// ClientToolName at compile time.
58+
const composed = composeMiddleware(middleware, async ({ toolName, input }) => {
59+
switch (toolName) {
60+
case 'get_fields':
61+
return bridge.getFields()
62+
case 'get_document_content':
63+
return bridge.getDocumentContent(GetDocumentContentInput.parse(input))
64+
case 'detect_fields':
65+
return bridge.detectFields()
66+
case 'delete_fields':
67+
return bridge.deleteFields(DeleteFieldsInput.parse(input))
68+
case 'select_tool':
69+
return bridge.selectTool(SelectToolInput.parse(input))
70+
case 'set_field_value':
71+
return bridge.setFieldValue(SetFieldValueInput.parse(input))
72+
case 'focus_field':
73+
return bridge.focusField(FocusFieldInput.parse(input))
74+
case 'go_to_page':
75+
return bridge.goTo(GoToInput.parse(input))
76+
case 'move_page':
77+
return bridge.movePage(MovePageInput.parse(input))
78+
case 'delete_pages':
79+
return bridge.deletePages(DeletePagesInput.parse(input))
80+
case 'rotate_page':
81+
return bridge.rotatePage(RotatePageInput.parse(input))
82+
case 'submit':
83+
return bridge.submit({ download_copy: false })
84+
case 'download':
85+
return bridge.download()
86+
default:
87+
toolName satisfies never
88+
return {
89+
success: false,
90+
error: { code: 'unknown_tool', message: `Unknown tool: ${String(toolName)}` },
91+
}
92+
}
93+
})
4794
return {
48-
schemas: CLIENT_TOOL_SCHEMAS,
4995
systemPrompt,
5096
execute: (toolName, input) => composed({ toolName, input }),
5197
isClientToolName,

copilot/src/lib/embed-bridge-adapters/client-tools/finalisation.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,19 @@
1+
import { DownloadInput, SubmitInput } from '../../embed-bridge'
12
import { IS_DEMO_MODE } from '../../mode'
2-
import { DownloadInput, SubmitInput } from './schemas'
33

44
// The single AI SDK tool that finalises the filled PDF. Demo mode (the
55
// SimplePDF-hosted copilot.simplepdf.com) exposes only `download`, which
66
// short-circuits through the host's upsell-aware handler. SimplePDF customer
77
// forks expose only `submit`, which fires the SimplePDF SUBMIT iframe event
88
// so the filled PDF lands in the customer's BYOS storage + webhook stack.
9+
// Both descriptions live with the bridge schemas.
910
export type FinalisationToolMap =
1011
| { submit: { description: string; inputSchema: typeof SubmitInput } }
1112
| { download: { description: string; inputSchema: typeof DownloadInput } }
1213

1314
export const FINALISATION_TOOL: FinalisationToolMap = IS_DEMO_MODE
14-
? {
15-
download: {
16-
description:
17-
'Finalizes the filled PDF and triggers an in-browser download for the user. Use only when the user asks to download.',
18-
inputSchema: DownloadInput,
19-
},
20-
}
21-
: {
22-
submit: {
23-
description:
24-
'Finalizes the filled PDF and submits it to the host application (storage, webhook, etc.). Use only when the user asks to submit or finalize.',
25-
inputSchema: SubmitInput,
26-
},
27-
}
15+
? { download: { description: DownloadInput.description ?? '', inputSchema: DownloadInput } }
16+
: { submit: { description: SubmitInput.description ?? '', inputSchema: SubmitInput } }
2817

2918
// Merges the mode-appropriate finalisation tool into the caller's static
3019
// tool map. The constraint `T & { submit?: never; download?: never }`
Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,9 @@
1-
export type { ToolInput } from './dispatch'
2-
export { dispatch } from './dispatch'
3-
export type { ClientTools, CreateClientToolsArgs } from './factory'
1+
export type { ClientTools, CreateClientToolsArgs, ToolInput } from './factory'
42
export { createClientTools } from './factory'
53
export type { FinalisationAction, FinalisationToolMap } from './finalisation'
64
export { FINALISATION_ACTION, FINALISATION_TOOL, withFinalisationTool } from './finalisation'
75
export type { MiddlewareContext, ToolMiddleware } from './middleware'
86
export { composeMiddleware } from './middleware'
97
export type { ClientToolName } from './schemas'
10-
export {
11-
CLIENT_TOOL_SCHEMAS,
12-
DeleteFieldsInput,
13-
DeletePagesInput,
14-
DetectFieldsInput,
15-
DownloadInput,
16-
FocusFieldInput,
17-
GetDocumentContentInput,
18-
GetFieldsInput,
19-
GoToPageInput,
20-
isClientToolName,
21-
MovePageInput,
22-
RotatePageInput,
23-
SelectToolInput,
24-
SetFieldValueInput,
25-
SubmitInput,
26-
} from './schemas'
8+
export { CLIENT_TOOL_NAMES, isClientToolName } from './schemas'
9+
export { LLM_STATIC_TOOLS } from './tools'

copilot/src/lib/embed-bridge-adapters/client-tools/middleware.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { BridgeResult } from '../../embed-bridge'
2-
import type { ToolInput } from './dispatch'
2+
import type { ToolInput } from './factory'
33
import type { ClientToolName } from './schemas'
44

55
// Onion-style middleware. Each layer receives a context (tool name + input)

0 commit comments

Comments
 (0)