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
30 changes: 26 additions & 4 deletions packages/codemode/src/tool-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,34 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
? toTypeScript(definition.output, true, pretty)
: jsonSchemaToTypeScript(definition.output, pretty)

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)

/** Parses a JSON string standing in for a declared object/array (e.g. `args: "{}"`); every other value is untouched. */
const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => {
if (typeof value !== "string" || (schema?.type !== "object" && schema?.type !== "array")) return value
try {
const parsed: unknown = JSON.parse(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a JSON-Schema object/array argument is passed as a string, JSON.parse runs after the runtime validates the original string. Revalidate the parsed value through the same boundary sanitizer, or decode before copyIn, so depth and blocked-member checks still apply.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/codemode/src/tool-schema.ts, line 293:

<comment>When a JSON-Schema object/array argument is passed as a string, `JSON.parse` runs after the runtime validates the original string. Revalidate the parsed value through the same boundary sanitizer, or decode before `copyIn`, so depth and blocked-member checks still apply.</comment>

<file context>
@@ -283,12 +283,35 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
+const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => {
+  if (typeof value !== "string" || (schema?.type !== "object" && schema?.type !== "array")) return value
+  try {
+    const parsed: unknown = JSON.parse(value)
+    if (schema.type === "array" ? Array.isArray(parsed) : isRecord(parsed)) return parsed
+  } catch {
</file context>

if (schema.type === "array" ? Array.isArray(parsed) : isRecord(parsed)) return parsed
} catch {
// not JSON: keep the original string
}
return value
}
Comment on lines +290 to +299

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a JSON Schema uses a legal union such as type: ["object", "null"], this guard skips decoding, so stringified object or array arguments still reach run as strings. Accept object and array members of an array-valued type before parsing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/codemode/src/tool-schema.ts, line 290:

<comment>When a JSON Schema uses a legal union such as `type: ["object", "null"]`, this guard skips decoding, so stringified object or array arguments still reach `run` as strings. Accept `object` and `array` members of an array-valued `type` before parsing.</comment>

<file context>
@@ -283,12 +283,34 @@ export const outputTypeScript = <R>(definition: Definition<R>, pretty = false):
+  typeof value === "object" && value !== null && !Array.isArray(value)
+
+/** Parses a JSON string standing in for a declared object/array (e.g. `args: "{}"`); every other value is untouched. */
+const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => {
+  if (typeof value !== "string" || (schema?.type !== "object" && schema?.type !== "array")) return value
+  try {
</file context>
Suggested change
const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => {
if (typeof value !== "string" || (schema?.type !== "object" && schema?.type !== "array")) return value
try {
const parsed: unknown = JSON.parse(value)
if (schema.type === "array" ? Array.isArray(parsed) : isRecord(parsed)) return parsed
} catch {
// not JSON: keep the original string
}
return value
}
const decodeJsonString = (value: unknown, schema: JsonSchema | undefined): unknown => {
const types: ReadonlyArray<string> =
schema === undefined
? []
: Array.isArray(schema.type)
? schema.type
: schema.type === undefined
? []
: [schema.type]
if (typeof value !== "string" || (!types.includes("object") && !types.includes("array"))) return value
try {
const parsed: unknown = JSON.parse(value)
if (types.includes("array") && Array.isArray(parsed)) return parsed
if (types.includes("object") && isRecord(parsed)) return parsed
} catch {
// not JSON: keep the original string
}
return value
}


/**
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
* JSON-Schema-described inputs pass through unvalidated (render-only).
* Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure); JSON-Schema
* inputs pass through unvalidated (render-only), after decoding JSON strings standing in for declared objects/arrays.
*/
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown => {
if (isEffectSchema(definition.input)) return Schema.decodeUnknownSync(definition.input)(value)
const decoded = decodeJsonString(value, definition.input)
const properties = definition.input.properties
if (properties === undefined || !isRecord(decoded)) return decoded
return Object.fromEntries(
Object.entries(decoded).map(([key, entry]) => [key, decodeJsonString(entry, properties[key])]),
)
}

/**
* Decodes a tool result before it is exposed to the program. Effect Schemas validate and
Expand Down
32 changes: 32 additions & 0 deletions packages/codemode/test/codemode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,38 @@ describe("CodeMode schema flexibility", () => {
expect(observed).toStrictEqual([{ id: 42 }])
})

test("decodes JSON-string input where the schema declares an object or array", async () => {
const observed: Array<unknown> = []
const call = Tool.make({
description: "Invoke a proxied tool",
input: {
type: "object",
properties: {
args: { type: "object" },
tags: { type: "array", items: { type: "string" } },
body: { type: "string" },
broken: { type: "object" },
},
},
run: (input) =>
Effect.sync(() => {
observed.push(input)
return { ok: true }
}),
})
const runtime = CodeMode.make({ tools: { proxy: { call } } })

await Effect.runPromise(
runtime.execute(`return await tools.proxy.call({ args: "{}", tags: '["a"]', body: "{}", broken: "not json" })`),
)
await Effect.runPromise(runtime.execute(`return await tools.proxy.call('{"args": {"port": 3000}}')`))

expect(observed).toStrictEqual([
{ args: {}, tags: ["a"], body: "{}", broken: "not json" },
{ args: { port: 3000 } },
])
})

test("renders JSON Schema outputs and $defs references", async () => {
const lookup = Tool.make({
description: "Look up a user",
Expand Down
Loading