Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/opencode/AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# opencode database guide

## Tool parameter schema contract

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

## Database

- **Schema**: Drizzle schema lives in `packages/core/src/**/*.sql.ts`.
Expand Down
15 changes: 0 additions & 15 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1537,21 +1537,6 @@ export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7
schema = sanitizeGemini(schema)
}

// OpenAI-compatible backends (DeepSeek, GLM, and other relays) reject
// function schemas whose root type is implicit — the model emits empty
// tool arguments instead of erroring. Effect emits object-only
// discriminated unions as a root `anyOf`; retaining the union while
// declaring its shared object type preserves every branch.
if (
model.api.npm === "@ai-sdk/openai-compatible" &&
schema.type === undefined &&
Array.isArray(schema.anyOf) &&
schema.anyOf.length > 0 &&
schema.anyOf.every((branch) => isPlainObject(branch) && branch.type === "object")
) {
schema = { ...schema, type: "object" }
}

return schema
}

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/session/llm/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Keep new integration code on one of these seams. Avoid importing session service

## Runtime selection

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.
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.

```txt
╭───────────────────╮
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/session/llm/native-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ function statusWithFetch(
input: Pick<StreamInput, "model" | "provider" | "auth">,
fetch: typeof globalThis.fetch | undefined,
): RuntimeStatus {
const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
// The gate keys off the SDK transport package, not the providerID: any
// OpenAI-compatible relay (local proxies, DeepSeek, GLM gateways) speaks the
// same wire protocol the native client implements.
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
Expand Down
38 changes: 38 additions & 0 deletions packages/opencode/src/tool/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { SessionV1 } from "@opencode-ai/core/v1/session"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { SessionID, MessageID } from "../session/schema"
import * as Truncate from "./truncate"
import { ToolJsonSchema } from "./json-schema"
import { Agent } from "@/agent/agent"

interface Metadata {
Expand Down Expand Up @@ -103,6 +104,42 @@ export type InferDef<T> =
? Def<P, M>
: never

/**
* The OpenAI tools contract requires `parameters` to be a JSON Schema object.
* A root-level combinator (anyOf/oneOf/allOf) is outside that contract:
* OpenAI tolerates it, DeepSeek rejects it with a schema error, and GLM
* silently emits empty tool arguments. Tools that need a discriminated union
* must nest it under a property (e.g. `{ params: <union> }`). Violations fail
* at construction time here instead of degrading at provider runtime.
*/
function assertObjectRootedParameters(id: string, toolInfo: DefWithoutID<never, never> | { parameters: unknown; jsonSchema?: unknown }) {
const root = toolInfo.jsonSchema ?? ToolJsonSchema.fromSchema(toolInfo.parameters as Schema.Top)
if (!isPlainObjectRoot(root as JSONSchema7)) {
return yieldOrDieRootCombinator(id, root)
}
}

function isPlainObjectRoot(root: JSONSchema7): boolean {
return (
typeof root === "object" &&
root !== null &&
!Array.isArray(root) &&
(root as { type?: unknown }).type === "object" &&
(root as { anyOf?: unknown }).anyOf === undefined &&
(root as { oneOf?: unknown }).oneOf === undefined &&
(root as { allOf?: unknown }).allOf === undefined
)
}

function yieldOrDieRootCombinator(id: string, root: unknown): never {
const combinator = ["anyOf", "oneOf", "allOf"].find(
(key) => Array.isArray((root as Record<string, unknown>)?.[key]),
)
throw new Error(
`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.`,
)
}

function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadata>(
id: string,
init: Init<Parameters, Result>,
Expand All @@ -112,6 +149,7 @@ function wrap<Parameters extends Schema.Decoder<unknown>, Result extends Metadat
return () =>
Effect.gen(function* () {
const toolInfo = typeof init === "function" ? { ...(yield* init()) } : { ...init }
assertObjectRootedParameters(id, toolInfo as { parameters: unknown; jsonSchema?: unknown })
// Compile the parser closure once per tool init; `decodeUnknownEffect`
// allocates a new closure per call, so hoisting avoids re-closing it for
// every LLM tool invocation.
Expand Down
21 changes: 15 additions & 6 deletions packages/opencode/src/tool/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,13 @@ export const StartSpec = DagValidation.StartSpec
export { Parameters as WorkflowParameters }

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

const specPathDescription =
Expand Down Expand Up @@ -118,7 +122,7 @@ const ValidatePath = Schema.Struct({
profile: ValidationProfile,
})

export const Parameters = Schema.Union([
const ActionParams = Schema.Union([
StartPath,
ExtendPath,
ControlReplanPath,
Expand All @@ -131,6 +135,10 @@ export const Parameters = Schema.Union([
ValidatePath,
])

export const Parameters = Schema.Struct({
params: ActionParams.annotate({ description: "The workflow action and its action-owned fields" }),
})

// ============================================================================
// Tool definition
// ============================================================================
Expand Down Expand Up @@ -243,10 +251,11 @@ export const WorkflowTool = Tool.define<
formatValidationError: (error) =>
[
`Workflow call rejected by the action schema: ${error instanceof Error ? error.message : String(error)}`,
"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.",
'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.',
].join("\n"),
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
execute: (call: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context<Metadata>) =>
Effect.gen(function* () {
const params = call.params
const callingSession = yield* sessions.get(SessionID.make(ctx.sessionID)).pipe(Effect.orDie)
if (callingSession.parentID) {
return yield* Effect.die(
Expand Down
Loading
Loading