@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.
- package/LICENSE +178 -0
- package/README.md +80 -2
- package/package.json +51 -4
- package/src/bulk-read.ts +147 -0
- package/src/lib/child-runtime.ts +18 -0
- package/src/log-reducer.ts +242 -0
- package/src/redact.ts +17 -0
- package/src/subagent/agent.ts +112 -0
- package/src/subagent/drive.ts +187 -0
- package/src/subagent/durable.ts +264 -0
- package/src/subagent/format.ts +241 -0
- package/src/subagent/helpers.ts +212 -0
- package/src/subagent/index.ts +853 -0
- package/src/subagent/recover.ts +268 -0
- package/src/subagent/session.ts +87 -0
- package/src/subagent/state.ts +59 -0
- package/src/subagent/structured.ts +202 -0
- package/src/subagent/types.ts +109 -0
- package/src/subagent/viewer.ts +184 -0
|
@@ -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
|
+
}
|