Skip to content

Commit d5ddc01

Browse files
authored
refactor: clarify tool input coercion (#380)
1 parent 7405628 commit d5ddc01

10 files changed

Lines changed: 93 additions & 92 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { toolInputToCommandArgs, toolInputToRpcArgs } from '../tool-input'
3+
4+
describe('tool input positional arguments', () => {
5+
it('passes arrays through and maps argN keys using the declared count', () => {
6+
expect(toolInputToRpcArgs([1, 2], 2)).toEqual([1, 2])
7+
expect(toolInputToRpcArgs({ arg0: 'a', arg1: 'b' }, 2)).toEqual(['a', 'b'])
8+
expect(toolInputToRpcArgs({ arg0: 'a' }, 0)).toEqual([])
9+
})
10+
11+
it('collects contiguous argN keys without a declared count', () => {
12+
expect(toolInputToRpcArgs({ arg0: 1, arg1: 2 })).toEqual([1, 2])
13+
})
14+
15+
it('treats null, undefined, and empty objects as zero-argument calls', () => {
16+
expect(toolInputToRpcArgs(undefined)).toEqual([])
17+
expect(toolInputToRpcArgs(null, 1)).toEqual([])
18+
expect(toolInputToRpcArgs({})).toEqual([])
19+
})
20+
21+
it('preserves undeclared RPC input as one argument', () => {
22+
const input = { name: 'devframe' }
23+
expect(toolInputToRpcArgs(input)).toEqual([input])
24+
})
25+
26+
it('drops undeclared command input', () => {
27+
expect(toolInputToCommandArgs({ name: 'devframe' })).toEqual([])
28+
})
29+
})

packages/devframe/src/client/webmcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { toAgentToolName } from 'devframe/utils/agent-tool-name'
44
// Pure, browser-safe projections shared with the node-side MCP adapter, so
55
// the WebMCP surface cannot drift from the MCP one.
66
import { argsToJsonSchema } from '../adapters/mcp/to-json-schema'
7-
import { coerceAgentPositionalArgs } from '../node/agent-args'
7+
import { toolInputToRpcArgs } from '../tool-input'
88

99
/**
1010
* Result a WebMCP tool's `execute` resolves with; mirrors the MCP
@@ -195,7 +195,7 @@ async function executeRpcTool<SetupContext>(
195195
args: Record<string, unknown>,
196196
): Promise<WebMcpToolResult> {
197197
try {
198-
const positional = coerceAgentPositionalArgs(args, def.args as readonly unknown[] | undefined, 'wrap')
198+
const positional = toolInputToRpcArgs(args, def.args?.length)
199199
const handler = await getRpcHandler(def, context)
200200
const result = await handler(...positional)
201201
return { content: [{ type: 'text', text: stringifyResult(result) }] }

packages/devframe/src/internal/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
// session/auth wiring the instance shell's own binding uses.
1010
// - `DevframeAgentHost`: the agent host implementation the hub composes into
1111
// its own commands host.
12-
// - `coerceAgentPositionalArgs`: positional-arg coercion the hub applies when
13-
// invoking agent tools as commands.
12+
// - `toolInputToCommandArgs`: positional-argument conversion the hub applies
13+
// when invoking tool-backed commands.
1414
// - `registerDevframeInstance` / `listLiveDevframeInstances`: the instance
1515
// registry: a custom host advertises itself; a devtool (the inspect plugin's
1616
// Instances tab, the connector) enumerates what's running.
@@ -40,8 +40,6 @@
4040
export { loadAutoMcpAdapter, normalizeBasePath, resolveBasePath, resolveMcpConfig } from '../adapters/_shared'
4141
export type { ResolvedMcpConfig } from '../adapters/_shared'
4242
export { resolveClientAssets } from '../client-assets'
43-
export { coerceAgentPositionalArgs } from '../node/agent-args'
44-
export type { AgentArgsFallback } from '../node/agent-args'
4543
export { diagnostics } from '../node/diagnostics'
4644
export { DevframeAgentHost } from '../node/host-agent'
4745
export * from '../node/host-h3'
@@ -64,3 +62,5 @@ export type { ContextRpcServer, CreateContextRpcServerOptions } from '../node/rp
6462
export { normalizeHttpServerUrl } from '../node/utils'
6563
export { createRpcWireCodec, peekRpcWireFrame } from '../rpc/wire-codec'
6664
export type { RpcWireCodec } from '../rpc/wire-codec'
65+
export { coerceAgentPositionalArgs, toolInputToCommandArgs } from '../tool-input'
66+
export type { AgentArgsFallback } from '../tool-input'

packages/devframe/src/node/__tests__/agent-args.test.ts

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

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

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type {
1717
} from 'devframe/types'
1818
import { createEventEmitter } from 'devframe/utils/events'
1919
import { DEVFRAME_EVENTS } from '../events'
20-
import { coerceAgentPositionalArgs } from './agent-args'
20+
import { toolInputToRpcArgs } from '../tool-input'
2121
import { diagnostics } from './diagnostics'
2222

2323
interface RegisteredTool {
@@ -184,7 +184,7 @@ export class DevframeAgentHost implements DevframeAgentHostType {
184184
// (what the MCP adapter sends after flattening), or a plain array.
185185
// An untyped RPC may take a single raw object, so undeclared object
186186
// payload wraps into one positional argument.
187-
const positional = coerceAgentPositionalArgs(args, rpcDef.args as readonly unknown[] | undefined, 'wrap')
187+
const positional = toolInputToRpcArgs(args, rpcDef.args?.length)
188188
return await this.context.rpc.invokeLocal(id as any, ...(positional as any))
189189
}
190190

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Convert an object-shaped tool input into positional arguments.
3+
*
4+
* Tool schemas expose positional parameters as `arg0`, `arg1`, and so on.
5+
* Arrays pass through for callers that already provide positional arguments.
6+
*/
7+
function collectPositionalArgs(input: unknown, argumentCount: number | undefined): unknown[] | undefined {
8+
if (Array.isArray(input))
9+
return input
10+
if (input === undefined || input === null)
11+
return []
12+
if (typeof input !== 'object')
13+
return undefined
14+
15+
const record = input as Record<string, unknown>
16+
if (argumentCount != null)
17+
return Array.from({ length: argumentCount }, (_, index) => record[`arg${index}`])
18+
if ('arg0' in record) {
19+
const positional: unknown[] = []
20+
while (`arg${positional.length}` in record)
21+
positional.push(record[`arg${positional.length}`])
22+
return positional
23+
}
24+
return Object.keys(record).length === 0 ? [] : undefined
25+
}
26+
27+
/** Convert tool input for an RPC, preserving an untyped payload as arg 0. */
28+
export function toolInputToRpcArgs(input: unknown, argumentCount?: number): unknown[] {
29+
return collectPositionalArgs(input, argumentCount) ?? [input]
30+
}
31+
32+
/** Convert tool input for a command, whose arguments must be declared. */
33+
export function toolInputToCommandArgs(input: unknown, argumentCount?: number): unknown[] {
34+
return collectPositionalArgs(input, argumentCount) ?? []
35+
}
36+
37+
/** @deprecated Use {@link toolInputToRpcArgs} or {@link toolInputToCommandArgs}. */
38+
export type AgentArgsFallback = 'wrap' | 'drop'
39+
40+
/** @deprecated Use {@link toolInputToRpcArgs} or {@link toolInputToCommandArgs}. */
41+
export function coerceAgentPositionalArgs(
42+
input: unknown,
43+
schemas: readonly unknown[] | undefined,
44+
fallback: AgentArgsFallback = 'wrap',
45+
): unknown[] {
46+
const argumentCount = schemas?.length
47+
return fallback === 'drop'
48+
? toolInputToCommandArgs(input, argumentCount)
49+
: toolInputToRpcArgs(input, argumentCount)
50+
}

packages/hub/src/node/host-commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
DevframeServerCommandInput,
77
} from '../types/commands'
88
import type { DevframeHubContext } from './context'
9-
import { coerceAgentPositionalArgs } from 'devframe/internal'
9+
import { toolInputToCommandArgs } from 'devframe/internal'
1010
import { createEventEmitter } from 'devframe/utils/events'
1111
import { HUB_EVENTS } from '../events'
1212
import { diagnostics } from './diagnostics'
@@ -193,7 +193,7 @@ export class DevframeCommandsHost implements DevframeCommandsHostType {
193193
* declared `agent.args` schemas; undeclared payload is dropped.
194194
*/
195195
handler: async (args: unknown) =>
196-
this.execute(command.id, ...coerceAgentPositionalArgs(args, agent.args, 'drop')),
196+
this.execute(command.id, ...toolInputToCommandArgs(args, agent.args?.length)),
197197
})
198198
}
199199
for (const child of command.children ?? [])

tests/__snapshots__/tsnapi/devframe/internal.snapshot.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface RpcWireCodec {
1515
// #endregion
1616

1717
// #region Types
18+
/** @deprecated */
1819
export type AgentArgsFallback = 'wrap' | 'drop';
1920
// #endregion
2021

@@ -48,6 +49,7 @@ export declare class DevframeAgentHost implements DevframeAgentHost$1 {
4849
// #endregion
4950

5051
// #region Functions
52+
/** @deprecated */
5153
export declare function coerceAgentPositionalArgs(_: unknown, _: readonly unknown[] | undefined, _?: AgentArgsFallback): unknown[];
5254
export declare function createH3DevframeHost(_: CreateH3DevframeHostOptions): DevframeHost;
5355
export declare function createRpcWireCodec(_?: ReadonlyMap<string, Pick<RpcFunctionDefinitionAny, 'jsonSerializable'>>): RpcWireCodec;
@@ -58,6 +60,7 @@ export declare function peekRpcWireFrame(_: string): {
5860
i?: string;
5961
};
6062
export declare function resolveClientAssets(_: DevframeDefinition): StaticAssetsSource | undefined;
63+
export declare function toolInputToCommandArgs(_: unknown, _?: number): unknown[];
6164
// #endregion
6265

6366
// #region Variables

tests/__snapshots__/tsnapi/devframe/internal.snapshot.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export { resolveClientAssets }
2121
export { resolveInstanceRegister }
2222
export { resolveMcpConfig }
2323
export { samePath }
24+
export { toolInputToCommandArgs }
2425
// #endregion

0 commit comments

Comments
 (0)