refactor(opencode): simplify code-mode result projection
This commit is contained in:
parent
044ca11790
commit
32af121433
2 changed files with 78 additions and 42 deletions
|
|
@ -56,12 +56,6 @@ type CatalogEntry = {
|
||||||
|
|
||||||
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
const toJsonSchema = (schema: unknown): JsonSchema => schema as JsonSchema
|
||||||
|
|
||||||
function fallbackInputSchema(tool: AITool): JsonSchema {
|
|
||||||
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
|
|
||||||
if (schema && typeof schema === "object") return toJsonSchema(schema)
|
|
||||||
return { type: "object", properties: {} }
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupByServer(
|
function groupByServer(
|
||||||
mcpTools: Record<string, AITool>,
|
mcpTools: Record<string, AITool>,
|
||||||
servers: readonly string[],
|
servers: readonly string[],
|
||||||
|
|
@ -73,15 +67,21 @@ function groupByServer(
|
||||||
const server =
|
const server =
|
||||||
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
byLongest.find((name) => key.startsWith(name + "_")) ?? (key.includes("_") ? key.slice(0, key.indexOf("_")) : key)
|
||||||
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
const local = server && key.startsWith(server + "_") ? key.slice(server.length + 1) : key
|
||||||
|
const tool = mcpTools[key]!
|
||||||
const def = mcpDefs[key]
|
const def = mcpDefs[key]
|
||||||
|
const schema = (tool.inputSchema as { jsonSchema?: unknown } | undefined)?.jsonSchema
|
||||||
const entry: CatalogEntry = {
|
const entry: CatalogEntry = {
|
||||||
path: `${server}.${local}`,
|
path: `${server}.${local}`,
|
||||||
key,
|
key,
|
||||||
server,
|
server,
|
||||||
local,
|
local,
|
||||||
description: mcpTools[key]!.description ?? def?.description ?? "",
|
description: tool.description ?? def?.description ?? "",
|
||||||
tool: mcpTools[key]!,
|
tool,
|
||||||
inputSchema: def?.inputSchema ? toJsonSchema(def.inputSchema) : fallbackInputSchema(mcpTools[key]!),
|
inputSchema: def?.inputSchema
|
||||||
|
? toJsonSchema(def.inputSchema)
|
||||||
|
: schema && typeof schema === "object"
|
||||||
|
? toJsonSchema(schema)
|
||||||
|
: { type: "object", properties: {} },
|
||||||
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
|
...(def?.outputSchema ? { outputSchema: toJsonSchema(def.outputSchema) } : {}),
|
||||||
}
|
}
|
||||||
groups.set(server, [...(groups.get(server) ?? []), entry])
|
groups.set(server, [...(groups.get(server) ?? []), entry])
|
||||||
|
|
@ -104,16 +104,6 @@ export function describeCatalog(
|
||||||
}).instructions()
|
}).instructions()
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayInput(input: unknown): Record<string, unknown> | undefined {
|
|
||||||
if (input === null || input === undefined) return
|
|
||||||
if (typeof input === "object" && !Array.isArray(input)) {
|
|
||||||
const value = input as Record<string, unknown>
|
|
||||||
if (Object.keys(value).length > 0) return value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return { input }
|
|
||||||
}
|
|
||||||
|
|
||||||
const lastSegment = (uri: string) => {
|
const lastSegment = (uri: string) => {
|
||||||
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
|
const trimmed = uri.split(/[?#]/, 1)[0]!.replace(/\/+$/, "")
|
||||||
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
|
const segment = trimmed.slice(trimmed.lastIndexOf("/") + 1)
|
||||||
|
|
@ -122,12 +112,7 @@ const lastSegment = (uri: string) => {
|
||||||
|
|
||||||
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
const dataUrl = (mime: string, base64: string) => `data:${mime};base64,${base64}`
|
||||||
|
|
||||||
const mediaMarker = (files: number, images: number) => {
|
function projectMcpResult(raw: unknown, collect: (attachment: Attachment) => void): unknown {
|
||||||
const noun = files === images ? "image" : "file"
|
|
||||||
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProgramValue(raw: unknown, collect: (attachment: Attachment) => void): unknown {
|
|
||||||
if (raw === null || typeof raw !== "object") return raw
|
if (raw === null || typeof raw !== "object") return raw
|
||||||
const record = raw as { structuredContent?: unknown; content?: unknown }
|
const record = raw as { structuredContent?: unknown; content?: unknown }
|
||||||
const content = Array.isArray(record.content) ? record.content : []
|
const content = Array.isArray(record.content) ? record.content : []
|
||||||
|
|
@ -180,7 +165,10 @@ function toProgramValue(raw: unknown, collect: (attachment: Attachment) => void)
|
||||||
|
|
||||||
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
|
if (record.structuredContent !== undefined && record.structuredContent !== null) return record.structuredContent
|
||||||
if (text.length > 0) return text.join("\n")
|
if (text.length > 0) return text.join("\n")
|
||||||
if (files > 0) return mediaMarker(files, images)
|
if (files > 0) {
|
||||||
|
const noun = files === images ? "image" : "file"
|
||||||
|
return `[${files} ${noun}${files === 1 ? "" : "s"} attached to the result]`
|
||||||
|
}
|
||||||
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
|
if (Array.isArray(record.content)) return null // MCP-shaped result with nothing extractable
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +271,7 @@ export const CodeModeTool = Tool.define(
|
||||||
ctx,
|
ctx,
|
||||||
execute: entry.tool.execute!,
|
execute: entry.tool.execute!,
|
||||||
})
|
})
|
||||||
return toProgramValue(raw, collect)
|
return projectMcpResult(raw, collect)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catchCause((cause) => {
|
Effect.catchCause((cause) => {
|
||||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||||
|
|
@ -296,7 +284,14 @@ export const CodeModeTool = Tool.define(
|
||||||
tools: toolTree(catalog, callTool),
|
tools: toolTree(catalog, callTool),
|
||||||
onToolCallStart: ({ index, name, input }) =>
|
onToolCallStart: ({ index, name, input }) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const shown = displayInput(input)
|
const shown = (() => {
|
||||||
|
if (input === null || input === undefined) return
|
||||||
|
if (typeof input === "object" && !Array.isArray(input)) {
|
||||||
|
const value = input as Record<string, unknown>
|
||||||
|
return Object.keys(value).length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
return { input }
|
||||||
|
})()
|
||||||
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
calls[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||||
return publish()
|
return publish()
|
||||||
}),
|
}),
|
||||||
|
|
@ -308,22 +303,22 @@ export const CodeModeTool = Tool.define(
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Bridge ai-sdk AbortSignal cancellation into the Effect fiber.
|
const abort = Effect.callback<void>((resume) => {
|
||||||
const cancelled = Effect.callback<ExecuteResult>((resume) => {
|
if (ctx.abort.aborted) return resume(Effect.void)
|
||||||
const onAbort = () =>
|
const handler = () => resume(Effect.void)
|
||||||
resume(
|
ctx.abort.addEventListener("abort", handler, { once: true })
|
||||||
Effect.succeed<ExecuteResult>({
|
return Effect.sync(() => ctx.abort.removeEventListener("abort", handler))
|
||||||
ok: false,
|
})
|
||||||
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
|
const cancelled = (): ExecuteResult => ({
|
||||||
toolCalls: calls.map((call) => ({ name: call.tool })),
|
ok: false,
|
||||||
}),
|
error: { kind: "ExecutionFailure", message: "Execution cancelled." },
|
||||||
)
|
toolCalls: calls.map((call) => ({ name: call.tool })),
|
||||||
if (ctx.abort.aborted) return onAbort()
|
|
||||||
ctx.abort.addEventListener("abort", onAbort, { once: true })
|
|
||||||
return Effect.sync(() => ctx.abort.removeEventListener("abort", onAbort))
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = yield* Effect.raceFirst(runtime.execute(params.code), cancelled)
|
const result = yield* Effect.raceFirst(
|
||||||
|
runtime.execute(params.code),
|
||||||
|
abort.pipe(Effect.map(cancelled)),
|
||||||
|
)
|
||||||
const logs = result.logs ?? []
|
const logs = result.logs ?? []
|
||||||
const attached = attachments.length > 0 ? { attachments } : {}
|
const attached = attachments.length > 0 ? { attachments } : {}
|
||||||
const hints = result.ok
|
const hints = result.ok
|
||||||
|
|
|
||||||
|
|
@ -509,6 +509,47 @@ describe("code mode execute", () => {
|
||||||
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
|
expect(out.attachments).toEqual([{ type: "file", mime: "image/png", url: "data:image/png;base64,PNGDATA" }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("media-only markers distinguish all-image from mixed attachments", async () => {
|
||||||
|
const tool = await build({
|
||||||
|
media_images: mcpTool("images", () => ({
|
||||||
|
content: [
|
||||||
|
{ type: "image", data: "PNG1", mimeType: "image/png" },
|
||||||
|
{ type: "image", data: "PNG2", mimeType: "image/png" },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
media_mixed: mcpTool("mixed", () => ({
|
||||||
|
content: [
|
||||||
|
{ type: "image", data: "PNG3", mimeType: "image/png" },
|
||||||
|
{ type: "resource_link", uri: "file:///tmp/report.pdf", mimeType: "application/pdf" },
|
||||||
|
],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
const out = await Effect.runPromise(
|
||||||
|
tool.execute(
|
||||||
|
{
|
||||||
|
code: `
|
||||||
|
const images = await tools.media.images({})
|
||||||
|
const mixed = await tools.media.mixed({})
|
||||||
|
return { images, mixed }
|
||||||
|
`,
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(JSON.parse(out.output)).toEqual({
|
||||||
|
images: "[2 images attached to the result]",
|
||||||
|
mixed: "[2 files attached to the result]",
|
||||||
|
})
|
||||||
|
expect(out.output).not.toContain("PNG")
|
||||||
|
expect(out.attachments).toEqual([
|
||||||
|
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG1" },
|
||||||
|
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG2" },
|
||||||
|
{ type: "file", mime: "image/png", url: "data:image/png;base64,PNG3" },
|
||||||
|
{ type: "file", mime: "application/pdf", url: "file:///tmp/report.pdf", filename: "report.pdf" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("attachments still flow when the program returns something else entirely", async () => {
|
test("attachments still flow when the program returns something else entirely", async () => {
|
||||||
const tool = await build({
|
const tool = await build({
|
||||||
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
|
shot_take: mcpTool("take", () => ({ content: [{ type: "image", data: "PNGDATA", mimeType: "image/png" }] })),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue