@tduyng/pi-subagent 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,268 @@
1
+ import { existsSync } from 'node:fs'
2
+
3
+ import type { ExtensionAPI, ExtensionContext, AgentSession, ModelRuntime } from '@earendil-works/pi-coding-agent'
4
+
5
+ import { createChildModelRuntime } from '../lib/child-runtime.js'
6
+ import { loadAgents } from './agent.js'
7
+ import { drivePrompt } from './drive.js'
8
+ import {
9
+ claimRun,
10
+ CONTINUE_PROMPT,
11
+ deliveredKeys,
12
+ detailsFromRecord,
13
+ listRuns,
14
+ pendingTasks,
15
+ releaseClaim,
16
+ resolveSessionFile,
17
+ runUpdatedAt,
18
+ saveRun,
19
+ syncRecord,
20
+ type DurableRun,
21
+ type DurableTask
22
+ } from './durable.js'
23
+ import { findLastAssistant, resultText } from './format.js'
24
+ import {
25
+ acquirePermit,
26
+ failureReason,
27
+ releasePermit,
28
+ resolveMaxTurns,
29
+ resolveTimeoutMs,
30
+ salvageFromMessages,
31
+ salvageOutput
32
+ } from './helpers.js'
33
+ import { createChildSession } from './session.js'
34
+ import { liveSessions, registry } from './state.js'
35
+ import { THINKING_LEVELS, type RunDetails, type TaskDetails, type ThinkingLevel } from './types.js'
36
+
37
+ const RECOVERY_WINDOW_MS = 10 * 60_000
38
+ const INTERRUPTED_ERROR = 'Pi exited before this child returned. Resume it with subagent({ resume: "<id>" }).'
39
+
40
+ export interface ResumeTaskInput {
41
+ pi: ExtensionAPI
42
+ ctx: ExtensionContext
43
+ run: DurableRun
44
+ record: DurableTask
45
+ task: TaskDetails
46
+ signal?: AbortSignal
47
+ followUp?: string
48
+ maxTurns?: number
49
+ timeoutMs?: number
50
+ modelRuntime?: ModelRuntime
51
+ onEmit: (force?: boolean) => void
52
+ }
53
+
54
+ function thinkingLevel(value: string): ThinkingLevel {
55
+ return THINKING_LEVELS.find((level) => level === value) ?? 'medium'
56
+ }
57
+
58
+ async function openSession(input: ResumeTaskInput): Promise<AgentSession> {
59
+ const live = liveSessions.get(input.task.id)
60
+ if (live) return live
61
+ const { record, ctx } = input
62
+ if (record.worktree && !existsSync(record.worktree)) {
63
+ throw new Error(`The worktree for "${record.label}" is gone; start the task again.`)
64
+ }
65
+ const sessionFile = resolveSessionFile(record)
66
+ if (!sessionFile) throw new Error(`No saved child session for "${record.label}"; start the task again.`)
67
+ const model = ctx.modelRegistry.find(record.provider, record.model) ?? ctx.model
68
+ if (!model) throw new Error(`Model ${record.provider}/${record.model} is no longer available.`)
69
+ const modelRuntime = input.modelRuntime ?? (await createChildModelRuntime(ctx))
70
+ const agent = record.agent ? loadAgents(ctx.cwd).get(record.agent) : undefined
71
+ return createChildSession({
72
+ cwd: record.cwd,
73
+ sessionDir: record.sessionDir,
74
+ model,
75
+ modelRuntime,
76
+ thinking: thinkingLevel(record.thinking),
77
+ parentSystemPrompt: ctx.getSystemPrompt(),
78
+ allTools: input.pi.getAllTools().map((tool) => tool.name),
79
+ agent,
80
+ schema: record.schema,
81
+ sessionFile,
82
+ onStructured: (value) => {
83
+ input.task.structured = value
84
+ }
85
+ })
86
+ }
87
+
88
+ export async function resumeTask(input: ResumeTaskInput): Promise<void> {
89
+ const { record, task, run } = input
90
+ let acquired = false
91
+ try {
92
+ await acquirePermit(input.signal)
93
+ acquired = true
94
+ const session = await openSession(input)
95
+ liveSessions.set(task.id, session)
96
+ task.status = 'running'
97
+ task.startedAt ??= Date.now()
98
+ task.error = undefined
99
+ task.partial = undefined
100
+ record.status = 'running'
101
+ input.onEmit(true)
102
+ const maxTurns = resolveMaxTurns(input.maxTurns, record.maxTurns)
103
+ const timeoutMs = resolveTimeoutMs(input.timeoutMs) ?? record.timeoutMs
104
+ const resumed = await drivePrompt({
105
+ session,
106
+ prompt: input.followUp ?? CONTINUE_PROMPT,
107
+ maxTurns,
108
+ schema: record.schema,
109
+ cwd: record.cwd,
110
+ timeoutMs,
111
+ abortSignal: input.signal,
112
+ onEmit: input.onEmit,
113
+ detail: task
114
+ })
115
+ const assistant = resumed.assistant ?? findLastAssistant(session.messages)
116
+ if (resumed.output) task.output = resumed.output
117
+ if (resumed.touched.length > 0) {
118
+ task.touched = [...new Set([...(task.touched ?? []), ...resumed.touched])].slice(0, 12)
119
+ }
120
+ task.sessionFile = session.sessionFile ?? task.sessionFile
121
+ task.finishedAt = Date.now()
122
+ if (!task.output || resumed.timedOut) {
123
+ const salvaged = salvageOutput(assistant) ?? salvageFromMessages(session.messages)
124
+ if (salvaged && !task.output) {
125
+ task.output = salvaged
126
+ task.partial = true
127
+ }
128
+ }
129
+ if (resumed.schemaFallback) task.hasSchema = false
130
+ if (task.hasSchema && task.structured === undefined) task.partial = true
131
+ const reason = failureReason({
132
+ hasAssistant: assistant !== undefined,
133
+ timedOut: resumed.timedOut,
134
+ duration: undefined,
135
+ hitTurnLimit: resumed.turnCount >= maxTurns,
136
+ maxTurns,
137
+ aborted: input.signal?.aborted === true,
138
+ hasSchema: task.hasSchema ?? false,
139
+ hasStructured: task.structured !== undefined,
140
+ output: task.output ?? '',
141
+ stopReason: assistant?.stopReason,
142
+ errorMessage: assistant?.errorMessage
143
+ })
144
+ if (reason) {
145
+ task.status = 'failed'
146
+ task.error = reason
147
+ } else {
148
+ task.status = 'completed'
149
+ }
150
+ } catch (error) {
151
+ task.status = 'failed'
152
+ task.error = error instanceof Error ? error.message : String(error)
153
+ task.finishedAt ??= Date.now()
154
+ } finally {
155
+ if (task.status === 'running') {
156
+ task.status = 'failed'
157
+ task.error ??= 'Subagent aborted.'
158
+ task.finishedAt ??= Date.now()
159
+ }
160
+ syncRecord(record, task)
161
+ saveRun(run)
162
+ if (acquired) releasePermit()
163
+ input.onEmit(true)
164
+ }
165
+ }
166
+
167
+ export interface RecoverRunsInput {
168
+ pi: ExtensionAPI
169
+ ctx: ExtensionContext
170
+ onEmit: (force?: boolean) => void
171
+ }
172
+
173
+ export async function recoverRuns(input: RecoverRunsInput): Promise<void> {
174
+ const sessionId = input.ctx.sessionManager.getSessionId()
175
+ const delivered = deliveredKeys(input.ctx.sessionManager)
176
+ for (const run of listRuns(sessionId)) {
177
+ if (delivered.has(run.runId)) continue
178
+ if (run.toolCallId !== undefined && delivered.has(run.toolCallId)) continue
179
+ const pending = pendingTasks(run)
180
+ if (pending.length === 0) {
181
+ run.finishedAt ??= Date.now()
182
+ saveRun(run)
183
+ await postResult(input.pi, run, 'This run finished after the session had already closed.')
184
+ continue
185
+ }
186
+ if (!claimRun(sessionId, run.runId)) continue
187
+ try {
188
+ const tasks = run.tasks.map(detailsFromRecord)
189
+ for (const task of tasks) {
190
+ if (!registry.some((known) => known.id === task.id)) registry.push(task)
191
+ }
192
+ input.onEmit(true)
193
+ const updatedAt = runUpdatedAt(sessionId, run.runId) ?? run.startedAt
194
+ const resumable =
195
+ Date.now() - updatedAt <= RECOVERY_WINDOW_MS &&
196
+ pending.some((record) => resolveSessionFile(record) !== undefined)
197
+ if (!resumable) {
198
+ markInterrupted(run, tasks)
199
+ await postResult(
200
+ input.pi,
201
+ run,
202
+ 'Pi exited before this run returned. Failed children keep their saved sessions and can be resumed by id.'
203
+ )
204
+ continue
205
+ }
206
+ const modelRuntime = await createChildModelRuntime(input.ctx)
207
+ await Promise.allSettled(
208
+ run.tasks.map((record, index) => {
209
+ const task = tasks[index]
210
+ if (!task || (record.status !== 'queued' && record.status !== 'running')) return undefined
211
+ return resumeTask({
212
+ pi: input.pi,
213
+ ctx: input.ctx,
214
+ run,
215
+ record,
216
+ task,
217
+ modelRuntime,
218
+ onEmit: input.onEmit
219
+ })
220
+ })
221
+ )
222
+ run.finishedAt = Date.now()
223
+ saveRun(run)
224
+ await postResult(
225
+ input.pi,
226
+ run,
227
+ 'Recovered after Pi exited: interrupted children resumed from their saved sessions.'
228
+ )
229
+ } finally {
230
+ releaseClaim(sessionId, run.runId)
231
+ }
232
+ }
233
+ }
234
+
235
+ function markInterrupted(run: DurableRun, tasks: TaskDetails[]): void {
236
+ for (const task of tasks) {
237
+ if (task.status !== 'queued' && task.status !== 'running') continue
238
+ task.status = 'failed'
239
+ task.error = INTERRUPTED_ERROR
240
+ task.finishedAt = Date.now()
241
+ }
242
+ for (const [index, record] of run.tasks.entries()) {
243
+ const task = tasks[index]
244
+ if (task) syncRecord(record, task)
245
+ }
246
+ run.finishedAt = Date.now()
247
+ saveRun(run)
248
+ }
249
+
250
+ async function postResult(pi: ExtensionAPI, run: DurableRun, note: string): Promise<void> {
251
+ const tasks = run.tasks.map(detailsFromRecord)
252
+ const details = {
253
+ status: tasks.every((task) => task.status === 'completed') ? 'completed' : 'failed',
254
+ runId: run.runId,
255
+ startedAt: run.startedAt,
256
+ finishedAt: run.finishedAt,
257
+ tasks
258
+ } satisfies RunDetails
259
+ await pi.sendMessage(
260
+ {
261
+ customType: 'subagent-durable',
262
+ content: [{ type: 'text', text: `${note}\n\n${resultText(details)}` }],
263
+ display: true,
264
+ details: { runId: run.runId }
265
+ },
266
+ { triggerTurn: false }
267
+ )
268
+ }
@@ -0,0 +1,87 @@
1
+ import type { Model } from '@earendil-works/pi-ai'
2
+ import {
3
+ createAgentSession,
4
+ DefaultResourceLoader,
5
+ getAgentDir,
6
+ ModelRuntime,
7
+ SessionManager,
8
+ type AgentSession
9
+ } from '@earendil-works/pi-coding-agent'
10
+
11
+ import cloak from '../../../pi-guard/src/cloak.js'
12
+ import gitInterceptor from '../../../pi-guard/src/git-interceptor.js'
13
+ import { childPromptExtras } from './helpers.js'
14
+ import { makeStructuredOutputTool, structuredOutputInstruction, type JsonPayload } from './structured.js'
15
+ import type { AgentProfile, ThinkingLevel } from './types.js'
16
+
17
+ export interface ChildSessionInput {
18
+ cwd: string
19
+ sessionDir: string
20
+ model: Model<any>
21
+ modelRuntime: ModelRuntime
22
+ thinking: ThinkingLevel
23
+ parentSystemPrompt: string
24
+ allTools: string[]
25
+ agent?: AgentProfile
26
+ schema?: JsonPayload
27
+ sessionFile?: string
28
+ onStructured?: (value: JsonPayload) => void
29
+ }
30
+
31
+ export async function createChildSession(input: ChildSessionInput): Promise<AgentSession> {
32
+ const { cwd, sessionDir, model, modelRuntime, thinking, parentSystemPrompt, allTools, agent, schema, sessionFile } =
33
+ input
34
+ const loaderOptions: ConstructorParameters<typeof DefaultResourceLoader>[0] = {
35
+ cwd,
36
+ agentDir: getAgentDir(),
37
+ noExtensions: true,
38
+ extensionFactories: [cloak, gitInterceptor],
39
+ noThemes: true,
40
+ noPromptTemplates: true,
41
+ noSkills: Boolean(agent && !agent.inheritSkills),
42
+ noContextFiles: Boolean(agent && !agent.inheritProjectContext)
43
+ }
44
+ const extras = childPromptExtras(model, parentSystemPrompt, structuredOutputInstruction(schema))
45
+ if (agent?.systemPrompt && agent.systemPromptMode === 'replace') {
46
+ loaderOptions['systemPromptOverride'] = () => agent.systemPrompt
47
+ loaderOptions['appendSystemPromptOverride'] = () => extras
48
+ } else {
49
+ loaderOptions['appendSystemPromptOverride'] = (base: string[]) => [
50
+ ...base,
51
+ ...(agent?.systemPrompt ? [agent.systemPrompt] : []),
52
+ ...extras
53
+ ]
54
+ }
55
+ const loader = new DefaultResourceLoader(loaderOptions)
56
+ await loader.reload()
57
+
58
+ const childTools = agent?.tools.length
59
+ ? agent.disallowedTools.length
60
+ ? agent.tools.filter((tool) => !agent.disallowedTools.includes(tool))
61
+ : agent.tools
62
+ : agent?.disallowedTools.length
63
+ ? allTools.filter((tool) => !agent.disallowedTools.includes(tool))
64
+ : undefined
65
+ const childToolList = schema !== undefined && childTools ? [...childTools, 'structured_output'] : childTools
66
+
67
+ const sessionOptions: Parameters<typeof createAgentSession>[0] = {
68
+ cwd,
69
+ modelRuntime,
70
+ model,
71
+ thinkingLevel: thinking,
72
+ resourceLoader: loader,
73
+ sessionManager: sessionFile
74
+ ? SessionManager.open(sessionFile, sessionDir, cwd)
75
+ : SessionManager.create(cwd, sessionDir),
76
+ tools: childToolList
77
+ }
78
+ if (schema !== undefined) {
79
+ sessionOptions.customTools = [
80
+ makeStructuredOutputTool(schema, (value) => {
81
+ input.onStructured?.(value)
82
+ })
83
+ ]
84
+ }
85
+ const { session } = await createAgentSession(sessionOptions)
86
+ return session
87
+ }
@@ -0,0 +1,59 @@
1
+ import type { AgentSession } from '@earendil-works/pi-coding-agent'
2
+
3
+ import { FAILED_RETENTION_MS, FINISHED_RETENTION_MS, MAX_REGISTRY, type TaskDetails } from './types.js'
4
+
5
+ export const registry: TaskDetails[] = []
6
+
7
+ export const liveSessions = new Map<string, AgentSession>()
8
+
9
+ function evict(index: number): void {
10
+ const task = registry[index]
11
+ if (!task) return
12
+ liveSessions.get(task.id)?.dispose()
13
+ liveSessions.delete(task.id)
14
+ registry.splice(index, 1)
15
+ }
16
+
17
+ function retentionFor(task: TaskDetails): number {
18
+ return task.status === 'failed' ? FAILED_RETENTION_MS : FINISHED_RETENTION_MS
19
+ }
20
+
21
+ export function sweepRegistry(): void {
22
+ const now = Date.now()
23
+ for (let i = registry.length - 1; i >= 0; i--) {
24
+ const task = registry[i]
25
+ if (!task) continue
26
+ const finished = task.status === 'completed' || task.status === 'failed'
27
+ if (finished && task.finishedAt !== undefined && now - task.finishedAt > retentionFor(task)) evict(i)
28
+ }
29
+ while (registry.length > MAX_REGISTRY) evict(0)
30
+ }
31
+
32
+ export function nextExpiryAt(): number | undefined {
33
+ let earliest: number | undefined
34
+ for (const task of registry) {
35
+ const finished = task.status === 'completed' || task.status === 'failed'
36
+ if (!finished || task.finishedAt === undefined) continue
37
+ const due = task.finishedAt + retentionFor(task)
38
+ if (earliest === undefined || due < earliest) earliest = due
39
+ }
40
+ return earliest
41
+ }
42
+
43
+ export function dismissTask(id: string): boolean {
44
+ const index = registry.findIndex((task) => task.id === id)
45
+ const task = registry[index]
46
+ if (!task || task.status === 'queued' || task.status === 'running') return false
47
+ evict(index)
48
+ return true
49
+ }
50
+
51
+ export function roster(): TaskDetails[] {
52
+ sweepRegistry()
53
+ return registry.toSorted((a, b) => {
54
+ const aActive = a.status === 'queued' || a.status === 'running'
55
+ const bActive = b.status === 'queued' || b.status === 'running'
56
+ if (aActive !== bActive) return aActive ? -1 : 1
57
+ return (b.startedAt ?? 0) - (a.startedAt ?? 0)
58
+ })
59
+ }
@@ -0,0 +1,202 @@
1
+ import { defineTool, type ToolDefinition } from '@earendil-works/pi-coding-agent'
2
+ import { Validator, type JSONSchema } from 'ata-validator'
3
+ import { t } from 'ata-validator/t'
4
+
5
+ const isStringArray = new Validator(t.array(t.string())).isValidObject
6
+ const isStringOrBoolean = new Validator(t.union([t.string(), t.boolean()])).isValidObject
7
+ const isNumber = new Validator(t.number()).isValidObject
8
+ const isString = new Validator(t.string()).isValidObject
9
+ const isBoolean = new Validator(t.boolean()).isValidObject
10
+
11
+ const MAX_SCHEMA_CHARS = 100_000
12
+ const SCHEMA_MAP_KEYWORDS = new Set(['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties'])
13
+ const SCHEMA_VALUE_KEYWORDS = new Set([
14
+ 'additionalItems',
15
+ 'additionalProperties',
16
+ 'contains',
17
+ 'else',
18
+ 'if',
19
+ 'items',
20
+ 'not',
21
+ 'then',
22
+ 'unevaluatedItems',
23
+ 'unevaluatedProperties'
24
+ ])
25
+
26
+ const STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION =
27
+ 'When your task is complete, call the `structured_output` tool exactly once as your final action, with fields matching the required schema. Do not write any other text after it.'
28
+
29
+ const STRUCTURED_OUTPUT_TOOL_DESCRIPTION =
30
+ 'Return your final result as structured data matching the required schema. Call this exactly once, as your last action; do not write any other text after it.'
31
+
32
+ type JsonValue = null | boolean | number | string | JsonValue[] | JsonRecord
33
+
34
+ export type JsonPayload = JsonValue | JSONSchema
35
+
36
+ interface JsonRecord {
37
+ [key: string]: JsonValue
38
+ }
39
+
40
+ const ObjectRecordSchema = t.object({}, { additionalProperties: true })
41
+ const isObjectRecordSchema = new Validator(ObjectRecordSchema).isValidObject
42
+
43
+ function isRecord(value: JsonPayload | undefined): value is JsonRecord {
44
+ if (!isObjectRecordSchema(value)) return false
45
+ const prototype = Object.getPrototypeOf(value)
46
+ return prototype === Object.prototype || prototype === null
47
+ }
48
+
49
+ export function isJsonSchema(value: unknown): value is JSONSchema {
50
+ if (!isObjectRecordSchema(value)) return false
51
+ const prototype = Object.getPrototypeOf(value)
52
+ if (prototype !== Object.prototype && prototype !== null) return false
53
+ const ancestors = new Set<object>()
54
+ let nodes = 0
55
+ const validate = (current: JsonValue, depth: number): boolean => {
56
+ if (++nodes > 10_000 || depth > 24) return false
57
+ if (current === null || isStringOrBoolean(current)) return true
58
+ if (isNumber(current)) return Number.isFinite(current)
59
+ if (Array.isArray(current)) return current.every((item) => validate(item, depth + 1))
60
+ if (!isRecord(current) || ancestors.has(current)) return false
61
+
62
+ ancestors.add(current)
63
+ const valid = Object.entries(current).every(([key, child]) => {
64
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return false
65
+ if (key === 'required' && !isStringArray(child)) return false
66
+ if (key === 'type' && !isString(child) && !isStringArray(child)) return false
67
+ if (key === 'additionalProperties' && !isBoolean(child) && !isRecord(child)) return false
68
+ if (
69
+ SCHEMA_MAP_KEYWORDS.has(key) &&
70
+ (!isRecord(child) || !Object.values(child).every((item) => validate(item, depth + 1)))
71
+ ) {
72
+ return false
73
+ }
74
+ if (SCHEMA_VALUE_KEYWORDS.has(key) && child !== undefined && !validate(child, depth + 1)) return false
75
+ return validate(child, depth + 1)
76
+ })
77
+ ancestors.delete(current)
78
+ return valid
79
+ }
80
+ return validate(value, 0)
81
+ }
82
+
83
+ function normalizeRequired(value: JsonValue | undefined): string[] | undefined {
84
+ if (isString(value)) return [value]
85
+ if (isRecord(value)) {
86
+ const item = value['item']
87
+ const nested = normalizeRequired(item)
88
+ if (nested) return nested
89
+ return Object.keys(value).length > 0 &&
90
+ Object.values(value).every((entry) => isRecord(entry) || entry === undefined)
91
+ ? Object.keys(value)
92
+ : undefined
93
+ }
94
+ if (!Array.isArray(value)) return undefined
95
+ const names: string[] = []
96
+ for (const item of value) {
97
+ if (isString(item)) {
98
+ names.push(item)
99
+ continue
100
+ }
101
+ if (!isRecord(item)) return undefined
102
+ const nested = item['item']
103
+ if (isString(nested)) names.push(nested)
104
+ else if (isStringArray(nested)) names.push(...nested)
105
+ else if (isRecord(nested) && isStringArray(nested['item'])) names.push(...nested['item'])
106
+ else return undefined
107
+ }
108
+ return names
109
+ }
110
+
111
+ function normalizeSchemaNode(value: JsonValue, stack: Set<object>): JsonValue {
112
+ if (Array.isArray(value)) return value.map((item) => normalizeSchemaNode(item, stack))
113
+ if (!isRecord(value)) return value
114
+ if (stack.has(value)) return value
115
+
116
+ stack.add(value)
117
+ const normalized: JsonRecord = { ...value }
118
+ const required = normalizeRequired(normalized['required'])
119
+ if (required) normalized['required'] = required
120
+ if (normalized['additionalProperties'] === 'false') normalized['additionalProperties'] = false
121
+ if (Array.isArray(normalized['type'])) {
122
+ const types = [...new Set(normalized['type'])]
123
+ if (types.length === 1 && types[0] !== undefined) normalized['type'] = types[0]
124
+ else if (isRecord(normalized['properties']) && types.includes('object') && normalized['items'] === undefined) {
125
+ normalized['type'] = 'object'
126
+ } else if (
127
+ normalized['items'] !== undefined &&
128
+ types.includes('array') &&
129
+ !isRecord(normalized['properties'])
130
+ ) {
131
+ normalized['type'] = 'array'
132
+ }
133
+ }
134
+ if (normalized['type'] === undefined) {
135
+ if (isRecord(normalized['properties'])) normalized['type'] = 'object'
136
+ else if (normalized['items'] !== undefined) normalized['type'] = 'array'
137
+ }
138
+
139
+ for (const key of SCHEMA_MAP_KEYWORDS) {
140
+ const child = normalized[key]
141
+ if (isRecord(child)) {
142
+ normalized[key] = Object.fromEntries(
143
+ Object.entries(child).map(([name, schema]) => [name, normalizeSchemaNode(schema, stack)])
144
+ )
145
+ }
146
+ }
147
+ for (const key of SCHEMA_VALUE_KEYWORDS) {
148
+ if (normalized[key] !== undefined) normalized[key] = normalizeSchemaNode(normalized[key], stack)
149
+ }
150
+ stack.delete(value)
151
+ return normalized
152
+ }
153
+
154
+ export function normalizeJsonSchema(value: JsonPayload | undefined): JSONSchema | undefined {
155
+ if (isString(value)) {
156
+ if (value.length > MAX_SCHEMA_CHARS) return undefined
157
+ try {
158
+ value = JSON.parse(value)
159
+ } catch {
160
+ return undefined
161
+ }
162
+ }
163
+ if (value === undefined) return undefined
164
+ if (!isRecord(value)) return undefined
165
+ const normalized: unknown = normalizeSchemaNode(value, new Set())
166
+ return isJsonSchema(normalized) ? normalized : undefined
167
+ }
168
+
169
+ function structuredParameters(schema: JsonPayload | undefined): JSONSchema {
170
+ const normalized = normalizeJsonSchema(schema)
171
+ if (!normalized) {
172
+ throw new Error('Structured output schema must be a bounded plain JSON object.')
173
+ }
174
+ return normalized
175
+ }
176
+
177
+ export function makeStructuredOutputTool(
178
+ schema: JsonPayload | undefined,
179
+ capture: (value: JsonPayload) => void
180
+ ): ToolDefinition {
181
+ return defineTool({
182
+ name: 'structured_output',
183
+ label: 'Structured Output',
184
+ description: STRUCTURED_OUTPUT_TOOL_DESCRIPTION,
185
+ parameters: structuredParameters(schema),
186
+ constrainedSampling: { type: 'json_schema', strict: 'prefer' },
187
+ async execute(_toolCallId, params: JsonPayload) {
188
+ capture(params)
189
+ return {
190
+ content: [{ type: 'text', text: 'Recorded structured result.' }],
191
+ details: params,
192
+ terminate: true
193
+ }
194
+ }
195
+ })
196
+ }
197
+
198
+ export function structuredOutputInstruction(schema: JsonPayload | undefined): string | undefined {
199
+ const normalized = normalizeJsonSchema(schema)
200
+ if (!normalized) return undefined
201
+ return `${STRUCTURED_OUTPUT_SYSTEM_INSTRUCTION}\n\nRequired schema:\n${JSON.stringify(normalized, null, 2)}`
202
+ }