Skip to content

Commit f969b01

Browse files
authored
Merge pull request #262 from LeXwDeX/fix/tool-params-object-root
fix(tool): require plain-object parameter roots per OpenAI tools contract
2 parents e02f5d3 + 2c89feb commit f969b01

14 files changed

Lines changed: 621 additions & 467 deletions

packages/opencode/AGENTS.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
# opencode database guide
22

3+
## Tool parameter schema contract
4+
5+
Tool `parameters` must serialize to a JSON Schema **plain object root** (`type:
6+
"object"` with `properties`). Root-level combinators (`anyOf`/`oneOf`/`allOf`)
7+
violate the OpenAI tools contract: OpenAI tolerates them, DeepSeek rejects them
8+
with a schema error, and GLM silently emits empty tool arguments. A tool that
9+
needs a discriminated union must nest it under a property, e.g.
10+
`Schema.Struct({ params: <union> })`. `Tool.define` enforces this at
11+
construction time (`assertObjectRootedParameters`) — a violating tool fails
12+
registration instead of degrading at provider runtime.
13+
314
## Database
415

516
- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`.

packages/opencode/src/provider/transform.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1537,21 +1537,6 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
15371537
schema = sanitizeGemini(schema)
15381538
}
15391539

1540-
// OpenAI-compatible backends (DeepSeek, GLM, and other relays) reject
1541-
// function schemas whose root type is implicit — the model emits empty
1542-
// tool arguments instead of erroring. Effect emits object-only
1543-
// discriminated unions as a root `anyOf`; retaining the union while
1544-
// declaring its shared object type preserves every branch.
1545-
if (
1546-
model.api.npm === "@ai-sdk/openai-compatible" &&
1547-
schema.type === undefined &&
1548-
Array.isArray(schema.anyOf) &&
1549-
schema.anyOf.length > 0 &&
1550-
schema.anyOf.every((branch) => isPlainObject(branch) && branch.type === "object")
1551-
) {
1552-
schema = { ...schema, type: "object" }
1553-
}
1554-
15551540
return schema
15561541
}
15571542

packages/opencode/src/session/llm/AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Keep new integration code on one of these seams. Avoid importing session service
3333

3434
## Runtime selection
3535

36-
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
36+
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others. The native gate keys off the model's SDK transport package (`api.npm`), not the providerID — any OpenAI-compatible relay (local proxies, DeepSeek, GLM gateways) speaks the wire protocol the native client implements.
3737

3838
```txt
3939
╭───────────────────╮

packages/opencode/src/session/llm/native-runtime.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ function statusWithFetch(
5151
input: Pick<StreamInput, "model" | "provider" | "auth">,
5252
fetch: typeof globalThis.fetch | undefined,
5353
): RuntimeStatus {
54-
const providerID = input.model.providerID
55-
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
56-
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
54+
// The gate keys off the SDK transport package, not the providerID: any
55+
// OpenAI-compatible relay (local proxies, DeepSeek, GLM gateways) speaks the
56+
// same wire protocol the native client implements.
5757
const npm = input.model.api.npm
5858
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
5959
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }

packages/opencode/src/tool/tool.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
44
import type { JSONSchema7 } from "@ai-sdk/provider"
55
import type { SessionID, MessageID } from "../session/schema"
66
import * as Truncate from "./truncate"
7+
import { ToolJsonSchema } from "./json-schema"
78
import { Agent } from "@/agent/agent"
89

910
interface Metadata {
@@ -103,6 +104,42 @@ export type InferDef<T> =
103104
? Def<P, M>
104105
: never
105106

107+
/**
108+
* The OpenAI tools contract requires `parameters` to be a JSON Schema object.
109+
* A root-level combinator (anyOf/oneOf/allOf) is outside that contract:
110+
* OpenAI tolerates it, DeepSeek rejects it with a schema error, and GLM
111+
* silently emits empty tool arguments. Tools that need a discriminated union
112+
* must nest it under a property (e.g. `{ params: <union> }`). Violations fail
113+
* at construction time here instead of degrading at provider runtime.
114+
*/
115+
function assertObjectRootedParameters(id: string, toolInfo: DefWithoutID<never, never> | { parameters: unknown; jsonSchema?: unknown }) {
116+
const root = toolInfo.jsonSchema ?? ToolJsonSchema.fromSchema(toolInfo.parameters as Schema.Top)
117+
if (!isPlainObjectRoot(root as JSONSchema7)) {
118+
return yieldOrDieRootCombinator(id, root)
119+
}
120+
}
121+
122+
function isPlainObjectRoot(root: JSONSchema7): boolean {
123+
return (
124+
typeof root === "object" &&
125+
root !== null &&
126+
!Array.isArray(root) &&
127+
(root as { type?: unknown }).type === "object" &&
128+
(root as { anyOf?: unknown }).anyOf === undefined &&
129+
(root as { oneOf?: unknown }).oneOf === undefined &&
130+
(root as { allOf?: unknown }).allOf === undefined
131+
)
132+
}
133+
134+
function yieldOrDieRootCombinator(id: string, root: unknown): never {
135+
const combinator = ["anyOf", "oneOf", "allOf"].find(
136+
(key) => Array.isArray((root as Record<string, unknown>)?.[key]),
137+
)
138+
throw new Error(
139+
`Tool "${id}" parameters must serialize to a plain object root (type: "object" with properties); found a root-level ${combinator ?? "non-object"} combinator. Nest the union under a property, e.g. Schema.Struct({ params: <union> }). Root-level combinators violate the OpenAI tools contract: DeepSeek rejects them and GLM answers with empty tool arguments.`,
140+
)
141+
}
142+
106143
function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
107144
id: string,
108145
init: Init<Parameters, Result>,
@@ -112,6 +149,7 @@ function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadat
112149
return () =>
113150
Effect.gen(function* () {
114151
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
152+
assertObjectRootedParameters(id, toolInfo as { parameters: unknown; jsonSchema?: unknown })
115153
// Compile the parser closure once per tool init; `decodeUnknownEffect`
116154
// allocates a new closure per call, so hoisting avoids re-closing it for
117155
// every LLM tool invocation.

packages/opencode/src/tool/workflow.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,13 @@ export const StartSpec = DagValidation.StartSpec
4545
export { Parameters as WorkflowParameters }
4646

4747
// ============================================================================
48-
// Parameters: one discriminated union, action-owned fields only.
49-
// Runtime-derived identity (session/project) is never model-authored — start
50-
// derives ownership from the calling session.
48+
// Parameters: a single `params` root property carrying the action union.
49+
// OpenAI's tools contract expects `parameters` to be a JSON Schema object; a
50+
// root-level combinator (anyOf/oneOf/allOf) is outside that contract and
51+
// OpenAI-compatible backends reject it — DeepSeek with an explicit schema
52+
// error, GLM by silently emitting empty tool arguments. Nesting the union one
53+
// level down keeps every discriminated branch intact while the schema root
54+
// stays a plain object on every transport.
5155
// ============================================================================
5256

5357
const specPathDescription =
@@ -118,7 +122,7 @@ const ValidatePath = Schema.Struct({
118122
profile: ValidationProfile,
119123
})
120124

121-
export const Parameters = Schema.Union([
125+
const ActionParams = Schema.Union([
122126
StartPath,
123127
ExtendPath,
124128
ControlReplanPath,
@@ -131,6 +135,10 @@ export const Parameters = Schema.Union([
131135
ValidatePath,
132136
])
133137

138+
export const Parameters = Schema.Struct({
139+
params: ActionParams.annotate({ description: "The workflow action and its action-owned fields" }),
140+
})
141+
134142
// ============================================================================
135143
// Tool definition
136144
// ============================================================================
@@ -243,10 +251,11 @@ export const WorkflowTool = Tool.define<
243251
formatValidationError: (error) =>
244252
[
245253
`Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`,
246-
"Each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.",
254+
'The call takes a single { params } object: params { action, ...action-owned fields } where each action owns only its own fields: start {spec_path}; extend {workflow_id, spec_path}; control(replan) {workflow_id, operation, spec_path}; other control operations {workflow_id, operation}; status {workflow_id}; result {workflow_id, node_id, cursor?, limit?}; list {}; read {spec_path}; guide {topic?}; validate {spec_path, profile?}. Put graph content in a .yaml/.yml file; session/project identity is never a parameter.',
247255
].join("\n"),
248-
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
256+
execute: (call: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
249257
Effect.gen(function* () {
258+
const params = call.params
250259
const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie)
251260
if (callingSession.parentID) {
252261
return yield* Effect.die(

0 commit comments

Comments
 (0)