@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.
- package/README.md +24 -3
- package/bin/supervisor.ts +116 -0
- package/package.json +42 -3
- package/scripts/build-supervisor-executable.ts +32 -0
- package/src/agents/agent-error-serializer.ts +23 -0
- package/src/agents/agent-event-stream.ts +81 -0
- package/src/agents/agent-id.ts +17 -0
- package/src/agents/agent-operation.ts +11 -0
- package/src/agents/agent-provider.ts +37 -0
- package/src/agents/agent-run.ts +32 -0
- package/src/agents/agent-runtime-error.ts +161 -0
- package/src/agents/agent-session-events.ts +32 -0
- package/src/agents/agent-tool-runner.ts +83 -0
- package/src/agents/agent-tool.ts +111 -0
- package/src/agents/author-context.ts +36 -0
- package/src/agents/claude/claude-command-mapper.ts +234 -0
- package/src/agents/claude/claude-event-mapper.ts +736 -0
- package/src/agents/claude/claude-provider.ts +191 -0
- package/src/agents/claude/claude-run.ts +464 -0
- package/src/agents/claude/claude-tool-mapper.ts +186 -0
- package/src/agents/claude/index.ts +1 -0
- package/src/agents/codex/codex-auth.ts +34 -0
- package/src/agents/codex/codex-command-mapper.ts +78 -0
- package/src/agents/codex/codex-event-mapper.ts +708 -0
- package/src/agents/codex/codex-json-rpc-client.ts +326 -0
- package/src/agents/codex/codex-protocol.ts +36 -0
- package/src/agents/codex/codex-provider.ts +1050 -0
- package/src/agents/codex/codex-run.ts +404 -0
- package/src/agents/codex/codex-skill-catalog.ts +158 -0
- package/src/agents/codex/codex-skill-roots.ts +19 -0
- package/src/agents/codex/codex-tool-mapper.ts +55 -0
- package/src/agents/codex/codex.errors.ts +68 -0
- package/src/agents/codex/generated/meta.gen.ts +606 -0
- package/src/agents/codex/generated/namespaces.gen.ts +311 -0
- package/src/agents/codex/generated/schema.gen.ts +34883 -0
- package/src/agents/codex/index.ts +2 -0
- package/src/agents/index.ts +14 -0
- package/src/agents/input-attachment-preparation.ts +86 -0
- package/src/agents/input-attachment.errors.ts +16 -0
- package/src/agents/instructions.ts +8 -0
- package/src/agents/materialized-input-attachment.ts +26 -0
- package/src/agents/message-id.ts +23 -0
- package/src/agents/normalize.ts +8 -0
- package/src/agents/sandbox-environment.ts +1 -0
- package/src/agents/tools/ping.tool.ts +13 -0
- package/src/agents/user-input-request.ts +470 -0
- package/src/provenance.gen.ts +3 -3
- package/src/supervisor/agent-provider-factory.ts +189 -0
- package/src/supervisor/bootstrap-binder.ts +125 -0
- package/src/supervisor/config.ts +49 -0
- package/src/supervisor/control-authority-verifier.ts +135 -0
- package/src/supervisor/create-supervisor-runtime.ts +25 -0
- package/src/supervisor/errors.ts +24 -0
- package/src/supervisor/index.ts +34 -0
- package/src/supervisor/persistence/json.ts +21 -0
- package/src/supervisor/persistence/state-discovery.ts +56 -0
- package/src/supervisor/persistence/supervisor-store.ts +364 -0
- package/src/supervisor/ports/index.ts +109 -0
- package/src/supervisor/provider-factory.ts +37 -0
- package/src/supervisor/resident.ts +143 -0
- package/src/supervisor/rpc-client.ts +120 -0
- package/src/supervisor/runtime-handler.ts +309 -0
- package/src/supervisor/websocket-server.ts +434 -0
- package/src/supervisor-protocol/bootstrap.ts +1 -1
- package/src/template-onboarding.ts +47 -0
- package/src/testing/es256-test-keys.ts +73 -0
- package/src/testing/in-memory-runtime-control-plane.ts +205 -0
- package/src/testing/index.ts +6 -0
- package/src/testing/loopback-supervisor-connection.ts +71 -0
- package/src/testing/scripted-provider.ts +64 -0
- package/src/worker-runtime-client/command-pump.ts +132 -0
- package/src/worker-runtime-client/connection-attempt.ts +340 -0
- package/src/worker-runtime-client/control-authority-signer.ts +100 -0
- package/src/worker-runtime-client/e2b-supervisor-connection.ts +102 -0
- package/src/worker-runtime-client/frame-processor.ts +178 -0
- package/src/worker-runtime-client/index.ts +27 -0
- package/src/worker-runtime-client/lease-reconciler.ts +14 -0
- package/src/worker-runtime-client/ports.ts +137 -0
- package/src/worker-runtime-client/postgres-notification-listener.ts +91 -0
- package/src/worker-runtime-client/rpc-dispatcher.ts +27 -0
- package/src/worker-runtime-client/rpc-request-manager.ts +141 -0
- package/src/worker-runtime-client/sandbox-connection-runtime.ts +300 -0
- package/src/worker-runtime-client/token-crypto.ts +46 -0
|
@@ -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,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
|
+
}
|