@meistrari/agent-core 0.0.0 → 0.1.0

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.
Files changed (83) hide show
  1. package/README.md +24 -3
  2. package/bin/supervisor.ts +116 -0
  3. package/package.json +42 -3
  4. package/scripts/build-supervisor-executable.ts +32 -0
  5. package/src/agents/agent-error-serializer.ts +23 -0
  6. package/src/agents/agent-event-stream.ts +81 -0
  7. package/src/agents/agent-id.ts +17 -0
  8. package/src/agents/agent-operation.ts +11 -0
  9. package/src/agents/agent-provider.ts +37 -0
  10. package/src/agents/agent-run.ts +32 -0
  11. package/src/agents/agent-runtime-error.ts +161 -0
  12. package/src/agents/agent-session-events.ts +32 -0
  13. package/src/agents/agent-tool-runner.ts +83 -0
  14. package/src/agents/agent-tool.ts +111 -0
  15. package/src/agents/author-context.ts +36 -0
  16. package/src/agents/claude/claude-command-mapper.ts +234 -0
  17. package/src/agents/claude/claude-event-mapper.ts +736 -0
  18. package/src/agents/claude/claude-provider.ts +191 -0
  19. package/src/agents/claude/claude-run.ts +464 -0
  20. package/src/agents/claude/claude-tool-mapper.ts +186 -0
  21. package/src/agents/claude/index.ts +1 -0
  22. package/src/agents/codex/codex-auth.ts +34 -0
  23. package/src/agents/codex/codex-command-mapper.ts +78 -0
  24. package/src/agents/codex/codex-event-mapper.ts +708 -0
  25. package/src/agents/codex/codex-json-rpc-client.ts +326 -0
  26. package/src/agents/codex/codex-protocol.ts +36 -0
  27. package/src/agents/codex/codex-provider.ts +1050 -0
  28. package/src/agents/codex/codex-run.ts +404 -0
  29. package/src/agents/codex/codex-skill-catalog.ts +158 -0
  30. package/src/agents/codex/codex-skill-roots.ts +19 -0
  31. package/src/agents/codex/codex-tool-mapper.ts +55 -0
  32. package/src/agents/codex/codex.errors.ts +68 -0
  33. package/src/agents/codex/generated/meta.gen.ts +606 -0
  34. package/src/agents/codex/generated/namespaces.gen.ts +311 -0
  35. package/src/agents/codex/generated/schema.gen.ts +34883 -0
  36. package/src/agents/codex/index.ts +2 -0
  37. package/src/agents/index.ts +14 -0
  38. package/src/agents/input-attachment-preparation.ts +86 -0
  39. package/src/agents/input-attachment.errors.ts +16 -0
  40. package/src/agents/instructions.ts +8 -0
  41. package/src/agents/materialized-input-attachment.ts +26 -0
  42. package/src/agents/message-id.ts +23 -0
  43. package/src/agents/normalize.ts +8 -0
  44. package/src/agents/sandbox-environment.ts +1 -0
  45. package/src/agents/tools/ping.tool.ts +13 -0
  46. package/src/agents/user-input-request.ts +470 -0
  47. package/src/provenance.gen.ts +3 -3
  48. package/src/supervisor/agent-provider-factory.ts +189 -0
  49. package/src/supervisor/bootstrap-binder.ts +125 -0
  50. package/src/supervisor/config.ts +49 -0
  51. package/src/supervisor/control-authority-verifier.ts +135 -0
  52. package/src/supervisor/create-supervisor-runtime.ts +25 -0
  53. package/src/supervisor/errors.ts +24 -0
  54. package/src/supervisor/index.ts +34 -0
  55. package/src/supervisor/persistence/json.ts +21 -0
  56. package/src/supervisor/persistence/state-discovery.ts +56 -0
  57. package/src/supervisor/persistence/supervisor-store.ts +364 -0
  58. package/src/supervisor/ports/index.ts +109 -0
  59. package/src/supervisor/provider-factory.ts +37 -0
  60. package/src/supervisor/resident.ts +143 -0
  61. package/src/supervisor/rpc-client.ts +120 -0
  62. package/src/supervisor/runtime-handler.ts +309 -0
  63. package/src/supervisor/websocket-server.ts +434 -0
  64. package/src/supervisor-protocol/bootstrap.ts +1 -1
  65. package/src/template-onboarding.ts +47 -0
  66. package/src/testing/es256-test-keys.ts +73 -0
  67. package/src/testing/in-memory-runtime-control-plane.ts +205 -0
  68. package/src/testing/index.ts +6 -0
  69. package/src/testing/loopback-supervisor-connection.ts +71 -0
  70. package/src/testing/scripted-provider.ts +64 -0
  71. package/src/worker-runtime-client/command-pump.ts +132 -0
  72. package/src/worker-runtime-client/connection-attempt.ts +340 -0
  73. package/src/worker-runtime-client/control-authority-signer.ts +100 -0
  74. package/src/worker-runtime-client/e2b-supervisor-connection.ts +102 -0
  75. package/src/worker-runtime-client/frame-processor.ts +178 -0
  76. package/src/worker-runtime-client/index.ts +27 -0
  77. package/src/worker-runtime-client/lease-reconciler.ts +14 -0
  78. package/src/worker-runtime-client/ports.ts +137 -0
  79. package/src/worker-runtime-client/postgres-notification-listener.ts +91 -0
  80. package/src/worker-runtime-client/rpc-dispatcher.ts +27 -0
  81. package/src/worker-runtime-client/rpc-request-manager.ts +141 -0
  82. package/src/worker-runtime-client/sandbox-connection-runtime.ts +300 -0
  83. package/src/worker-runtime-client/token-crypto.ts +46 -0
@@ -0,0 +1,2 @@
1
+ export * from './codex-auth'
2
+ export * from './codex-provider'
@@ -0,0 +1,14 @@
1
+ export * from './agent-error-serializer'
2
+ export * from './agent-event-stream'
3
+ export * from './agent-operation'
4
+ export * from './agent-provider'
5
+ export * from './agent-run'
6
+ export * from './agent-runtime-error'
7
+ export * from './agent-session-events'
8
+ export * from './agent-tool'
9
+ export * from './agent-tool-runner'
10
+ export * from './instructions'
11
+ export * from './materialized-input-attachment'
12
+ export * from './message-id'
13
+ export * from './tools/ping.tool'
14
+ export * from './user-input-request'
@@ -0,0 +1,86 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { MaterializedAgentInputAttachment } from './materialized-input-attachment'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { AgentInputAttachmentPreparationFailure } from './input-attachment.errors'
5
+
6
+ type NativeImageMimeType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'
7
+
8
+ export async function readAttachmentBytes(attachment: MaterializedAgentInputAttachment, signal: AbortSignal): Promise<Buffer> {
9
+ signal.throwIfAborted()
10
+ try {
11
+ const bytes = await readFile(attachment.localPath, { signal })
12
+ signal.throwIfAborted()
13
+ return bytes
14
+ }
15
+ catch (cause) {
16
+ if (signal.aborted)
17
+ throw signal.reason
18
+ if (!isErrnoBackedFilesystemError(cause))
19
+ throw cause
20
+ throw new AgentInputAttachmentPreparationFailure({ attachmentId: attachment.attachmentId, cause })
21
+ }
22
+ }
23
+
24
+ function isErrnoBackedFilesystemError(cause: unknown): cause is Error & { errno: number, syscall: string } {
25
+ if (!(cause instanceof Error))
26
+ return false
27
+ const filesystemError = cause as NodeJS.ErrnoException
28
+ return typeof filesystemError.errno === 'number' && typeof filesystemError.syscall === 'string'
29
+ }
30
+
31
+ export function nativeImageMimeType(mediaType: string, bytes: Uint8Array): NativeImageMimeType | undefined {
32
+ if (mediaType === 'image/jpeg' && bytes.length >= 2 && bytes[0] === 0xFF && bytes[1] === 0xD8)
33
+ return mediaType
34
+
35
+ if (mediaType === 'image/png' && bytes.length >= 9 && hasBytes(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]))
36
+ return mediaType
37
+
38
+ if (mediaType === 'image/gif' && (hasAsciiPrefix(bytes, 'GIF87a') || hasAsciiPrefix(bytes, 'GIF89a')))
39
+ return mediaType
40
+
41
+ if (mediaType === 'image/webp' && bytes.length >= 12 && hasAsciiPrefix(bytes, 'RIFF') && hasAsciiAt(bytes, 8, 'WEBP'))
42
+ return mediaType
43
+
44
+ return undefined
45
+ }
46
+
47
+ export function nativeAttachmentLabel(attachment: MaterializedAgentInputAttachment): string {
48
+ return `Input attachment (native):\n${JSON.stringify({
49
+ attachmentId: attachment.attachmentId,
50
+ ordinal: attachment.ordinal,
51
+ filename: attachment.filename,
52
+ mediaType: attachment.mediaType,
53
+ byteSize: attachment.byteSize,
54
+ sha256: attachment.sha256,
55
+ })}`
56
+ }
57
+
58
+ export function filesystemFallbackLabel(attachment: MaterializedAgentInputAttachment): string {
59
+ return `Input attachment (filesystem fallback):\n${JSON.stringify({
60
+ attachmentId: attachment.attachmentId,
61
+ ordinal: attachment.ordinal,
62
+ filename: attachment.filename,
63
+ mediaType: attachment.mediaType,
64
+ byteSize: attachment.byteSize,
65
+ sha256: attachment.sha256,
66
+ path: attachment.localPath,
67
+ })}`
68
+ }
69
+
70
+ function hasBytes(bytes: Uint8Array, expected: readonly number[]): boolean {
71
+ return expected.every((byte, index) => bytes[index] === byte)
72
+ }
73
+
74
+ function hasAsciiPrefix(bytes: Uint8Array, expected: string): boolean {
75
+ return hasAsciiAt(bytes, 0, expected)
76
+ }
77
+
78
+ function hasAsciiAt(bytes: Uint8Array, offset: number, expected: string): boolean {
79
+ if (bytes.length < offset + expected.length)
80
+ return false
81
+ for (let index = 0; index < expected.length; index += 1) {
82
+ if (bytes[offset + index] !== expected.charCodeAt(index))
83
+ return false
84
+ }
85
+ return true
86
+ }
@@ -0,0 +1,16 @@
1
+ import { InfrastructureError } from '../errors'
2
+
3
+ export class AgentInputAttachmentPreparationFailure extends InfrastructureError {
4
+ readonly attachmentId: string
5
+
6
+ constructor(input: { attachmentId: string, cause: unknown }) {
7
+ super({
8
+ code: 'agents.input-attachment-preparation-failed',
9
+ message: 'Failed to prepare a materialized input attachment.',
10
+ publicMessage: 'Agent input attachment could not be prepared.',
11
+ retryable: false,
12
+ cause: input.cause,
13
+ })
14
+ this.attachmentId = input.attachmentId
15
+ }
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { AgentProviderId } from '../protocol'
2
+
3
+ export type InstructionComposer = (input: {
4
+ provider: AgentProviderId
5
+ tools: readonly { name: string }[]
6
+ }) => string
7
+
8
+ export const emptyInstructionComposer: InstructionComposer = () => ''
@@ -0,0 +1,26 @@
1
+ export type MaterializedAgentInputAttachment = Readonly<{
2
+ attachmentId: string
3
+ ordinal: number
4
+ filename: string
5
+ mediaType: string
6
+ byteSize: number
7
+ sha256: string
8
+ localPath: string
9
+ }>
10
+
11
+ export type AgentInputAttachmentProjection = Readonly<{
12
+ attachmentId: string
13
+ ordinal: number
14
+ sha256: string
15
+ } & (
16
+ | { route: 'native', nativeKind: 'image' | 'document' }
17
+ | { route: 'filesystem_fallback' }
18
+ )>
19
+
20
+ export type AgentInputAttachmentPreparation = Readonly<{
21
+ attachments: readonly MaterializedAgentInputAttachment[]
22
+ signal: AbortSignal
23
+ recordInputAttachmentProjections: (
24
+ records: readonly AgentInputAttachmentProjection[],
25
+ ) => void
26
+ }>
@@ -0,0 +1,23 @@
1
+ import type { UUID } from 'node:crypto'
2
+ import { Buffer } from 'node:buffer'
3
+ import { createHash } from 'node:crypto'
4
+
5
+ // Frozen: the namespace must never change, or a restarted supervisor would derive a different
6
+ // correlation id when it redrives the same durable command.
7
+ const NAMESPACE = 'b9d7a3f1-6e2c-5a48-9c1d-3f8e0a7b2c64'
8
+
9
+ // Deterministic UUIDv5 correlation id: a redelivered command re-derives the same id across restarts.
10
+ // It is correlation only — the provider is not assumed to deduplicate, so under at-least-once a
11
+ // duplicate may be accepted; distinct command ids derive distinct ids, so genuine repeats are never
12
+ // conflated.
13
+ export function deriveMessageId(commandId: string): UUID {
14
+ const digest = createHash('sha1')
15
+ .update(Buffer.from(NAMESPACE.replaceAll('-', ''), 'hex'))
16
+ .update(commandId, 'utf8')
17
+ .digest()
18
+ const view = new DataView(digest.buffer, digest.byteOffset, 16)
19
+ view.setUint8(6, (view.getUint8(6) & 0x0F) | 0x50) // version 5
20
+ view.setUint8(8, (view.getUint8(8) & 0x3F) | 0x80) // RFC-4122 variant
21
+ const hex = digest.subarray(0, 16).toString('hex')
22
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
23
+ }
@@ -0,0 +1,8 @@
1
+ export function removeUndefined<T extends object>(value: T): T {
2
+ return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T
3
+ }
4
+
5
+ export function optionalNonEmpty(value: string | null | undefined): string | undefined {
6
+ const trimmed = value?.trim()
7
+ return trimmed || undefined
8
+ }
@@ -0,0 +1 @@
1
+ export const SANDBOX_WORKSPACE_ROOT = '/home/user/workspace'
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod'
2
+ import { tool } from '../agent-tool'
3
+
4
+ export const pingTool = tool({
5
+ name: 'ping',
6
+ description: 'Return pong for smoke and contract checks.',
7
+ inputSchema: z.object({}),
8
+ timeoutMs: 5_000,
9
+ execute: async () => ({
10
+ success: true,
11
+ content: [{ type: 'text', text: 'pong' }],
12
+ }),
13
+ })
@@ -0,0 +1,470 @@
1
+ import type {
2
+ AgentPrompt,
3
+ AgentUserInputAnswers,
4
+ AgentUserInputQuestion,
5
+ RespondUserInputAgentCommand,
6
+ UserInputRequestedAgentEvent,
7
+ UserInputResolvedAgentEvent,
8
+ } from '../protocol'
9
+ import type { AgentEventDraft } from './agent-event-stream'
10
+ import { Buffer } from 'node:buffer'
11
+ import { randomUUID } from 'node:crypto'
12
+ import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
13
+ import { readFile } from 'node:fs/promises'
14
+ import { basename, dirname, join } from 'node:path'
15
+ import z from 'zod'
16
+ import {
17
+ agentUserInputAnswersSchema,
18
+ agentUserInputQuestionSchema,
19
+ } from '../protocol'
20
+ import { createUserInputRequestId } from './agent-id'
21
+ import { AgentRunStateError, AgentUserInputRequestStoreError } from './agent-runtime-error'
22
+
23
+ const mainActor = { type: 'main', actorId: 'main' } as const
24
+
25
+ const STORE_VERSION = 1
26
+ const STORE_FILE_NAME = 'user-input-requests.json'
27
+ const USER_INPUT_STORE_DIR = 'coding-agent-user-input'
28
+
29
+ const userInputRequestStatusSchema = z.enum(['pending', 'answered', 'delivered', 'cancelled'])
30
+
31
+ const userInputRequestRecordSchema = z.object({
32
+ requestId: z.string().min(1),
33
+ sessionId: z.string().min(1),
34
+ turnId: z.string().min(1),
35
+ toolCallId: z.string().min(1),
36
+ prompt: z.string().min(1),
37
+ questions: z.array(agentUserInputQuestionSchema).min(1),
38
+ status: userInputRequestStatusSchema,
39
+ answers: agentUserInputAnswersSchema.optional(),
40
+ createdAt: z.string().datetime(),
41
+ answeredAt: z.string().datetime().optional(),
42
+ deliveredAt: z.string().datetime().optional(),
43
+ }).strict()
44
+
45
+ const userInputRequestStoreFileSchema = z.object({
46
+ version: z.literal(STORE_VERSION),
47
+ sessionId: z.string().min(1),
48
+ requests: z.array(userInputRequestRecordSchema),
49
+ }).strict()
50
+
51
+ export interface UserInputRequestInput {
52
+ turnId: string
53
+ toolCallId: string
54
+ prompt: string
55
+ questions: readonly AgentUserInputQuestion[]
56
+ }
57
+
58
+ export type UserInputRequestRecord = z.infer<typeof userInputRequestRecordSchema>
59
+ export type UserInputRequestStoreFile = z.infer<typeof userInputRequestStoreFileSchema>
60
+
61
+ export interface OpenUserInputRequestStoreInput {
62
+ sessionId: string
63
+ rootDir: string
64
+ }
65
+
66
+ interface ResolvedUserInputRequest {
67
+ record: UserInputRequestRecord
68
+ event?: Extract<AgentEventDraft, { type: 'user-input.resolved' }>
69
+ prompt: AgentPrompt
70
+ }
71
+
72
+ export class UserInputRequestStore {
73
+ private readonly requests: Map<string, UserInputRequestRecord>
74
+
75
+ private constructor(
76
+ private readonly sessionId: string,
77
+ private readonly filePath: string,
78
+ requests: readonly UserInputRequestRecord[],
79
+ ) {
80
+ this.requests = new Map(requests.map(request => [request.requestId, request]))
81
+ }
82
+
83
+ static async open(input: OpenUserInputRequestStoreInput): Promise<UserInputRequestStore> {
84
+ if (!input.sessionId.trim()) {
85
+ throw new AgentUserInputRequestStoreError({ message: 'User-input request store requires a sessionId.' })
86
+ }
87
+ if (!input.rootDir.trim()) {
88
+ throw new AgentUserInputRequestStoreError({ message: 'User-input request store requires a rootDir.' })
89
+ }
90
+
91
+ const filePath = userInputRequestStoreFilePath(input)
92
+ const file = await readStoreFile({ sessionId: input.sessionId, filePath })
93
+ return new UserInputRequestStore(input.sessionId, filePath, file.requests)
94
+ }
95
+
96
+ create(input: UserInputRequestInput): {
97
+ requestId: string
98
+ event: Extract<AgentEventDraft, { type: 'user-input.requested' }>
99
+ } {
100
+ validateQuestions(input.questions)
101
+ const requestId = createUserInputRequestId()
102
+ const record: UserInputRequestRecord = {
103
+ requestId,
104
+ sessionId: this.sessionId,
105
+ turnId: input.turnId,
106
+ toolCallId: input.toolCallId,
107
+ prompt: input.prompt,
108
+ questions: userInputQuestions(input.questions),
109
+ status: 'pending',
110
+ createdAt: new Date().toISOString(),
111
+ }
112
+ this.requests.set(requestId, record)
113
+ this.persist()
114
+
115
+ return {
116
+ requestId,
117
+ event: {
118
+ type: 'user-input.requested',
119
+ turnId: input.turnId,
120
+ actor: mainActor,
121
+ payload: {
122
+ requestId,
123
+ toolCallId: input.toolCallId,
124
+ prompt: input.prompt,
125
+ questions: record.questions,
126
+ },
127
+ } satisfies UserInputRequestedEventDraft,
128
+ }
129
+ }
130
+
131
+ decorateTurnEnded(draft: AgentEventDraft): AgentEventDraft {
132
+ if (draft.type !== 'turn.ended' || draft.payload.status !== 'completed' || !this.hasPendingForTurn(draft.turnId))
133
+ return draft
134
+
135
+ return {
136
+ ...draft,
137
+ payload: {
138
+ ...draft.payload,
139
+ reason: 'user_input_requested',
140
+ },
141
+ }
142
+ }
143
+
144
+ shouldHoldIdle(): boolean {
145
+ return [...this.requests.values()].some(request => request.status === 'pending')
146
+ }
147
+
148
+ resolve(command: RespondUserInputAgentCommand): ResolvedUserInputRequest | null {
149
+ if (command.sessionId !== this.sessionId) {
150
+ throw new AgentRunStateError({
151
+ message: `User input request store for ${this.sessionId} cannot resolve command for ${command.sessionId}.`,
152
+ details: { storeSessionId: this.sessionId, commandSessionId: command.sessionId },
153
+ })
154
+ }
155
+
156
+ const record = this.requests.get(command.requestId)
157
+ if (!record) {
158
+ throw new AgentRunStateError({
159
+ message: `Unknown user input request: ${command.requestId}`,
160
+ details: { requestId: command.requestId },
161
+ })
162
+ }
163
+
164
+ if (record.status === 'delivered')
165
+ return null
166
+
167
+ if (record.status === 'answered') {
168
+ assertSameAnswers(record, command.answers)
169
+ return {
170
+ record,
171
+ prompt: promptForResolvedUserInput(record, record.answers),
172
+ }
173
+ }
174
+
175
+ if (record.status !== 'pending') {
176
+ throw new AgentRunStateError({
177
+ message: `User input request ${command.requestId} is ${record.status}.`,
178
+ details: { requestId: command.requestId, status: record.status },
179
+ })
180
+ }
181
+
182
+ validateAnswers(record, command.answers)
183
+ const answered: UserInputRequestRecord = {
184
+ ...record,
185
+ status: 'answered',
186
+ answers: command.answers,
187
+ answeredAt: new Date().toISOString(),
188
+ }
189
+ this.requests.set(answered.requestId, answered)
190
+ this.persist()
191
+
192
+ return {
193
+ record: answered,
194
+ event: {
195
+ type: 'user-input.resolved',
196
+ payload: {
197
+ requestId: answered.requestId,
198
+ requestedTurnId: answered.turnId,
199
+ toolCallId: answered.toolCallId,
200
+ answers: command.answers,
201
+ },
202
+ } satisfies UserInputResolvedEventDraft,
203
+ prompt: promptForResolvedUserInput(answered, command.answers),
204
+ }
205
+ }
206
+
207
+ markDelivered(requestId: string): void {
208
+ const record = this.requests.get(requestId)
209
+ if (!record || record.status !== 'answered')
210
+ return
211
+
212
+ this.requests.set(requestId, {
213
+ ...record,
214
+ status: 'delivered',
215
+ deliveredAt: new Date().toISOString(),
216
+ })
217
+ this.persist()
218
+ }
219
+
220
+ private hasPendingForTurn(turnId: string): boolean {
221
+ return [...this.requests.values()].some(request => request.turnId === turnId && request.status === 'pending')
222
+ }
223
+
224
+ private persist(): void {
225
+ const file: UserInputRequestStoreFile = {
226
+ version: STORE_VERSION,
227
+ sessionId: this.sessionId,
228
+ requests: [...this.requests.values()],
229
+ }
230
+ const parent = dirname(this.filePath)
231
+ const tempPath = join(parent, `.${basename(this.filePath)}.${process.pid}.${randomUUID()}.tmp`)
232
+
233
+ try {
234
+ mkdirSync(parent, { recursive: true })
235
+ writeFileSync(tempPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8')
236
+ renameSync(tempPath, this.filePath)
237
+ }
238
+ catch (error) {
239
+ throw new AgentUserInputRequestStoreError({
240
+ message: `Failed to persist user-input request store for session ${this.sessionId}.`,
241
+ details: { sessionId: this.sessionId },
242
+ cause: error,
243
+ })
244
+ }
245
+ }
246
+ }
247
+
248
+ type UserInputRequestedEventDraft = Omit<UserInputRequestedAgentEvent, 'eventId' | 'provider' | 'sessionId' | 'providerSessionId' | 'timestamp' | 'sequence'>
249
+ type UserInputResolvedEventDraft = Omit<UserInputResolvedAgentEvent, 'eventId' | 'provider' | 'sessionId' | 'providerSessionId' | 'timestamp' | 'sequence'>
250
+
251
+ export function userInputRequestStoreRootFromTranscriptPath(transcriptPath: string): string {
252
+ return join(dirname(transcriptPath), USER_INPUT_STORE_DIR)
253
+ }
254
+
255
+ export function userInputRequestStoreFilePath(input: OpenUserInputRequestStoreInput): string {
256
+ return join(input.rootDir, safeSessionPathSegment(input.sessionId), STORE_FILE_NAME)
257
+ }
258
+
259
+ async function readStoreFile(input: { sessionId: string, filePath: string }): Promise<UserInputRequestStoreFile> {
260
+ let raw: string
261
+ try {
262
+ raw = await readFile(input.filePath, 'utf8')
263
+ }
264
+ catch (error) {
265
+ if (isNodeError(error) && error.code === 'ENOENT') {
266
+ return { version: STORE_VERSION, sessionId: input.sessionId, requests: [] }
267
+ }
268
+ throw new AgentUserInputRequestStoreError({
269
+ message: `Failed to read user-input request store for session ${input.sessionId}.`,
270
+ details: { sessionId: input.sessionId },
271
+ cause: error,
272
+ })
273
+ }
274
+
275
+ let parsed: unknown
276
+ try {
277
+ parsed = JSON.parse(raw)
278
+ }
279
+ catch (error) {
280
+ throw new AgentUserInputRequestStoreError({
281
+ message: `User-input request store for session ${input.sessionId} contains invalid JSON.`,
282
+ details: { sessionId: input.sessionId },
283
+ cause: error,
284
+ })
285
+ }
286
+
287
+ const result = userInputRequestStoreFileSchema.safeParse(parsed)
288
+ if (!result.success) {
289
+ throw new AgentUserInputRequestStoreError({
290
+ message: `User-input request store for session ${input.sessionId} has invalid shape.`,
291
+ details: { sessionId: input.sessionId },
292
+ cause: result.error,
293
+ })
294
+ }
295
+
296
+ if (result.data.sessionId !== input.sessionId) {
297
+ throw new AgentUserInputRequestStoreError({
298
+ message: `User-input request store session mismatch: expected ${input.sessionId}, found ${result.data.sessionId}.`,
299
+ details: { expectedSessionId: input.sessionId, actualSessionId: result.data.sessionId },
300
+ })
301
+ }
302
+
303
+ const seenRequestIds = new Set<string>()
304
+ for (const request of result.data.requests) {
305
+ if (request.sessionId !== input.sessionId) {
306
+ throw new AgentUserInputRequestStoreError({
307
+ message: `User-input request ${request.requestId} belongs to session ${request.sessionId}, not ${input.sessionId}.`,
308
+ details: { expectedSessionId: input.sessionId, actualSessionId: request.sessionId, requestId: request.requestId },
309
+ })
310
+ }
311
+ if (seenRequestIds.has(request.requestId)) {
312
+ throw new AgentUserInputRequestStoreError({
313
+ message: `User-input request store contains duplicate request id ${request.requestId}.`,
314
+ details: { sessionId: input.sessionId, requestId: request.requestId },
315
+ })
316
+ }
317
+ seenRequestIds.add(request.requestId)
318
+ }
319
+
320
+ return result.data
321
+ }
322
+
323
+ function userInputQuestions(questions: readonly AgentUserInputQuestion[]): AgentUserInputQuestion[] {
324
+ return z.array(agentUserInputQuestionSchema).parse(questions)
325
+ }
326
+
327
+ function validateQuestions(questions: readonly AgentUserInputQuestion[]): void {
328
+ const ids = new Set<string>()
329
+ for (const question of questions) {
330
+ if (ids.has(question.id)) {
331
+ throw new AgentRunStateError({
332
+ message: `Duplicate user-input question id: ${question.id}`,
333
+ details: { questionId: question.id },
334
+ })
335
+ }
336
+ ids.add(question.id)
337
+
338
+ if (question.type !== 'single_select')
339
+ continue
340
+
341
+ const optionValues = new Set<string>()
342
+ for (const option of question.options) {
343
+ if (optionValues.has(option.value)) {
344
+ throw new AgentRunStateError({
345
+ message: `Duplicate option value ${option.value} for user-input question ${question.id}.`,
346
+ details: { questionId: question.id, optionValue: option.value },
347
+ })
348
+ }
349
+ optionValues.add(option.value)
350
+ }
351
+ }
352
+ }
353
+
354
+ function validateAnswers(record: UserInputRequestRecord, answers: AgentUserInputAnswers): void {
355
+ const questionsById = new Map(record.questions.map(question => [question.id, question]))
356
+
357
+ for (const answerId of Object.keys(answers)) {
358
+ if (!questionsById.has(answerId)) {
359
+ throw new AgentRunStateError({
360
+ message: `Answer provided for unknown user-input question: ${answerId}`,
361
+ details: { requestId: record.requestId, questionId: answerId },
362
+ })
363
+ }
364
+ }
365
+
366
+ for (const question of record.questions) {
367
+ const answer = answers[question.id]
368
+ if (answer === undefined || answer === null) {
369
+ if (question.required === false)
370
+ continue
371
+ throw new AgentRunStateError({
372
+ message: `Missing answer for user-input question: ${question.id}`,
373
+ details: { requestId: record.requestId, questionId: question.id },
374
+ })
375
+ }
376
+
377
+ if (question.type === 'text') {
378
+ if (typeof answer !== 'string') {
379
+ throw new AgentRunStateError({
380
+ message: `Answer for user-input question ${question.id} must be a string.`,
381
+ details: { requestId: record.requestId, questionId: question.id },
382
+ })
383
+ }
384
+ if (question.required !== false && !answer.trim()) {
385
+ throw new AgentRunStateError({
386
+ message: `Answer for user-input question ${question.id} must not be empty.`,
387
+ details: { requestId: record.requestId, questionId: question.id },
388
+ })
389
+ }
390
+ continue
391
+ }
392
+
393
+ if (typeof answer !== 'string' || !question.options.some(option => option.value === answer)) {
394
+ throw new AgentRunStateError({
395
+ message: `Answer for user-input question ${question.id} must be one of the allowed options.`,
396
+ details: { requestId: record.requestId, questionId: question.id },
397
+ })
398
+ }
399
+ }
400
+ }
401
+
402
+ function assertSameAnswers(record: UserInputRequestRecord, answers: AgentUserInputAnswers): asserts record is UserInputRequestRecord & { answers: AgentUserInputAnswers } {
403
+ const existing = record.answers
404
+ if (!existing) {
405
+ throw new AgentRunStateError({
406
+ message: `User input request ${record.requestId} is answered without stored answers.`,
407
+ details: { requestId: record.requestId },
408
+ })
409
+ }
410
+
411
+ if (!sameAnswerValues(existing, answers)) {
412
+ throw new AgentRunStateError({
413
+ message: `User input request ${record.requestId} was already answered with different answers.`,
414
+ details: { requestId: record.requestId },
415
+ })
416
+ }
417
+ }
418
+
419
+ function sameAnswerValues(left: AgentUserInputAnswers, right: AgentUserInputAnswers): boolean {
420
+ return stableJson(left) === stableJson(right)
421
+ }
422
+
423
+ function stableJson(value: unknown): string {
424
+ return JSON.stringify(stableValue(value))
425
+ }
426
+
427
+ function stableValue(value: unknown): unknown {
428
+ if (Array.isArray(value))
429
+ return value.map(stableValue)
430
+ if (value && typeof value === 'object') {
431
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, stableValue(entry)]))
432
+ }
433
+ return value
434
+ }
435
+
436
+ function promptForResolvedUserInput(record: UserInputRequestRecord, answers: AgentUserInputAnswers): AgentPrompt {
437
+ return {
438
+ blocks: [{
439
+ type: 'text',
440
+ text: [
441
+ 'The user answered a previous user-input request.',
442
+ '',
443
+ `Request ID: ${record.requestId}`,
444
+ 'Original prompt:',
445
+ record.prompt,
446
+ '',
447
+ 'Answers:',
448
+ ...record.questions.map(question => `- ${question.id}: ${formatAnswer(answers[question.id])}`),
449
+ '',
450
+ 'Continue from where you left off. Treat these answers as the result of the previous ask_user request. Do not ask the same question again unless the answer is insufficient.',
451
+ ].join('\n'),
452
+ }],
453
+ }
454
+ }
455
+
456
+ function formatAnswer(value: AgentUserInputAnswers[string] | undefined): string {
457
+ if (value === undefined)
458
+ return '(no answer)'
459
+ if (typeof value === 'string')
460
+ return value
461
+ return JSON.stringify(value)
462
+ }
463
+
464
+ function safeSessionPathSegment(sessionId: string): string {
465
+ return Buffer.from(sessionId, 'utf8').toString('base64url')
466
+ }
467
+
468
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
469
+ return error instanceof Error && 'code' in error
470
+ }