Skip to content

Commit 6ff544e

Browse files
antfubotagent
andcommitted
refactor: infer JSON serialization for agent in-page functions and dedupe safety
Align the in-page `agent` option with #379: setting `agent` now implies `jsonSerializable: true` instead of requiring it, and DF0080 only fires on an explicit `jsonSerializable: false`. Collapse the three copies of the agent safety inference (WebMCP, the MCP host, and the in-page bridge) into one shared `resolveAgentSafety` helper, and drop the redundant id de-duplication in the browser-agent registry since the node-side tool provider already dedupes. Co-authored-by: agent <agent@opencode>
1 parent b85af55 commit 6ff544e

9 files changed

Lines changed: 64 additions & 55 deletions

File tree

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. A function with `agent` metadata must set `jsonSerializable: true` and is available to coding agents through MCP. `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: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
---
22
title: 'DF0080: Agent In-Page Function Not JSON-Serializable'
3-
description: 'An in-page channel function sets `agent` but does not set `jsonSerializable: true`.'
3+
description: 'An in-page channel function sets `agent` but `jsonSerializable` is `false`.'
44
---
55

66
## Message
77

8-
> In-page channel function "{name}" has `agent` set but `jsonSerializable` is not `true`; MCP requires JSON-serializable data.
8+
> In-page channel function "{name}" has `agent` set but `jsonSerializable` is `false`; MCP requires JSON-serializable data.
99
1010
## Cause
1111

12-
A function exposed to coding agents crosses the browser-to-node agent bridge and is surfaced over MCP, whose payloads must be JSON-serializable. Declaring `agent` without `jsonSerializable: true` leaves the channel free to move non-JSON values (through structured clone) that MCP cannot represent.
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.
1313

1414
## Example
1515

@@ -21,24 +21,24 @@ createPageScriptChannel({
2121
functions: {
2222
addTodo: {
2323
agent: { description: 'Add a todo item.' },
24-
handler: (text: string) => ({ added: text }), // ✗ `agent` without `jsonSerializable: true`
24+
jsonSerializable: false, // ✗ throws DF0080
25+
handler: (text: string) => ({ added: text }),
2526
},
2627
},
2728
})
2829
```
2930

3031
## Fix
3132

32-
Set `jsonSerializable: true` when the payload is JSON-safe, or remove `agent` to keep the function channel-only.
33+
Remove `jsonSerializable: false` to use the implicit JSON contract, or remove `agent` to keep the function channel-only.
3334

3435
```ts
3536
const addTodo = {
36-
agent: { description: 'Add a todo item.' },
37-
jsonSerializable: true, //
37+
agent: { description: 'Add a todo item.' }, // jsonSerializable is inferred true
3838
handler: (text: string) => ({ added: text }),
3939
}
4040
```
4141

4242
## Source
4343

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 carries `agent` without `jsonSerializable: true`.
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.
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+
}

packages/devframe/src/client/browser-agent.ts

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import type { RpcFunctionAgentOptions } from 'devframe/rpc'
2-
31
export interface BrowserAgentToolManifest {
42
id: string
53
title?: string
@@ -41,24 +39,10 @@ export function registerBrowserAgentTool(tool: BrowserAgentTool): () => void {
4139
}
4240

4341
export function listBrowserAgentTools(): BrowserAgentTool[] {
44-
const unique = new Map<string, BrowserAgentTool>()
45-
for (const tool of tools.values()) {
46-
if (!unique.has(tool.id))
47-
unique.set(tool.id, tool)
48-
}
49-
return [...unique.values()]
42+
return [...tools.values()]
5043
}
5144

5245
export function onBrowserAgentToolsChanged(listener: () => void): () => void {
5346
listeners.add(listener)
5447
return () => listeners.delete(listener)
5548
}
56-
57-
export function resolveBrowserAgentSafety(
58-
type: string | undefined,
59-
agent: RpcFunctionAgentOptions,
60-
): BrowserAgentToolManifest['safety'] {
61-
if (agent.safety)
62-
return agent.safety
63-
return type === 'static' || type === 'query' || type == null ? 'read' : 'action'
64-
}

packages/devframe/src/client/webmcp.ts

Lines changed: 5 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import type { RpcFunctionAgentOptions, RpcFunctionDefinitionAnyWithContext, RpcFunctionsCollector, RpcFunctionType } from 'devframe/rpc'
1+
import type { RpcFunctionAgentOptions, RpcFunctionDefinitionAnyWithContext, RpcFunctionsCollector } from 'devframe/rpc'
22
import { getRpcHandler } from 'devframe/rpc'
33
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
44
// Pure, browser-safe projections shared with the node-side MCP adapter
55
// (`@devframes/agentic/mcp`, via `devframe/internal`), so the WebMCP surface
66
// cannot drift from the MCP one.
7+
import { resolveAgentSafety } from '../agent/safety'
78
import { argsToJsonSchema } from '../agent/to-json-schema'
89
import { toolInputToRpcArgs } from '../tool-input'
910

@@ -132,14 +133,15 @@ export function registerWebMcpTools<LocalFunctions, SetupContext>(
132133
}
133134

134135
const controller = new AbortController()
136+
const safety = resolveAgentSafety(def.type, agent)
135137
const result = modelContext.registerTool({
136138
name,
137139
description: agent.description,
138140
inputSchema: argsToJsonSchema(def.args),
139141
annotations: {
140142
title: agent.title ?? def.name,
141-
readOnlyHint: resolveSafety(def, agent) === 'read',
142-
destructiveHint: resolveSafety(def, agent) === 'destructive',
143+
readOnlyHint: safety === 'read',
144+
destructiveHint: safety === 'destructive',
143145
},
144146
execute: args => executeRpcTool(def, clientRpc.context, args),
145147
}, { signal: controller.signal })
@@ -180,16 +182,6 @@ export function registerWebMcpTools<LocalFunctions, SetupContext>(
180182
}
181183
}
182184

183-
function resolveSafety(
184-
def: RpcFunctionDefinitionAnyWithContext<any>,
185-
agent: RpcFunctionAgentOptions,
186-
): 'read' | 'action' | 'destructive' {
187-
if (agent.safety)
188-
return agent.safety
189-
const type: RpcFunctionType = def.type ?? 'query'
190-
return type === 'static' || type === 'query' ? 'read' : 'action'
191-
}
192-
193185
async function executeRpcTool<SetupContext>(
194186
def: RpcFunctionDefinitionAnyWithContext<SetupContext>,
195187
context: SetupContext,

packages/devframe/src/in-page-channel/agent.test.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,13 +62,31 @@ describe('in-page channel agent tools', () => {
6262
expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:test:add')).toBe(false)
6363
})
6464

65-
it('rejects agent exposure without strict JSON serialization', () => {
65+
it('infers strict JSON serialization when agent is set', () => {
66+
const channel = createPageScriptChannel<TestProtocol>({
67+
name: 'devframes:inferred',
68+
window: false,
69+
heartbeat: false,
70+
functions: {
71+
add: {
72+
agent: { description: 'Add two numbers.' },
73+
handler: (a, b) => ({ sum: a + b }),
74+
},
75+
hidden: { handler: () => 'internal' },
76+
},
77+
})
78+
expect(listBrowserAgentTools().some(tool => tool.id === 'devframes:inferred:add')).toBe(true)
79+
channel.close()
80+
})
81+
82+
it('rejects agent exposure with explicit jsonSerializable: false', () => {
6683
expect(() => createPageScriptChannel<TestProtocol>({
6784
name: 'devframes:invalid',
6885
window: false,
6986
functions: {
7087
add: {
7188
agent: { description: 'Add two numbers.' },
89+
jsonSerializable: false,
7290
handler: (a, b) => ({ sum: a + b }),
7391
},
7492
hidden: { handler: () => 'internal' },

packages/devframe/src/in-page-channel/diagnostics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ export const diagnostics = /* #__PURE__ */ defineDiagnostics({
99
},
1010
DF0080: {
1111
why: (p: { name: string }) =>
12-
`In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is not \`true\`; MCP requires JSON-serializable data.`,
13-
fix: 'Set `jsonSerializable: true` if the payload is JSON-safe, or remove `agent` to keep it channel-only.',
12+
`In-page channel function "${p.name}" has \`agent\` set but \`jsonSerializable\` is \`false\`; MCP requires JSON-serializable data.`,
13+
fix: 'Remove `jsonSerializable: false`, or remove `agent` to keep it channel-only.',
1414
},
1515
},
1616
})

packages/devframe/src/in-page-channel/internal.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import type { RpcArgsSchema } from '../rpc/types'
44
import type { InPageChannelControlFrame } from './protocol'
55
import type { InPageFunctionDefinitionAny, InPageFunctionType } from './types'
66
import { createBirpc } from 'birpc'
7+
import { resolveAgentSafety } from '../agent/safety'
78
import { argsToJsonSchema } from '../agent/to-json-schema'
8-
import { registerBrowserAgentTool, resolveBrowserAgentSafety } from '../client/browser-agent'
9+
import { registerBrowserAgentTool } from '../client/browser-agent'
910
import { toolInputToRpcArgs } from '../tool-input'
1011
import { diagnostics } from './diagnostics'
1112
import { isControlFrame } from './protocol'
@@ -189,8 +190,13 @@ export function createLocalFunctionRegistry(codec: InPageChannelSerialization):
189190
return {
190191
definitions,
191192
register(definition) {
192-
if ('agent' in definition && definition.agent && definition.jsonSerializable !== true)
193-
throw diagnostics.DF0080({ name: definition.name })
193+
// `agent` implies strict JSON serialization, since MCP consumes
194+
// JSON-shaped data; an explicit `jsonSerializable: false` conflicts.
195+
if ('agent' in definition && definition.agent) {
196+
if (definition.jsonSerializable === false)
197+
throw diagnostics.DF0080({ name: definition.name })
198+
definition.jsonSerializable = true
199+
}
194200
definitions.set(channelMethod(definition.type, definition.name), definition)
195201
},
196202
// The shared-state layer keys its handlers by their own fully-qualified
@@ -276,7 +282,7 @@ export function registerInPageAgentTools(
276282
id: `${channelName}:${definition.name}`,
277283
title: agent.title ?? definition.name,
278284
description: agent.description,
279-
safety: resolveBrowserAgentSafety(definition.type, agent),
285+
safety: resolveAgentSafety(definition.type, agent),
280286
tags: agent.tags,
281287
inputSchema: argsToJsonSchema(definition.args),
282288
invoke: (args) => {

packages/devframe/src/node/host-agent.ts

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { RpcFunctionDefinitionAnyWithContext, RpcFunctionType } from 'devframe/rpc'
1+
import type { RpcFunctionDefinitionAnyWithContext } from 'devframe/rpc'
22
import type {
33
AgentHandle,
44
AgentManifest,
@@ -16,6 +16,7 @@ import type {
1616
RpcFunctionAgentOptions,
1717
} from 'devframe/types'
1818
import { createEventEmitter } from 'devframe/utils/events'
19+
import { resolveAgentSafety } from '../agent/safety'
1920
import { DEVFRAME_EVENTS } from '../events'
2021
import { toolInputToRpcArgs } from '../tool-input'
2122
import { diagnostics } from './diagnostics'
@@ -261,8 +262,7 @@ export class DevframeAgentHost implements DevframeAgentHostType {
261262
if (!agent.description || typeof agent.description !== 'string')
262263
throw diagnostics.DF0014({ name })
263264

264-
const type: RpcFunctionType = def.type ?? 'query'
265-
const safety = agent.safety ?? inferSafety(type)
265+
const safety = resolveAgentSafety(def.type, agent)
266266
out.push({
267267
id: name,
268268
kind: 'rpc',
@@ -287,9 +287,3 @@ export class DevframeAgentHost implements DevframeAgentHostType {
287287
return undefined
288288
}
289289
}
290-
291-
function inferSafety(type: RpcFunctionType): 'read' | 'action' | 'destructive' {
292-
if (type === 'static' || type === 'query')
293-
return 'read'
294-
return 'action'
295-
}

0 commit comments

Comments
 (0)