Compare commits
13 Commits
dev
...
provider-o
| Author | SHA1 | Date |
|---|---|---|
|
|
5a7b678553 | |
|
|
8d7b0f0235 | |
|
|
5572602ec4 | |
|
|
a584c0fb9f | |
|
|
e5a14e6110 | |
|
|
b1da5714d7 | |
|
|
4c7c65a054 | |
|
|
d2beb78457 | |
|
|
0e8f7694ed | |
|
|
34f9feb12d | |
|
|
4cb9f8ab34 | |
|
|
d582dc1c9f | |
|
|
32ef11da1f |
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"keep": {
|
|
||||||
"days": true,
|
|
||||||
"amount": 14
|
|
||||||
},
|
|
||||||
"auditLog": "/home/thdxr/dev/projects/sst/opencode/logs/.2c5480b3b2480f80fa29b850af461dce619c0b2f-audit.json",
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"date": 1759827172859,
|
|
||||||
"name": "/home/thdxr/dev/projects/sst/opencode/logs/mcp-puppeteer-2025-10-07.log",
|
|
||||||
"hash": "a3d98b26edd793411b968a0d24cfeee8332138e282023c3b83ec169d55c67f16"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"hashType": "sha256"
|
|
||||||
}
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.879"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:52.880"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.191"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:56.192"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.267"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:52:59.268"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.276"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:20.277"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.838"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:30.839"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:42.452"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.499"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:53:46.500"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:02.295"}
|
|
||||||
{"arguments":{"url":"https://google.com"},"level":"debug","message":"Tool call received","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150","tool":"puppeteer_navigate"}
|
|
||||||
{"0":"n","1":"p","2":"x","level":"info","message":"Launching browser with config:","service":"mcp-puppeteer","timestamp":"2025-10-07 04:54:37.150"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.488"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 04:55:08.489"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.815"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:11.816"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.934"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:21.935"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:32.544"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.154"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:41.155"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.426"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:23:55.427"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.715"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:15.716"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.063"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:25.064"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.567"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:24:48.568"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.937"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 05:25:08.938"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.120"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:37.121"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.490"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:38:52.491"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.524"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:39:25.525"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.126"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:40:57.127"}
|
|
||||||
{"level":"info","message":"Starting MCP server","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.175"}
|
|
||||||
{"level":"info","message":"MCP server started successfully","service":"mcp-puppeteer","timestamp":"2025-10-07 22:42:24.176"}
|
|
||||||
|
|
@ -64,7 +64,7 @@ export namespace ModelsDev {
|
||||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||||
options: z.record(z.string(), z.any()),
|
options: z.record(z.string(), z.any()),
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z.record(z.string(), z.string()).optional(),
|
||||||
provider: z.object({ npm: z.string(), api: z.string() }).optional(),
|
provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(),
|
||||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||||
})
|
})
|
||||||
export type Model = z.infer<typeof Model>
|
export type Model = z.infer<typeof Model>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ export namespace LLM {
|
||||||
small?: boolean
|
small?: boolean
|
||||||
tools: Record<string, Tool>
|
tools: Record<string, Tool>
|
||||||
retries?: number
|
retries?: number
|
||||||
|
toolChoice?: "auto" | "required" | "none"
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StreamOutput = StreamTextResult<ToolSet, unknown>
|
export type StreamOutput = StreamTextResult<ToolSet, unknown>
|
||||||
|
|
@ -205,6 +206,7 @@ export namespace LLM {
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
||||||
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
|
||||||
tools,
|
tools,
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
maxOutputTokens,
|
maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,13 @@ import type { Provider } from "@/provider/provider"
|
||||||
export namespace MessageV2 {
|
export namespace MessageV2 {
|
||||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||||
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
|
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
|
||||||
|
export const StructuredOutputError = NamedError.create(
|
||||||
|
"StructuredOutputError",
|
||||||
|
z.object({
|
||||||
|
message: z.string(),
|
||||||
|
retries: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
export const AuthError = NamedError.create(
|
export const AuthError = NamedError.create(
|
||||||
"ProviderAuthError",
|
"ProviderAuthError",
|
||||||
z.object({
|
z.object({
|
||||||
|
|
@ -39,6 +46,29 @@ export namespace MessageV2 {
|
||||||
z.object({ message: z.string(), responseBody: z.string().optional() }),
|
z.object({ message: z.string(), responseBody: z.string().optional() }),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const OutputFormatText = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("text"),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "OutputFormatText",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const OutputFormatJsonSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("json_schema"),
|
||||||
|
schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }),
|
||||||
|
retryCount: z.number().int().min(0).default(2),
|
||||||
|
})
|
||||||
|
.meta({
|
||||||
|
ref: "OutputFormatJsonSchema",
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({
|
||||||
|
ref: "OutputFormat",
|
||||||
|
})
|
||||||
|
export type OutputFormat = z.infer<typeof Format>
|
||||||
|
|
||||||
const PartBase = z.object({
|
const PartBase = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
sessionID: z.string(),
|
sessionID: z.string(),
|
||||||
|
|
@ -313,6 +343,7 @@ export namespace MessageV2 {
|
||||||
time: z.object({
|
time: z.object({
|
||||||
created: z.number(),
|
created: z.number(),
|
||||||
}),
|
}),
|
||||||
|
format: Format.optional(),
|
||||||
summary: z
|
summary: z
|
||||||
.object({
|
.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
|
@ -365,6 +396,7 @@ export namespace MessageV2 {
|
||||||
NamedError.Unknown.Schema,
|
NamedError.Unknown.Schema,
|
||||||
OutputLengthError.Schema,
|
OutputLengthError.Schema,
|
||||||
AbortedError.Schema,
|
AbortedError.Schema,
|
||||||
|
StructuredOutputError.Schema,
|
||||||
ContextOverflowError.Schema,
|
ContextOverflowError.Schema,
|
||||||
APIError.Schema,
|
APIError.Schema,
|
||||||
])
|
])
|
||||||
|
|
@ -393,6 +425,7 @@ export namespace MessageV2 {
|
||||||
write: z.number(),
|
write: z.number(),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
structured: z.any().optional(),
|
||||||
variant: z.string().optional(),
|
variant: z.string().optional(),
|
||||||
finish: z.string().optional(),
|
finish: z.string().optional(),
|
||||||
}).meta({
|
}).meta({
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@ import { Truncate } from "@/tool/truncation"
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
|
|
||||||
|
const STRUCTURED_OUTPUT_DESCRIPTION = `Use this tool to return your final response in the requested structured format.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- You MUST call this tool exactly once at the end of your response
|
||||||
|
- The input must be valid JSON matching the required schema
|
||||||
|
- Complete all necessary research and tool calls BEFORE calling this tool
|
||||||
|
- This tool provides your final answer - no further actions are taken after calling it`
|
||||||
|
|
||||||
|
const STRUCTURED_OUTPUT_SYSTEM_PROMPT = `IMPORTANT: The user has requested structured output. You MUST use the StructuredOutput tool to provide your final response. Do NOT respond with plain text - you MUST call the StructuredOutput tool with your answer formatted according to the schema.`
|
||||||
|
|
||||||
export namespace SessionPrompt {
|
export namespace SessionPrompt {
|
||||||
const log = Log.create({ service: "session.prompt" })
|
const log = Log.create({ service: "session.prompt" })
|
||||||
|
|
||||||
|
|
@ -96,6 +106,7 @@ export namespace SessionPrompt {
|
||||||
.describe(
|
.describe(
|
||||||
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
|
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
|
||||||
),
|
),
|
||||||
|
format: MessageV2.Format.optional(),
|
||||||
system: z.string().optional(),
|
system: z.string().optional(),
|
||||||
variant: z.string().optional(),
|
variant: z.string().optional(),
|
||||||
parts: z.array(
|
parts: z.array(
|
||||||
|
|
@ -276,6 +287,11 @@ export namespace SessionPrompt {
|
||||||
|
|
||||||
using _ = defer(() => cancel(sessionID))
|
using _ = defer(() => cancel(sessionID))
|
||||||
|
|
||||||
|
// Structured output state
|
||||||
|
// Note: On session resumption, state is reset but outputFormat is preserved
|
||||||
|
// on the user message and will be retrieved from lastUser below
|
||||||
|
let structuredOutput: unknown | undefined
|
||||||
|
|
||||||
let step = 0
|
let step = 0
|
||||||
const session = await Session.get(sessionID)
|
const session = await Session.get(sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
|
|
@ -589,6 +605,16 @@ export namespace SessionPrompt {
|
||||||
messages: msgs,
|
messages: msgs,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Inject StructuredOutput tool if JSON schema mode enabled
|
||||||
|
if (lastUser.format?.type === "json_schema") {
|
||||||
|
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||||
|
schema: lastUser.format.schema,
|
||||||
|
onSuccess(output) {
|
||||||
|
structuredOutput = output
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (step === 1) {
|
if (step === 1) {
|
||||||
SessionSummary.summarize({
|
SessionSummary.summarize({
|
||||||
sessionID: sessionID,
|
sessionID: sessionID,
|
||||||
|
|
@ -619,12 +645,19 @@ export namespace SessionPrompt {
|
||||||
|
|
||||||
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })
|
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages })
|
||||||
|
|
||||||
|
// Build system prompt, adding structured output instruction if needed
|
||||||
|
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
|
||||||
|
const format = lastUser.format ?? { type: "text" }
|
||||||
|
if (format.type === "json_schema") {
|
||||||
|
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
|
||||||
|
}
|
||||||
|
|
||||||
const result = await processor.process({
|
const result = await processor.process({
|
||||||
user: lastUser,
|
user: lastUser,
|
||||||
agent,
|
agent,
|
||||||
abort,
|
abort,
|
||||||
sessionID,
|
sessionID,
|
||||||
system: [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())],
|
system,
|
||||||
messages: [
|
messages: [
|
||||||
...MessageV2.toModelMessages(sessionMessages, model),
|
...MessageV2.toModelMessages(sessionMessages, model),
|
||||||
...(isLastStep
|
...(isLastStep
|
||||||
|
|
@ -638,7 +671,33 @@ export namespace SessionPrompt {
|
||||||
],
|
],
|
||||||
tools,
|
tools,
|
||||||
model,
|
model,
|
||||||
|
toolChoice: format.type === "json_schema" ? "required" : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// If structured output was captured, save it and exit immediately
|
||||||
|
// This takes priority because the StructuredOutput tool was called successfully
|
||||||
|
if (structuredOutput !== undefined) {
|
||||||
|
processor.message.structured = structuredOutput
|
||||||
|
processor.message.finish = processor.message.finish ?? "stop"
|
||||||
|
await Session.updateMessage(processor.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if model finished (finish reason is not "tool-calls" or "unknown")
|
||||||
|
const modelFinished = processor.message.finish && !["tool-calls", "unknown"].includes(processor.message.finish)
|
||||||
|
|
||||||
|
if (modelFinished && !processor.message.error) {
|
||||||
|
if (format.type === "json_schema") {
|
||||||
|
// Model stopped without calling StructuredOutput tool
|
||||||
|
processor.message.error = new MessageV2.StructuredOutputError({
|
||||||
|
message: "Model did not produce structured output",
|
||||||
|
retries: 0,
|
||||||
|
}).toObject()
|
||||||
|
await Session.updateMessage(processor.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result === "stop") break
|
if (result === "stop") break
|
||||||
if (result === "compact") {
|
if (result === "compact") {
|
||||||
await SessionCompaction.create({
|
await SessionCompaction.create({
|
||||||
|
|
@ -669,7 +728,8 @@ export namespace SessionPrompt {
|
||||||
return Provider.defaultModel()
|
return Provider.defaultModel()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveTools(input: {
|
/** @internal Exported for testing */
|
||||||
|
export async function resolveTools(input: {
|
||||||
agent: Agent.Info
|
agent: Agent.Info
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
session: Session.Info
|
session: Session.Info
|
||||||
|
|
@ -849,6 +909,36 @@ export namespace SessionPrompt {
|
||||||
return tools
|
return tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal Exported for testing */
|
||||||
|
export function createStructuredOutputTool(input: {
|
||||||
|
schema: Record<string, any>
|
||||||
|
onSuccess: (output: unknown) => void
|
||||||
|
}): AITool {
|
||||||
|
// Remove $schema property if present (not needed for tool input)
|
||||||
|
const { $schema, ...toolSchema } = input.schema
|
||||||
|
|
||||||
|
return tool({
|
||||||
|
id: "StructuredOutput" as any,
|
||||||
|
description: STRUCTURED_OUTPUT_DESCRIPTION,
|
||||||
|
inputSchema: jsonSchema(toolSchema as any),
|
||||||
|
async execute(args) {
|
||||||
|
// AI SDK validates args against inputSchema before calling execute()
|
||||||
|
input.onSuccess(args)
|
||||||
|
return {
|
||||||
|
output: "Structured output captured successfully.",
|
||||||
|
title: "Structured Output",
|
||||||
|
metadata: { valid: true },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toModelOutput(result) {
|
||||||
|
return {
|
||||||
|
type: "text",
|
||||||
|
value: result.output,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async function createUserMessage(input: PromptInput) {
|
async function createUserMessage(input: PromptInput) {
|
||||||
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
|
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
|
||||||
|
|
||||||
|
|
@ -870,6 +960,7 @@ export namespace SessionPrompt {
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
model,
|
model,
|
||||||
system: input.system,
|
system: input.system,
|
||||||
|
format: input.format,
|
||||||
variant,
|
variant,
|
||||||
}
|
}
|
||||||
using _ = defer(() => InstructionPrompt.clear(info.id))
|
using _ = defer(() => InstructionPrompt.clear(info.id))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,233 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
|
||||||
|
const projectRoot = path.join(__dirname, "../..")
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
// Skip tests if no API key is available
|
||||||
|
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
||||||
|
|
||||||
|
// Helper to run test within Instance context
|
||||||
|
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
return Instance.provide({
|
||||||
|
directory: projectRoot,
|
||||||
|
fn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("StructuredOutput Integration", () => {
|
||||||
|
test.skipIf(!hasApiKey)(
|
||||||
|
"produces structured output with simple schema",
|
||||||
|
async () => {
|
||||||
|
await withInstance(async () => {
|
||||||
|
const session = await Session.create({ title: "Structured Output Test" })
|
||||||
|
|
||||||
|
const result = await SessionPrompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "What is 2 + 2? Provide a simple answer.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
answer: { type: "number", description: "The numerical answer" },
|
||||||
|
explanation: { type: "string", description: "Brief explanation" },
|
||||||
|
},
|
||||||
|
required: ["answer"],
|
||||||
|
},
|
||||||
|
retryCount: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify structured output was captured (only on assistant messages)
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
if (result.info.role === "assistant") {
|
||||||
|
expect(result.info.structured).toBeDefined()
|
||||||
|
expect(typeof result.info.structured).toBe("object")
|
||||||
|
|
||||||
|
const output = result.info.structured as any
|
||||||
|
expect(output.answer).toBe(4)
|
||||||
|
|
||||||
|
// Verify no error was set
|
||||||
|
expect(result.info.error).toBeUndefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||||
|
})
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
|
||||||
|
test.skipIf(!hasApiKey)(
|
||||||
|
"produces structured output with nested objects",
|
||||||
|
async () => {
|
||||||
|
await withInstance(async () => {
|
||||||
|
const session = await Session.create({ title: "Nested Schema Test" })
|
||||||
|
|
||||||
|
const result = await SessionPrompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Tell me about Anthropic company in a structured format.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
company: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
founded: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["name", "founded"],
|
||||||
|
},
|
||||||
|
products: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["company"],
|
||||||
|
},
|
||||||
|
retryCount: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify structured output was captured (only on assistant messages)
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
if (result.info.role === "assistant") {
|
||||||
|
expect(result.info.structured).toBeDefined()
|
||||||
|
const output = result.info.structured as any
|
||||||
|
|
||||||
|
expect(output.company).toBeDefined()
|
||||||
|
expect(output.company.name).toBe("Anthropic")
|
||||||
|
expect(typeof output.company.founded).toBe("number")
|
||||||
|
|
||||||
|
if (output.products) {
|
||||||
|
expect(Array.isArray(output.products)).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no error was set
|
||||||
|
expect(result.info.error).toBeUndefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||||
|
})
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
|
||||||
|
test.skipIf(!hasApiKey)(
|
||||||
|
"works with text outputFormat (default)",
|
||||||
|
async () => {
|
||||||
|
await withInstance(async () => {
|
||||||
|
const session = await Session.create({ title: "Text Output Test" })
|
||||||
|
|
||||||
|
const result = await SessionPrompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Say hello.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: {
|
||||||
|
type: "text",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Verify no structured output (text mode) and no error
|
||||||
|
expect(result.info.role).toBe("assistant")
|
||||||
|
if (result.info.role === "assistant") {
|
||||||
|
expect(result.info.structured).toBeUndefined()
|
||||||
|
expect(result.info.error).toBeUndefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we got a response with parts
|
||||||
|
expect(result.parts.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||||
|
})
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
|
||||||
|
test.skipIf(!hasApiKey)(
|
||||||
|
"stores outputFormat on user message",
|
||||||
|
async () => {
|
||||||
|
await withInstance(async () => {
|
||||||
|
const session = await Session.create({ title: "OutputFormat Storage Test" })
|
||||||
|
|
||||||
|
await SessionPrompt.prompt({
|
||||||
|
sessionID: session.id,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "What is 1 + 1?",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
format: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
result: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["result"],
|
||||||
|
},
|
||||||
|
retryCount: 3,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get all messages from session
|
||||||
|
const messages = await Session.messages({ sessionID: session.id })
|
||||||
|
const userMessage = messages.find((m) => m.info.role === "user")
|
||||||
|
|
||||||
|
// Verify outputFormat was stored on user message
|
||||||
|
expect(userMessage).toBeDefined()
|
||||||
|
if (userMessage?.info.role === "user") {
|
||||||
|
expect(userMessage.info.format).toBeDefined()
|
||||||
|
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||||
|
if (userMessage.info.format?.type === "json_schema") {
|
||||||
|
expect(userMessage.info.format.retryCount).toBe(3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||||
|
})
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
|
||||||
|
test("unit test: StructuredOutputError is properly structured", () => {
|
||||||
|
const error = new MessageV2.StructuredOutputError({
|
||||||
|
message: "Failed to produce valid structured output after 3 attempts",
|
||||||
|
retries: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(error.name).toBe("StructuredOutputError")
|
||||||
|
expect(error.data.message).toContain("3 attempts")
|
||||||
|
expect(error.data.retries).toBe(3)
|
||||||
|
|
||||||
|
const obj = error.toObject()
|
||||||
|
expect(obj.name).toBe("StructuredOutputError")
|
||||||
|
expect(obj.data.retries).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,385 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
|
|
||||||
|
describe("structured-output.OutputFormat", () => {
|
||||||
|
test("parses text format", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({ type: "text" })
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.type).toBe("text")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses json_schema format with defaults", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({
|
||||||
|
type: "json_schema",
|
||||||
|
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.type).toBe("json_schema")
|
||||||
|
if (result.data.type === "json_schema") {
|
||||||
|
expect(result.data.retryCount).toBe(2) // default value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses json_schema format with custom retryCount", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({
|
||||||
|
type: "json_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
retryCount: 5,
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success && result.data.type === "json_schema") {
|
||||||
|
expect(result.data.retryCount).toBe(5)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid type", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({ type: "invalid" })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects json_schema without schema", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({ type: "json_schema" })
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects negative retryCount", () => {
|
||||||
|
const result = MessageV2.Format.safeParse({
|
||||||
|
type: "json_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
retryCount: -1,
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("structured-output.StructuredOutputError", () => {
|
||||||
|
test("creates error with message and retries", () => {
|
||||||
|
const error = new MessageV2.StructuredOutputError({
|
||||||
|
message: "Failed to validate",
|
||||||
|
retries: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(error.name).toBe("StructuredOutputError")
|
||||||
|
expect(error.data.message).toBe("Failed to validate")
|
||||||
|
expect(error.data.retries).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("converts to object correctly", () => {
|
||||||
|
const error = new MessageV2.StructuredOutputError({
|
||||||
|
message: "Test error",
|
||||||
|
retries: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
const obj = error.toObject()
|
||||||
|
expect(obj.name).toBe("StructuredOutputError")
|
||||||
|
expect(obj.data.message).toBe("Test error")
|
||||||
|
expect(obj.data.retries).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("isInstance correctly identifies error", () => {
|
||||||
|
const error = new MessageV2.StructuredOutputError({
|
||||||
|
message: "Test",
|
||||||
|
retries: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(MessageV2.StructuredOutputError.isInstance(error)).toBe(true)
|
||||||
|
expect(MessageV2.StructuredOutputError.isInstance({ name: "other" })).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("structured-output.UserMessage", () => {
|
||||||
|
test("user message accepts outputFormat", () => {
|
||||||
|
const result = MessageV2.User.safeParse({
|
||||||
|
id: "test-id",
|
||||||
|
sessionID: "test-session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "default",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-3" },
|
||||||
|
outputFormat: {
|
||||||
|
type: "json_schema",
|
||||||
|
schema: { type: "object" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("user message works without outputFormat (optional)", () => {
|
||||||
|
const result = MessageV2.User.safeParse({
|
||||||
|
id: "test-id",
|
||||||
|
sessionID: "test-session",
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "default",
|
||||||
|
model: { providerID: "anthropic", modelID: "claude-3" },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("structured-output.AssistantMessage", () => {
|
||||||
|
const baseAssistantMessage = {
|
||||||
|
id: "test-id",
|
||||||
|
sessionID: "test-session",
|
||||||
|
role: "assistant" as const,
|
||||||
|
parentID: "parent-id",
|
||||||
|
modelID: "claude-3",
|
||||||
|
providerID: "anthropic",
|
||||||
|
mode: "default",
|
||||||
|
agent: "default",
|
||||||
|
path: { cwd: "/test", root: "/test" },
|
||||||
|
cost: 0.001,
|
||||||
|
tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
time: { created: Date.now() },
|
||||||
|
}
|
||||||
|
|
||||||
|
test("assistant message accepts structured", () => {
|
||||||
|
const result = MessageV2.Assistant.safeParse({
|
||||||
|
...baseAssistantMessage,
|
||||||
|
structured: { company: "Anthropic", founded: 2021 },
|
||||||
|
})
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.data.structured).toEqual({ company: "Anthropic", founded: 2021 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("assistant message works without structured_output (optional)", () => {
|
||||||
|
const result = MessageV2.Assistant.safeParse(baseAssistantMessage)
|
||||||
|
expect(result.success).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("structured-output.createStructuredOutputTool", () => {
|
||||||
|
test("creates tool with correct id", () => {
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI SDK tool type doesn't expose id, but we set it internally
|
||||||
|
expect((tool as any).id).toBe("StructuredOutput")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates tool with description", () => {
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: { type: "object" },
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tool.description).toContain("structured format")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates tool with schema as inputSchema", () => {
|
||||||
|
const schema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
company: { type: "string" },
|
||||||
|
founded: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["company"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema,
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI SDK wraps schema in { jsonSchema: {...} }
|
||||||
|
expect(tool.inputSchema).toBeDefined()
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.company).toBeDefined()
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.founded).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips $schema property from inputSchema", () => {
|
||||||
|
const schema = {
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
type: "object",
|
||||||
|
properties: { name: { type: "string" } },
|
||||||
|
}
|
||||||
|
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema,
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// AI SDK wraps schema in { jsonSchema: {...} }
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.$schema).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("execute calls onSuccess with valid args", async () => {
|
||||||
|
let capturedOutput: unknown
|
||||||
|
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||||
|
onSuccess: (output) => {
|
||||||
|
capturedOutput = output
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tool.execute).toBeDefined()
|
||||||
|
const testArgs = { name: "Test Company" }
|
||||||
|
const result = await tool.execute!(testArgs, {
|
||||||
|
toolCallId: "test-call-id",
|
||||||
|
messages: [],
|
||||||
|
abortSignal: undefined as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(capturedOutput).toEqual(testArgs)
|
||||||
|
expect(result.output).toBe("Structured output captured successfully.")
|
||||||
|
expect(result.metadata.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("AI SDK validates schema before execute - missing required field", async () => {
|
||||||
|
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
|
||||||
|
// So invalid inputs never reach the tool's execute function
|
||||||
|
// This test documents the expected schema behavior
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
age: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["name", "age"],
|
||||||
|
},
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The schema requires both 'name' and 'age'
|
||||||
|
expect(tool.inputSchema).toBeDefined()
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.required).toContain("name")
|
||||||
|
expect(inputSchema.jsonSchema?.required).toContain("age")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("AI SDK validates schema types before execute - wrong type", async () => {
|
||||||
|
// Note: The AI SDK validates the input against the schema BEFORE calling execute()
|
||||||
|
// So invalid inputs never reach the tool's execute function
|
||||||
|
// This test documents the expected schema behavior
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
count: { type: "number" },
|
||||||
|
},
|
||||||
|
required: ["count"],
|
||||||
|
},
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// The schema defines 'count' as a number
|
||||||
|
expect(tool.inputSchema).toBeDefined()
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.count?.type).toBe("number")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("execute handles nested objects", async () => {
|
||||||
|
let capturedOutput: unknown
|
||||||
|
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string" },
|
||||||
|
email: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["user"],
|
||||||
|
},
|
||||||
|
onSuccess: (output) => {
|
||||||
|
capturedOutput = output
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Valid nested object - AI SDK validates before calling execute()
|
||||||
|
const validResult = await tool.execute!(
|
||||||
|
{ user: { name: "John", email: "john@test.com" } },
|
||||||
|
{
|
||||||
|
toolCallId: "test-call-id",
|
||||||
|
messages: [],
|
||||||
|
abortSignal: undefined as any,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(capturedOutput).toEqual({ user: { name: "John", email: "john@test.com" } })
|
||||||
|
expect(validResult.metadata.valid).toBe(true)
|
||||||
|
|
||||||
|
// Verify schema has correct nested structure
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.user?.type).toBe("object")
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.user?.properties?.name?.type).toBe("string")
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.user?.required).toContain("name")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("execute handles arrays", async () => {
|
||||||
|
let capturedOutput: unknown
|
||||||
|
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tags: {
|
||||||
|
type: "array",
|
||||||
|
items: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["tags"],
|
||||||
|
},
|
||||||
|
onSuccess: (output) => {
|
||||||
|
capturedOutput = output
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Valid array - AI SDK validates before calling execute()
|
||||||
|
const validResult = await tool.execute!(
|
||||||
|
{ tags: ["a", "b", "c"] },
|
||||||
|
{
|
||||||
|
toolCallId: "test-call-id",
|
||||||
|
messages: [],
|
||||||
|
abortSignal: undefined as any,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(capturedOutput).toEqual({ tags: ["a", "b", "c"] })
|
||||||
|
expect(validResult.metadata.valid).toBe(true)
|
||||||
|
|
||||||
|
// Verify schema has correct array structure
|
||||||
|
const inputSchema = tool.inputSchema as any
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.tags?.type).toBe("array")
|
||||||
|
expect(inputSchema.jsonSchema?.properties?.tags?.items?.type).toBe("string")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("toModelOutput returns text value", () => {
|
||||||
|
const tool = SessionPrompt.createStructuredOutputTool({
|
||||||
|
schema: { type: "object" },
|
||||||
|
onSuccess: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(tool.toModelOutput).toBeDefined()
|
||||||
|
const modelOutput = tool.toModelOutput!({
|
||||||
|
output: "Test output",
|
||||||
|
title: "Test",
|
||||||
|
metadata: { valid: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(modelOutput.type).toBe("text")
|
||||||
|
expect(modelOutput.value).toBe("Test output")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Note: Retry behavior is handled by the AI SDK and the prompt loop, not the tool itself
|
||||||
|
// The tool simply calls onSuccess when execute() is called with valid args
|
||||||
|
// See prompt.ts loop() for actual retry logic
|
||||||
|
})
|
||||||
|
|
@ -57,6 +57,7 @@ import type {
|
||||||
McpLocalConfig,
|
McpLocalConfig,
|
||||||
McpRemoteConfig,
|
McpRemoteConfig,
|
||||||
McpStatusResponses,
|
McpStatusResponses,
|
||||||
|
OutputFormat,
|
||||||
Part as Part2,
|
Part as Part2,
|
||||||
PartDeleteErrors,
|
PartDeleteErrors,
|
||||||
PartDeleteResponses,
|
PartDeleteResponses,
|
||||||
|
|
@ -1473,6 +1474,7 @@ export class Session extends HeyApiClient {
|
||||||
tools?: {
|
tools?: {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
format?: OutputFormat
|
||||||
system?: string
|
system?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||||
|
|
@ -1491,6 +1493,7 @@ export class Session extends HeyApiClient {
|
||||||
{ in: "body", key: "agent" },
|
{ in: "body", key: "agent" },
|
||||||
{ in: "body", key: "noReply" },
|
{ in: "body", key: "noReply" },
|
||||||
{ in: "body", key: "tools" },
|
{ in: "body", key: "tools" },
|
||||||
|
{ in: "body", key: "format" },
|
||||||
{ in: "body", key: "system" },
|
{ in: "body", key: "system" },
|
||||||
{ in: "body", key: "variant" },
|
{ in: "body", key: "variant" },
|
||||||
{ in: "body", key: "parts" },
|
{ in: "body", key: "parts" },
|
||||||
|
|
@ -1561,6 +1564,7 @@ export class Session extends HeyApiClient {
|
||||||
tools?: {
|
tools?: {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
format?: OutputFormat
|
||||||
system?: string
|
system?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
parts?: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||||
|
|
@ -1579,6 +1583,7 @@ export class Session extends HeyApiClient {
|
||||||
{ in: "body", key: "agent" },
|
{ in: "body", key: "agent" },
|
||||||
{ in: "body", key: "noReply" },
|
{ in: "body", key: "noReply" },
|
||||||
{ in: "body", key: "tools" },
|
{ in: "body", key: "tools" },
|
||||||
|
{ in: "body", key: "format" },
|
||||||
{ in: "body", key: "system" },
|
{ in: "body", key: "system" },
|
||||||
{ in: "body", key: "variant" },
|
{ in: "body", key: "variant" },
|
||||||
{ in: "body", key: "parts" },
|
{ in: "body", key: "parts" },
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,22 @@ export type EventFileEdited = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type OutputFormatText = {
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JsonSchema = {
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutputFormatJsonSchema = {
|
||||||
|
type: "json_schema"
|
||||||
|
schema: JsonSchema
|
||||||
|
retryCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OutputFormat = OutputFormatText | OutputFormatJsonSchema
|
||||||
|
|
||||||
export type FileDiff = {
|
export type FileDiff = {
|
||||||
file: string
|
file: string
|
||||||
before: string
|
before: string
|
||||||
|
|
@ -106,6 +122,7 @@ export type UserMessage = {
|
||||||
time: {
|
time: {
|
||||||
created: number
|
created: number
|
||||||
}
|
}
|
||||||
|
format?: OutputFormat
|
||||||
summary?: {
|
summary?: {
|
||||||
title?: string
|
title?: string
|
||||||
body?: string
|
body?: string
|
||||||
|
|
@ -152,6 +169,14 @@ export type MessageAbortedError = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StructuredOutputError = {
|
||||||
|
name: "StructuredOutputError"
|
||||||
|
data: {
|
||||||
|
message: string
|
||||||
|
retries: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type ContextOverflowError = {
|
export type ContextOverflowError = {
|
||||||
name: "ContextOverflowError"
|
name: "ContextOverflowError"
|
||||||
data: {
|
data: {
|
||||||
|
|
@ -189,6 +214,7 @@ export type AssistantMessage = {
|
||||||
| UnknownError
|
| UnknownError
|
||||||
| MessageOutputLengthError
|
| MessageOutputLengthError
|
||||||
| MessageAbortedError
|
| MessageAbortedError
|
||||||
|
| StructuredOutputError
|
||||||
| ContextOverflowError
|
| ContextOverflowError
|
||||||
| ApiError
|
| ApiError
|
||||||
parentID: string
|
parentID: string
|
||||||
|
|
@ -212,6 +238,7 @@ export type AssistantMessage = {
|
||||||
write: number
|
write: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
structured?: unknown
|
||||||
variant?: string
|
variant?: string
|
||||||
finish?: string
|
finish?: string
|
||||||
}
|
}
|
||||||
|
|
@ -841,6 +868,7 @@ export type EventSessionError = {
|
||||||
| UnknownError
|
| UnknownError
|
||||||
| MessageOutputLengthError
|
| MessageOutputLengthError
|
||||||
| MessageAbortedError
|
| MessageAbortedError
|
||||||
|
| StructuredOutputError
|
||||||
| ContextOverflowError
|
| ContextOverflowError
|
||||||
| ApiError
|
| ApiError
|
||||||
}
|
}
|
||||||
|
|
@ -1521,6 +1549,7 @@ export type ProviderConfig = {
|
||||||
}
|
}
|
||||||
provider?: {
|
provider?: {
|
||||||
npm: string
|
npm: string
|
||||||
|
api: string
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Variant-specific configuration
|
* Variant-specific configuration
|
||||||
|
|
@ -3402,6 +3431,7 @@ export type SessionPromptData = {
|
||||||
tools?: {
|
tools?: {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
format?: OutputFormat
|
||||||
system?: string
|
system?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||||
|
|
@ -3589,6 +3619,7 @@ export type SessionPromptAsyncData = {
|
||||||
tools?: {
|
tools?: {
|
||||||
[key: string]: boolean
|
[key: string]: boolean
|
||||||
}
|
}
|
||||||
|
format?: OutputFormat
|
||||||
system?: string
|
system?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
parts: Array<TextPartInput | FilePartInput | AgentPartInput | SubtaskPartInput>
|
||||||
|
|
@ -4038,6 +4069,7 @@ export type ProviderListResponses = {
|
||||||
}
|
}
|
||||||
provider?: {
|
provider?: {
|
||||||
npm: string
|
npm: string
|
||||||
|
api: string
|
||||||
}
|
}
|
||||||
variants?: {
|
variants?: {
|
||||||
[key: string]: {
|
[key: string]: {
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,78 @@ try {
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Structured Output
|
||||||
|
|
||||||
|
You can request structured JSON output from the model by specifying an `outputFormat` with a JSON schema. The model will use a `StructuredOutput` tool to return validated JSON matching your schema.
|
||||||
|
|
||||||
|
### Basic Usage
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await client.session.prompt({
|
||||||
|
path: { id: sessionId },
|
||||||
|
body: {
|
||||||
|
parts: [{ type: 'text', text: 'Research Anthropic and provide company info' }],
|
||||||
|
outputFormat: {
|
||||||
|
type: 'json_schema',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
company: { type: 'string', description: 'Company name' },
|
||||||
|
founded: { type: 'number', description: 'Year founded' },
|
||||||
|
products: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string' },
|
||||||
|
description: 'Main products'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ['company', 'founded']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Access the structured output
|
||||||
|
console.log(result.data.info.structured_output)
|
||||||
|
// { company: "Anthropic", founded: 2021, products: ["Claude", "Claude API"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Output Format Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `text` | Default. Standard text response (no structured output) |
|
||||||
|
| `json_schema` | Returns validated JSON matching the provided schema |
|
||||||
|
|
||||||
|
### JSON Schema Format
|
||||||
|
|
||||||
|
When using `type: 'json_schema'`, provide:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `type` | `'json_schema'` | Required. Specifies JSON schema mode |
|
||||||
|
| `schema` | `object` | Required. JSON Schema object defining the output structure |
|
||||||
|
| `retryCount` | `number` | Optional. Number of validation retries (default: 2) |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
If the model fails to produce valid structured output after all retries, the response will include a `StructuredOutputError`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (result.data.info.error?.name === 'StructuredOutputError') {
|
||||||
|
console.error('Failed to produce structured output:', result.data.info.error.message)
|
||||||
|
console.error('Attempts:', result.data.info.error.retries)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Best Practices
|
||||||
|
|
||||||
|
1. **Provide clear descriptions** in your schema properties to help the model understand what data to extract
|
||||||
|
2. **Use `required`** to specify which fields must be present
|
||||||
|
3. **Keep schemas focused** - complex nested schemas may be harder for the model to fill correctly
|
||||||
|
4. **Set appropriate `retryCount`** - increase for complex schemas, decrease for simple ones
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## APIs
|
## APIs
|
||||||
|
|
||||||
The SDK exposes all server APIs through a type-safe client.
|
The SDK exposes all server APIs through a type-safe client.
|
||||||
|
|
@ -241,7 +313,7 @@ const { providers, default: defaults } = await client.config.providers()
|
||||||
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
|
| `session.summarize({ path, body })` | Summarize session | Returns `boolean` |
|
||||||
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
|
| `session.messages({ path })` | List messages in a session | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}[]` |
|
||||||
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
| `session.message({ path })` | Get message details | Returns `{ info: `<a href={typesUrl}><code>Message</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||||
| `session.prompt({ path, body })` | Send prompt message | `body.noReply: true` returns UserMessage (context only). Default returns <a href={typesUrl}><code>AssistantMessage</code></a> with AI response |
|
| `session.prompt({ path, body })` | Send prompt message | `body.noReply: true` returns UserMessage (context only). Default returns <a href={typesUrl}><code>AssistantMessage</code></a> with AI response. Supports `body.outputFormat` for [structured output](#structured-output) |
|
||||||
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
| `session.command({ path, body })` | Send command to session | Returns `{ info: `<a href={typesUrl}><code>AssistantMessage</code></a>`, parts: `<a href={typesUrl}><code>Part[]</code></a>`}` |
|
||||||
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
|
| `session.shell({ path, body })` | Run a shell command | Returns <a href={typesUrl}><code>AssistantMessage</code></a> |
|
||||||
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
|
| `session.revert({ path, body })` | Revert a message | Returns <a href={typesUrl}><code>Session</code></a> |
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue