Skip to content

Commit 9464558

Browse files
posvaagentantfubot
authored
feat: eagerly expose in-page tools to browser agents (#376)
Co-authored-by: agent <agent@opencode> Co-authored-by: Anthony Fu (via agent) <reg-github-bot@antfu.me>
1 parent 76e670b commit 9464558

25 files changed

Lines changed: 639 additions & 41 deletions

docs/content/1.guide/12.in-page-channel.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Channel names are namespaced with the devframe id, like RPC ids. Function names
6464

6565
## The page script endpoint
6666

67-
The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. `defineChannelFunction` retains the named definition shape for lower-level authoring.
67+
The required `functions` option and optional `events` option declare every incoming name on the endpoint's protocol side; use `{}` for an empty direction. Functions require a `handler`. Events accept an optional `handler`, and `{}` registers an event for runtime subscriptions through `on()`. Handlers are contextually typed from the shared protocol and support Standard-Schema argument validation and `jsonSerializable` metadata. A function with `agent` metadata is available to coding agents through MCP; the field implicitly enables strict JSON serialization (an explicit `jsonSerializable: false` conflicts). `defineChannelFunction` retains the named definition shape for lower-level authoring.
6868

6969
`call()` accepts names from `functions`, including actions returning `void` or `Promise<void>`: callers can await completion and catch errors or timeouts. `emit()` and `on()` use the names declared in `events`. Function and event names have separate namespaces.
7070

docs/content/6.errors/DF0080.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
title: 'DF0080: Agent In-Page Function Not JSON-Serializable'
3+
description: 'An in-page channel function sets `agent` but `jsonSerializable` is `false`.'
4+
---
5+
6+
## Message
7+
8+
> In-page channel function "{name}" has `agent` set but `jsonSerializable` is `false`; MCP requires JSON-serializable data.
9+
10+
## Cause
11+
12+
The `agent` field exposes an in-page function over the browser-to-node agent bridge and MCP, which only consumes JSON-shaped data, so it implicitly enables strict JSON serialization. An explicit `jsonSerializable: false` conflicts with that contract.
13+
14+
## Example
15+
16+
```ts
17+
import { createPageScriptChannel } from 'devframe/in-page-channel'
18+
19+
createPageScriptChannel({
20+
name: 'devframes:example',
21+
functions: {
22+
addTodo: {
23+
agent: { description: 'Add a todo item.' },
24+
jsonSerializable: false, // ✗ throws DF0080
25+
handler: (text: string) => ({ added: text }),
26+
},
27+
},
28+
})
29+
```
30+
31+
## Fix
32+
33+
Remove `jsonSerializable: false` to use the implicit JSON contract, or remove `agent` to keep the function channel-only.
34+
35+
```ts
36+
const addTodo = {
37+
agent: { description: 'Add a todo item.' }, // jsonSerializable is inferred true
38+
handler: (text: string) => ({ added: text }),
39+
}
40+
```
41+
42+
## Source
43+
44+
- [`packages/devframe/src/in-page-channel/internal.ts`](https://github.com/devframes/devframe/blob/main/packages/devframe/src/in-page-channel/internal.ts): `createLocalFunctionRegistry().register()` throws this when a definition combines `agent` with `jsonSerializable: false`, and infers `jsonSerializable: true` otherwise.

packages/agentic/src/connect/index.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,12 @@ export interface ConnectServerHandle {
6767
}
6868

6969
/** One discovered instance in the `list-instances` payload: the registry record plus its probed MCP surface. */
70+
interface IndexedInstanceTools extends Pick<Tool, 'name' | 'title' | 'description' | 'inputSchema' | 'outputSchema' | 'annotations'> {}
71+
7072
interface IndexedInstance extends Omit<DevframeInstanceRecord, 'mcp'> {
7173
mcp: {
7274
url: string
73-
tools?: { name: string, description?: string }[]
75+
tools?: IndexedInstanceTools[]
7476
error?: string
7577
} | null
7678
hint?: string
@@ -214,14 +216,8 @@ async function probePort(port: number, timeoutMs?: number): Promise<DevframeInst
214216
}
215217
}
216218

217-
async function listInstanceTools(url: string, token: string | undefined): Promise<{ name: string, description?: string }[]> {
218-
return withInstanceClient(url, token, async (client) => {
219-
const listed = await client.listTools()
220-
return listed.tools.map((tool: { name: string, description?: string }) => ({
221-
name: tool.name,
222-
description: tool.description,
223-
}))
224-
})
219+
async function listInstanceTools(url: string, token: string | undefined): Promise<IndexedInstanceTools[]> {
220+
return withInstanceClient(url, token, async client => (await client.listTools()).tools)
225221
}
226222

227223
async function call(
@@ -235,7 +231,8 @@ async function call(
235231
instancesDir: options.instancesDir,
236232
timeoutMs: options.timeoutMs,
237233
})
238-
const record = live.find(r => r.port === args.port) ?? await probePort(args.port, options.timeoutMs)
234+
const record = live.find(record => record.port === args.port && record.mcp)
235+
?? await probePort(args.port, options.timeoutMs)
239236
if (!record)
240237
throw diagnostics.DF0050({ port: args.port })
241238
if (!record.mcp)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { RpcFunctionAgentOptions } from '../rpc/types'
2+
3+
/**
4+
* An agent tool's safety classification: the explicit `agent.safety`, else
5+
* inferred from the function `type` (`static`/`query` are read-only, the rest
6+
* mutate). Shared by every agent surface (MCP host, WebMCP, in-page bridge).
7+
*/
8+
export function resolveAgentSafety(
9+
type: string | undefined,
10+
agent: RpcFunctionAgentOptions,
11+
): 'read' | 'action' | 'destructive' {
12+
if (agent.safety)
13+
return agent.safety
14+
return type === 'static' || type === 'query' || type == null ? 'read' : 'action'
15+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import type { BrowserAgentToolManifest } from './browser-agent'
2+
import type { BrowserAgentInvocationDefinition } from './browser-agent-rpc'
3+
import { afterEach, describe, expect, it, vi } from 'vitest'
4+
import { registerBrowserAgentTool } from './browser-agent'
5+
import { setupBrowserAgentRpcBridge } from './browser-agent-rpc'
6+
7+
describe('browser agent RPC bridge', () => {
8+
const disposals: (() => void)[] = []
9+
afterEach(() => disposals.splice(0).forEach(dispose => dispose()))
10+
11+
it('synchronizes manifests and invokes the original browser tool', async () => {
12+
const handlers = new Map<string, (...args: any[]) => unknown>()
13+
const callOptional = vi.fn().mockResolvedValue(undefined)
14+
const rpc = {
15+
client: {
16+
register(definition: BrowserAgentInvocationDefinition) {
17+
handlers.set(definition.name, definition.handler)
18+
},
19+
},
20+
callOptional(
21+
method: 'devframe:agent:sync-client-tools',
22+
clientId: string,
23+
tools: BrowserAgentToolManifest[],
24+
) {
25+
return callOptional(method, clientId, tools)
26+
},
27+
events: { on: () => () => {} },
28+
}
29+
30+
disposals.push(registerBrowserAgentTool({
31+
id: 'todos:add',
32+
description: 'Add a todo.',
33+
safety: 'action',
34+
inputSchema: { type: 'object' },
35+
invoke: args => ({ added: args.text }),
36+
}))
37+
disposals.push(setupBrowserAgentRpcBridge(rpc))
38+
await vi.waitFor(() => expect(callOptional).toHaveBeenCalledWith(
39+
'devframe:agent:sync-client-tools',
40+
expect.any(String),
41+
[{
42+
id: 'todos:add',
43+
description: 'Add a todo.',
44+
safety: 'action',
45+
inputSchema: { type: 'object' },
46+
}],
47+
))
48+
49+
await expect(handlers.get('devframe:agent:invoke-client-tool')!(
50+
'todos:add',
51+
{ text: 'milk' },
52+
)).resolves.toEqual({ added: 'milk' })
53+
})
54+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { BrowserAgentToolManifest } from './browser-agent'
2+
import type { DevframeConnectionStatus } from './connection'
3+
import {
4+
listBrowserAgentTools,
5+
onBrowserAgentToolsChanged,
6+
} from './browser-agent'
7+
import { resolveClientId } from './client-id'
8+
9+
export interface BrowserAgentInvocationDefinition {
10+
name: 'devframe:agent:invoke-client-tool'
11+
type: 'action'
12+
jsonSerializable: true
13+
handler: (id: string, args: Record<string, unknown>) => Promise<unknown>
14+
}
15+
16+
interface BrowserAgentRpcClient {
17+
client: { register: (definition: BrowserAgentInvocationDefinition) => void }
18+
callOptional: (
19+
method: 'devframe:agent:sync-client-tools',
20+
clientId: string,
21+
tools: BrowserAgentToolManifest[],
22+
) => Promise<unknown>
23+
events: {
24+
on: (
25+
event: 'connection:status',
26+
listener: (status: DevframeConnectionStatus, previous: DevframeConnectionStatus) => void,
27+
) => () => void
28+
}
29+
}
30+
31+
/** Mirror this document's browser-agent registry over its existing RPC connection. */
32+
export function setupBrowserAgentRpcBridge(rpc: BrowserAgentRpcClient): () => void {
33+
rpc.client.register({
34+
name: 'devframe:agent:invoke-client-tool',
35+
type: 'action',
36+
jsonSerializable: true,
37+
handler: async (id: string, args: Record<string, unknown>) => {
38+
const tool = listBrowserAgentTools().find(tool => tool.id === id)
39+
if (!tool)
40+
throw new Error(`[devframe/agent] browser tool "${id}" not found`)
41+
return await tool.invoke(args)
42+
},
43+
})
44+
45+
let queued = false
46+
let disposed = false
47+
let lastSyncedCount = 0
48+
const sync = (): void => {
49+
if (queued || disposed)
50+
return
51+
queued = true
52+
queueMicrotask(async () => {
53+
queued = false
54+
if (disposed)
55+
return
56+
const manifests = listBrowserAgentTools().map(({ invoke: _, ...manifest }) => manifest)
57+
// Skip the no-op sync when nothing is registered and nothing was ever
58+
// mirrored; a page with no browser-agent tools stays off the wire.
59+
if (manifests.length === 0 && lastSyncedCount === 0)
60+
return
61+
lastSyncedCount = manifests.length
62+
await rpc.callOptional('devframe:agent:sync-client-tools', resolveClientId(), manifests).catch(() => {})
63+
})
64+
}
65+
66+
const stopTools = onBrowserAgentToolsChanged(sync)
67+
const stopConnection = rpc.events.on('connection:status', (status) => {
68+
if (status === 'connected')
69+
sync()
70+
})
71+
sync()
72+
73+
return () => {
74+
disposed = true
75+
stopTools()
76+
stopConnection()
77+
}
78+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
export interface BrowserAgentToolManifest {
2+
id: string
3+
title?: string
4+
description: string
5+
safety: 'read' | 'action' | 'destructive'
6+
tags?: readonly string[]
7+
inputSchema?: unknown
8+
}
9+
10+
export interface BrowserAgentTool extends BrowserAgentToolManifest {
11+
invoke: (args: Record<string, unknown>) => unknown | Promise<unknown>
12+
}
13+
14+
interface BrowserAgentRegistryState {
15+
tools: Map<symbol, BrowserAgentTool>
16+
listeners: Set<() => void>
17+
}
18+
19+
const REGISTRY_KEY = Symbol.for('devframe:browser-agent-registry')
20+
const state = ((globalThis as any)[REGISTRY_KEY] ??= {
21+
tools: new Map(),
22+
listeners: new Set(),
23+
}) as BrowserAgentRegistryState
24+
const { tools, listeners } = state
25+
26+
function notifyChanged(): void {
27+
for (const listener of listeners)
28+
listener()
29+
}
30+
31+
export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void {
32+
const key = Symbol(tool.id)
33+
tools.set(key, tool)
34+
notifyChanged()
35+
return () => {
36+
if (tools.delete(key))
37+
notifyChanged()
38+
}
39+
}
40+
41+
export function listBrowserAgentTools(): BrowserAgentTool[] {
42+
return [...tools.values()]
43+
}
44+
45+
export function onBrowserAgentToolsChanged(listener: () => void): () => void {
46+
listeners.add(listener)
47+
return () => listeners.delete(listener)
48+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { nanoid } from 'devframe/utils/nanoid'
2+
3+
const CLIENT_ID_STORAGE_KEY = 'devframe:client-id'
4+
let memoryClientId: string | undefined
5+
6+
/**
7+
* This browser tab's stable client id: one nanoid per tab, persisted in
8+
* `sessionStorage` so it survives page reloads and RPC reconnects. The node
9+
* side uses it to tell connected tabs apart across reconnects (see #394).
10+
*
11+
* Tab duplication copies `sessionStorage`, so two tabs can briefly share an id
12+
* until per-tab disambiguation lands with the wider tab-metadata work.
13+
*/
14+
export function resolveClientId(win: Window | undefined = globalThis.window): string {
15+
try {
16+
const storage = win?.sessionStorage
17+
if (storage) {
18+
let id = storage.getItem(CLIENT_ID_STORAGE_KEY)
19+
if (!id) {
20+
id = nanoid()
21+
storage.setItem(CLIENT_ID_STORAGE_KEY, id)
22+
}
23+
return id
24+
}
25+
}
26+
catch {
27+
// Storage unavailable (sandboxed iframe, disabled cookies); fall through.
28+
}
29+
memoryClientId ??= nanoid()
30+
return memoryClientId
31+
}

packages/devframe/src/client/rpc-auth-gate.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,29 @@ describe('getDevframeRpcClient: auth bootstrap gates outbound calls', () => {
145145
// `close?:` exists to keep working.
146146
expect(() => rpc.close?.()).not.toThrow()
147147
})
148+
149+
const INVOKE_CLIENT_TOOL = 'devframe:agent:invoke-client-tool'
150+
151+
it('loads the browser-agent bridge only when the node advertises MCP', async () => {
152+
const { getDevframeRpcClient } = await import('./rpc')
153+
const rpc = await getDevframeRpcClient({
154+
connectionMeta: { ...connectionMeta, mcp: { path: '__mcp' } },
155+
otpParam: false,
156+
simpleAuth: false,
157+
})
158+
// The bridge lives in its own chunk and registers this handler once loaded.
159+
await vi.waitFor(() => expect(rpc.client.definitions.has(INVOKE_CLIENT_TOOL)).toBe(true))
160+
})
161+
162+
it('leaves the browser-agent bridge unloaded without MCP', async () => {
163+
const { getDevframeRpcClient } = await import('./rpc')
164+
const rpc = await getDevframeRpcClient({
165+
connectionMeta,
166+
otpParam: false,
167+
simpleAuth: false,
168+
})
169+
// Give any stray dynamic import time to resolve; it must not.
170+
await new Promise(resolve => setTimeout(resolve, 20))
171+
expect(rpc.client.definitions.has(INVOKE_CLIENT_TOOL)).toBe(false)
172+
})
148173
})

packages/devframe/src/client/rpc.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,8 @@ export async function getDevframeRpcClient(
356356
const clientRpc: DevframeClientRpcHost = new RpcFunctionsCollectorBase<DevframeRpcClientFunctions, DevframeRpcContext>(context)
357357
// No-op when the browser provides no WebMCP model context.
358358
const disposeWebMcp = options.webmcp === false ? undefined : registerWebMcpTools(clientRpc)
359+
let disposeBrowserAgentBridge: (() => void) | undefined
360+
let closed = false
359361

360362
async function fetchJsonFromBases(path: string): Promise<any> {
361363
const candidates = [
@@ -447,7 +449,9 @@ export async function getDevframeRpcClient(
447449

448450
/** Release authentication and transport resources even if another disposer fails. */
449451
function closeRpcClient(): void {
452+
closed = true
450453
try {
454+
disposeBrowserAgentBridge?.()
451455
disposeWebMcp?.()
452456
}
453457
finally {
@@ -596,6 +600,18 @@ export async function getDevframeRpcClient(
596600
() => { bootstrapAuthSettled = true },
597601
)
598602

603+
// Only when the node advertises an MCP endpoint (`connectionMeta.mcp`) is the
604+
// browser-agent bridge useful, so load it from its own chunk on demand and
605+
// keep it out of the main client bundle for every non-MCP connection.
606+
if (connectionMeta.mcp) {
607+
void import('./browser-agent-rpc')
608+
.then(({ setupBrowserAgentRpcBridge }) => {
609+
if (!closed)
610+
disposeBrowserAgentBridge = setupBrowserAgentRpcBridge(rpc)
611+
})
612+
.catch(() => {})
613+
}
614+
599615
// Listen for auth updates from other tabs (e.g., the auth page, or another
600616
// tab that just completed a code exchange).
601617
if (authChannel) {

0 commit comments

Comments
 (0)