diff --git a/apps/desktop/src/renderer/chat-composer-region.tsx b/apps/desktop/src/renderer/chat-composer-region.tsx index 2c25797fd7..c3ffcbd76e 100644 --- a/apps/desktop/src/renderer/chat-composer-region.tsx +++ b/apps/desktop/src/renderer/chat-composer-region.tsx @@ -23,6 +23,7 @@ import { Button, ClientCapabilityPrompt, Composer, + type ComposerInteraction, ComposerGoalProjectionConsumer, SandboxBoundaryPrompt, UserQuestionPrompt, @@ -61,11 +62,6 @@ interface BoundaryUnreadableNotice { onRetry(): void; } -type ComposerInteraction = - | ComponentProps['request'] - | ComponentProps['request'] - | ComponentProps['request']; - /** * The composer region of the chat surface (issue #1043): the composer * interaction slot (permission / user-question prompts) plus the always-mounted diff --git a/packages/core/src/__tests__/interaction.test.ts b/packages/core/src/__tests__/interaction.test.ts index 9b820797e9..c824cda0c8 100644 --- a/packages/core/src/__tests__/interaction.test.ts +++ b/packages/core/src/__tests__/interaction.test.ts @@ -29,12 +29,15 @@ import { InteractionPermissionProjectionError, decodeInteractionAnswer, decodeInteractionCanonicalOutcome, + decodeInteractionFormResponse, decodeInteractionRequest, interactionCanonicalOutcomesEquivalent, isInteractionAnswerValidForRequest, isInteractionCanonicalOutcomeValidForRequest, + isInteractionFormFieldValueValid, projectInteractionClientCapabilityRequest, projectInteractionPermissionRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, projectInteractionSandboxBoundaryRequest, } from '../interaction.js'; @@ -1059,4 +1062,561 @@ describe('Interaction decoding and validity', () => { }), ); }); + + test('decodes the complete bounded primitive form contract', () => { + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Example MCP server' }, + fields: [ + { + kind: 'string', + name: 'owner', + label: 'Owner email', + required: true, + format: 'email', + minLength: 3, + maxLength: 100, + }, + { + kind: 'number', + name: 'ratio', + label: 'Traffic ratio', + required: false, + minimum: 0, + maximum: 1, + default: 0.5, + }, + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'boolean', + name: 'confirm', + label: 'Confirm deployment', + required: true, + default: false, + }, + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [ + { value: 'staging', label: 'Staging' }, + { value: 'production', label: 'Production' }, + ], + default: 'staging', + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + minItems: 1, + maxItems: 2, + default: ['us'], + }, + ], + }); + + const accepted = decodeInteractionAnswer({ + kind: 'form', + action: 'accept', + values: { + owner: 'owner@example.test', + ratio: 0.25, + replicas: 3, + confirm: true, + environment: 'production', + regions: ['us', 'eu'], + }, + }); + assert.equal(isInteractionAnswerValidForRequest(request, accepted), true); + assert.equal( + isInteractionAnswerValidForRequest( + request, + decodeInteractionAnswer({ kind: 'form', action: 'decline' }), + ), + true, + ); + assert.equal( + isInteractionAnswerValidForRequest( + request, + decodeInteractionAnswer({ kind: 'form', action: 'cancel' }), + ), + true, + ); + }); + + test('projects every human-facing form label through the safe review boundary', () => { + const projected = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: '\u202e password=message-secret', + requester: { + name: 'token=requester-secret', + source: '\napi_key=source-secret', + }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: '\u0007 password=field-secret', + description: '\nclient_secret=description-secret', + required: true, + options: [ + { value: 'production', label: '\u202e token=option-secret' }, + { value: 'staging', label: 'Staging' }, + ], + }, + ], + }); + + assert.equal(projected.message, '\\u{202E} password=[redacted]'); + assert.deepEqual(projected.requester, { + name: 'token=[redacted]', + source: '\\u{A}api_key=[redacted]', + }); + assert.deepEqual(projected.fields[0], { + kind: 'single_select', + name: 'environment', + label: '\\u{7} password=[redacted]', + description: '\\u{A}client_secret=[redacted]', + required: true, + options: [ + { value: 'production', label: '\\u{202E} token=[redacted]' }, + { value: 'staging', label: 'Staging' }, + ], + }); + assert.doesNotMatch(JSON.stringify(projected), /message-secret|requester-secret|source-secret/); + }); + + test('drops string defaults that cannot be represented without changing canonical semantics', () => { + const bidiDefault = '\u202e'; + const secretDefault = 'sk-live-secret'; + const projected = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Review defaults', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'string', + name: 'direction', + label: 'Direction', + required: false, + default: bidiDefault, + minLength: 1, + maxLength: 1, + }, + { + kind: 'string', + name: 'token', + label: 'Token', + required: false, + default: secretDefault, + minLength: secretDefault.length, + maxLength: secretDefault.length, + }, + { + kind: 'string', + name: 'contact', + label: 'Contact', + required: false, + default: 'password=secret@example.test', + format: 'email', + maxLength: 512, + }, + { + kind: 'string', + name: 'safe-contact', + label: 'Safe contact', + required: false, + default: 'owner@example.test', + format: 'email', + maxLength: 512, + }, + ], + }); + + assert.equal(projected.fields[0]?.default, undefined); + assert.equal(projected.fields[1]?.default, undefined); + assert.equal(projected.fields[2]?.default, undefined); + assert.equal(projected.fields[3]?.default, 'owner@example.test'); + }); + + test('rejects form option labels that collide after safe projection', () => { + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [ + { value: 'first', label: 'password=first-secret' }, + { value: 'second', label: 'password=second-secret' }, + ], + }, + ], + }), + ); + }); + + test('reserves canonical outcome overhead before admitting an accepted form answer', () => { + const values = { + a: 'x'.repeat(2_048), + b: 'x'.repeat(2_048), + c: 'x'.repeat(2_048), + d: 'x'.repeat(1_950), + }; + assert.ok( + Buffer.byteLength(JSON.stringify({ kind: 'form', action: 'accept', values })) < 8 * 1_024, + ); + assert.throws( + () => decodeInteractionAnswer({ kind: 'form', action: 'accept', values }), + /Interaction form outcome exceeds serialized byte limit/, + ); + }); + + test('validates date-time calendar and clock fields without Date.parse normalization', () => { + const field = { + kind: 'string' as const, + name: 'when', + label: 'When', + required: true, + format: 'date-time' as const, + }; + assert.equal(isInteractionFormFieldValueValid(field, '2024-02-29T23:59:59Z'), true); + assert.equal(isInteractionFormFieldValueValid(field, '2023-02-29T00:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2023-02-30T00:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2024-01-01T24:00:00Z'), false); + assert.equal(isInteractionFormFieldValueValid(field, '2024-01-01T00:00:00+24:00'), false); + }); + + test('rejects malformed form schemas and invalid accepted values', () => { + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + minItems: 1, + }, + ], + }); + + for (const values of [ + {}, + { replicas: 1.5 }, + { replicas: 11 }, + { replicas: 2, unknown: true }, + { replicas: 2, regions: [] }, + { replicas: 2, regions: ['elsewhere'] }, + ]) { + const answer = decodeInteractionAnswer({ kind: 'form', action: 'accept', values }); + assert.equal(isInteractionAnswerValidForRequest(request, answer), false); + } + + assert.throws(() => + decodeInteractionAnswer({ + kind: 'form', + action: 'accept', + values: { replicas: 2, regions: ['us', 'us'] }, + }), + ); + + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { kind: 'boolean', name: 'same', label: 'First', required: false }, + { kind: 'boolean', name: 'same', label: 'Second', required: false }, + ], + }), + ); + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'single_select', + name: 'environment', + label: 'Environment', + required: true, + options: [{ value: 'staging', label: 'Staging' }], + default: 'production', + }, + ], + }), + ); + }); + + test('rejects forms that cannot produce a bounded accepted answer', () => { + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter required values', + requester: { name: 'deploy' }, + fields: Array.from({ length: 5 }, (_, index) => ({ + kind: 'string' as const, + name: `value-${index}`, + label: `Value ${index}`, + required: true, + minLength: 2_048, + maxLength: 2_048, + })), + }), + /Interaction form answer exceeds serialized byte limit/, + ); + + assert.doesNotThrow(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter a timestamp', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'string', + name: 'when', + label: 'When', + required: true, + format: 'date-time', + minLength: 22, + maxLength: 22, + }, + ], + }), + ); + + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Optional values still consume the persisted answer envelope', + requester: { name: 'deploy' }, + fields: Array.from({ length: 4 }, (_, index) => ({ + kind: 'string' as const, + name: `optional-${index}`, + label: `Optional ${index}`, + required: false, + maxLength: 2_048, + })), + }), + /Interaction form (answer|outcome) exceeds serialized byte limit/, + ); + }); + + test('reserves the worst-case escaped answer envelope at admission', () => { + // String constraints keep their schema semantics — maxLength in code + // points, raw UTF-8 bytes bounded per value — so admission must prove that + // even a fully JSON-escaped legal answer still fits the canonical + // envelope. A form whose limits allow an undeliverable answer is rejected + // instead of stranding the pending interaction after the user submits. + const stringField = (name: string, maxLength?: number) => ({ + kind: 'string' as const, + name, + label: name, + required: true, + ...(maxLength === undefined ? {} : { maxLength }), + }); + for (const fields of [ + [stringField('a', 2_048), stringField('b', 2_048)], + [stringField('loose')], + ]) { + assert.throws( + () => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter required values', + requester: { name: 'deploy' }, + fields, + }), + /Interaction form (answer|outcome) exceeds serialized byte limit/, + ); + } + + // A form whose escaped worst case fits stays admissible, and its + // escape-heavy legal answers — backslashes, newlines, control characters — + // decode and deliver. + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Enter a value', + requester: { name: 'deploy' }, + fields: [stringField('value', 1_024)], + }); + for (const heavy of ['\\'.repeat(1_024), '\n'.repeat(1_024), '\u0001'.repeat(1_024)]) { + const answer = { + kind: 'form' as const, + action: 'accept' as const, + values: { value: heavy }, + }; + assert.equal(isInteractionAnswerValidForRequest(request, answer), true); + decodeInteractionAnswer(answer); + } + + // The displayed character constraint keeps its meaning: 2,048 plain + // characters or 1,024 backslashes satisfy a maxLength: 2_048 field. + const wideField = stringField('wide', 2_048); + assert.equal(isInteractionFormFieldValueValid(wideField, 'a'.repeat(2_048)), true); + assert.equal(isInteractionFormFieldValueValid(wideField, '\\'.repeat(1_024)), true); + + // Select envelopes are measured in the serialized domain too: the + // raw-longest option is not always the serialized-longest one. + const options = [ + { value: 'a'.repeat(1_200), label: 'Plain' }, + { value: '\\'.repeat(1_000), label: 'Escaped' }, + ]; + const selectFields = (count: number) => + Array.from({ length: count }, (_, index) => ({ + kind: 'single_select' as const, + name: `select-${index}`, + label: `Select ${index}`, + required: true, + options, + })); + assert.throws(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Pick five values', + requester: { name: 'deploy' }, + fields: selectFields(5), + }), + ); + assert.doesNotThrow(() => + projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Pick three values', + requester: { name: 'deploy' }, + fields: selectFields(3), + }), + ); + }); + + test('admits date and date-time strings without an explicit maxLength', () => { + // The admission envelope reserves the worst value inside each format's + // legal language: date is fixed-length over [0-9-], and date-time adds + // only characters that never JSON-escape. Neither may inflate to the + // unconstrained control-character worst case. + const request = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Schedule the deploy', + requester: { name: 'deploy' }, + fields: [ + { kind: 'string', name: 'day', label: 'Day', required: true, format: 'date' }, + { kind: 'string', name: 'at', label: 'At', required: true, format: 'date-time' }, + ], + }); + const answer = { + kind: 'form' as const, + action: 'accept' as const, + values: { day: '2024-02-29', at: '2024-02-29T23:59:59Z' }, + }; + assert.equal(isInteractionAnswerValidForRequest(request, answer), true); + decodeInteractionAnswer(answer); + + // Fractional seconds leave the date-time length unbounded up to the field + // caps, and the envelope reserves exactly that plain-character worst case. + const fractional = projectInteractionFormRequest({ + toolUseId: 'tool-form', + message: 'Schedule the deploy', + requester: { name: 'deploy' }, + fields: [{ kind: 'string', name: 'at', label: 'At', required: true, format: 'date-time' }], + }); + const longFraction = { + kind: 'form' as const, + action: 'accept' as const, + values: { at: `2024-02-29T23:59:59.${'0'.repeat(2_020)}Z` }, + }; + assert.equal(isInteractionAnswerValidForRequest(fractional, longFraction), true); + decodeInteractionAnswer(longFraction); + }); + + test('compares canonical accepted form values structurally', () => { + const first = decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { regions: ['us', 'eu'], replicas: 2 }, + committedAt: 1, + }); + const retry = decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { replicas: 2, regions: ['us', 'eu'] }, + committedAt: 2, + }); + assert.equal(interactionCanonicalOutcomesEquivalent(first, retry), true); + assert.equal( + interactionCanonicalOutcomesEquivalent( + first, + decodeInteractionCanonicalOutcome({ + kind: 'form_answer', + action: 'accept', + values: { replicas: 2, regions: ['eu', 'us'] }, + committedAt: 3, + }), + ), + false, + ); + }); + + test('decodes form responses as a closed renderer-to-runtime shape', () => { + assert.deepEqual( + decodeInteractionFormResponse({ + requestId: 'form-1', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }), + { + requestId: 'form-1', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }, + ); + assert.deepEqual(decodeInteractionFormResponse({ requestId: 'form-1', action: 'decline' }), { + requestId: 'form-1', + action: 'decline', + }); + for (const invalid of [ + { requestId: 'form-1', action: 'accept' }, + { requestId: 'form-1', action: 'cancel', values: {} }, + { requestId: 'form-1', action: 'accept', values: {}, extra: true }, + { requestId: '', action: 'decline' }, + ]) { + assert.throws(() => decodeInteractionFormResponse(invalid)); + } + }); }); diff --git a/packages/core/src/__tests__/runtime-event.test.ts b/packages/core/src/__tests__/runtime-event.test.ts index 2e6725bfd3..18c4dde645 100644 --- a/packages/core/src/__tests__/runtime-event.test.ts +++ b/packages/core/src/__tests__/runtime-event.test.ts @@ -623,7 +623,7 @@ describe('RuntimeEvent actions', () => { } }); - test('permission and user-question interactions are first-class actions', () => { + test('permission, question, and form interactions are first-class actions', () => { const actions: RuntimeEventActions = { permissionRequest: { kind: 'tool_permission', @@ -638,6 +638,14 @@ describe('RuntimeEvent actions', () => { permissionDecision: { requestId: 'pr-1', decision: 'deny' }, permissionAnswerAccepted: { requestId: 'hosted-pr-1' }, userQuestionAnswerAccepted: { requestId: 'question-1' }, + formRequest: { + requestId: 'form-1', + toolUseId: 'tc-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + formAnswerAccepted: { requestId: 'form-1' }, }; assert.strictEqual(actions.permissionRequest?.category, 'shell_unsafe'); assert.strictEqual(actions.permissionDecision?.decision, 'deny'); @@ -649,6 +657,7 @@ describe('RuntimeEvent actions', () => { for (const [accepted, requestId] of [ [decodedActions?.permissionAnswerAccepted, 'hosted-pr-1'], [decodedActions?.userQuestionAnswerAccepted, 'question-1'], + [decodedActions?.formAnswerAccepted, 'form-1'], ] as const) { assert.deepEqual(accepted, { requestId }); assert.ok(accepted); @@ -659,8 +668,17 @@ describe('RuntimeEvent actions', () => { for (const invalidAcceptedAction of [ { permissionAnswerAccepted: { requestId: 'pr-1', extra: true } }, { userQuestionAnswerAccepted: { requestId: 'question-1', extra: true } }, + { formAnswerAccepted: { requestId: 'form-1', extra: true } }, { permissionAnswerAccepted: Object.create({ requestId: 'inherited-pr-1' }) }, { userQuestionAnswerAccepted: { requestId: 'x'.repeat(257) } }, + { + formRequest: { + ...actions.formRequest, + fields: [ + { kind: 'boolean', name: 'confirm', label: 'Confirm', required: true, extra: true }, + ], + }, + }, ]) { assert.throws(() => decodeRuntimeEvent({ diff --git a/packages/core/src/backend-types.ts b/packages/core/src/backend-types.ts index 9f326ca20f..a3ffdcae98 100644 --- a/packages/core/src/backend-types.ts +++ b/packages/core/src/backend-types.ts @@ -35,9 +35,10 @@ import type { QuoteRef, SessionEvent, SandboxBoundaryRequestEvent, + FormRequestEvent, UserQuestionRequestEvent, } from './events.js'; -import type { InteractionClosureReason } from './interaction.js'; +import type { InteractionClosureReason, InteractionFormResult } from './interaction.js'; import type { RuntimeEvent } from './runtime-event.js'; import type { SandboxBoundaryResponse, SandboxBoundarySettlement } from './sandbox-boundary.js'; import type { StoredMessage, PersistedBackendKind } from './session.js'; @@ -132,6 +133,11 @@ export interface HostedUserQuestionSettlement { applyClosure(reason: Exclude): Promise; } +export interface HostedFormSettlement { + applyAnswer(answer: InteractionFormResult): Promise; + applyClosure(reason: Exclude): Promise; +} + export interface HostedSandboxBoundarySettlement { applyDecision(settlement: SandboxBoundarySettlement): Promise; applyClosure(reason: Exclude): Promise; @@ -150,6 +156,10 @@ export interface HostedInteractionBridge { request: UserQuestionRequestEvent; settlement: HostedUserQuestionSettlement; }): Promise; + admitFormRequest(input: { + request: FormRequestEvent; + settlement: HostedFormSettlement; + }): Promise; admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index 52af0d0727..a30d3999d4 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -36,6 +36,7 @@ import type { SandboxEscalationRequest, } from './permission.js'; import type { SandboxBoundaryExpansion, SandboxBoundaryRequestStatus } from './sandbox-boundary.js'; +import type { InteractionFormField, InteractionRequesterProjection } from './interaction.js'; import type { UserQuestionRequest } from './user-question.js'; import type { ClientCapabilityGrantCapability, @@ -568,6 +569,8 @@ export type SessionEvent = | PermissionDecisionAckEvent | UserQuestionRequestEvent | UserQuestionAnswerAckEvent + | FormRequestEvent + | FormAnswerAckEvent | PlanSubmittedEvent | TokenUsageEvent | SteeringMessageEvent @@ -1014,6 +1017,15 @@ export interface UserQuestionRequestEvent extends BaseEvent, UserQuestionRequest type: 'user_question_request'; } +export interface FormRequestEvent extends BaseEvent { + type: 'form_request'; + requestId: string; + toolUseId: string; + message: string; + requester: InteractionRequesterProjection; + fields: readonly InteractionFormField[]; +} + export interface SandboxBoundaryRequestEvent extends BaseEvent { type: 'sandbox_boundary_request'; requestId: string; @@ -1038,6 +1050,7 @@ export interface ClientCapabilityRequestEvent extends BaseEvent { export type ActiveInteractionRequestEvent = | SandboxBoundaryRequestEvent | UserQuestionRequestEvent + | FormRequestEvent | ClientCapabilityRequestEvent; export interface SandboxBoundaryDecisionAckEvent extends BaseEvent { @@ -1066,6 +1079,13 @@ export interface UserQuestionAnswerAckEvent extends BaseEvent { toolUseId: string; } +/** Echo that the hosted runtime accepted a form answer. */ +export interface FormAnswerAckEvent extends BaseEvent { + type: 'form_answer_ack'; + requestId: string; + toolUseId: string; +} + /** * Echo that the hosted runtime accepted a permission answer. * The canonical decision remains owned by the Interaction outcome. diff --git a/packages/core/src/interaction.ts b/packages/core/src/interaction.ts index c064906aff..383e61f5e7 100644 --- a/packages/core/src/interaction.ts +++ b/packages/core/src/interaction.ts @@ -59,6 +59,15 @@ export const INTERACTION_SANDBOX_BOUNDARY_JUSTIFICATION_MAX_CHARS = 2_000; export const INTERACTION_ANSWER_SERIALIZED_MAX_BYTES = 8 * 1024; export const INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES = 8 * 1024; export const INTERACTION_AUTO_REVIEW_RATIONALE_MAX_CHARS = 1_000; +export const INTERACTION_FORM_MAX_FIELDS = 32; +export const INTERACTION_FORM_MAX_OPTIONS = 64; +export const INTERACTION_FORM_MESSAGE_MAX_BYTES = 2_048; +export const INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES = 256; +export const INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES = 512; +export const INTERACTION_FORM_FIELD_NAME_MAX_BYTES = 256; +export const INTERACTION_FORM_FIELD_LABEL_MAX_BYTES = 256; +export const INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES = 512; +export const INTERACTION_FORM_VALUE_MAX_BYTES = 2_048; export const INTERACTION_CLOSURE_REASONS = [ 'turn_stopped', @@ -94,6 +103,72 @@ export interface InteractionQuestionRequest { readonly questions: readonly InteractionQuestion[]; } +export interface InteractionRequesterProjection { + /** Human-readable name only. Runtime identity remains the exact Tool invocation. */ + readonly name: string; + /** Optional display provenance such as a provider or server name. */ + readonly source?: string; +} + +export interface InteractionFormOption { + readonly value: string; + readonly label: string; +} + +interface InteractionFormFieldBase { + readonly name: string; + readonly label: string; + readonly required: boolean; + readonly description?: string; +} + +export type InteractionFormField = + | (InteractionFormFieldBase & { + readonly kind: 'string'; + readonly default?: string; + readonly minLength?: number; + readonly maxLength?: number; + readonly format?: 'email' | 'uri' | 'date' | 'date-time'; + }) + | (InteractionFormFieldBase & { + readonly kind: 'number'; + readonly default?: number; + readonly minimum?: number; + readonly maximum?: number; + }) + | (InteractionFormFieldBase & { + readonly kind: 'integer'; + readonly default?: number; + readonly minimum?: number; + readonly maximum?: number; + }) + | (InteractionFormFieldBase & { + readonly kind: 'boolean'; + readonly default?: boolean; + }) + | (InteractionFormFieldBase & { + readonly kind: 'single_select'; + readonly options: readonly InteractionFormOption[]; + readonly default?: string; + }) + | (InteractionFormFieldBase & { + readonly kind: 'multi_select'; + readonly options: readonly InteractionFormOption[]; + readonly default?: readonly string[]; + readonly minItems?: number; + readonly maxItems?: number; + }); + +export type InteractionFormValue = string | number | boolean | readonly string[]; + +export interface InteractionFormRequest { + readonly kind: 'form'; + readonly toolUseId: string; + readonly message: string; + readonly requester: InteractionRequesterProjection; + readonly fields: readonly InteractionFormField[]; +} + export interface InteractionSandboxBoundaryRequest { readonly kind: 'sandbox_boundary'; readonly expansion: SandboxBoundaryExpansion; @@ -109,6 +184,7 @@ export interface InteractionClientCapabilityRequest { export type InteractionRequest = | InteractionPermissionRequest | InteractionQuestionRequest + | InteractionFormRequest | InteractionSandboxBoundaryRequest | InteractionClientCapabilityRequest; @@ -125,6 +201,19 @@ export interface InteractionQuestionAnswer { readonly answers: readonly (string | null)[]; } +export type InteractionFormResult = + | { + readonly action: 'accept'; + readonly values: Readonly>; + } + | { + readonly action: 'decline' | 'cancel'; + }; + +export type InteractionFormAnswer = { readonly kind: 'form' } & InteractionFormResult; + +export type InteractionFormResponse = { readonly requestId: string } & InteractionFormResult; + export interface InteractionSandboxBoundaryAnswer { readonly kind: 'sandbox_boundary'; readonly decision: 'allow' | 'deny'; @@ -138,6 +227,7 @@ export interface InteractionClientCapabilityAnswer { export type InteractionAnswer = | InteractionPermissionAnswer | InteractionQuestionAnswer + | InteractionFormAnswer | InteractionSandboxBoundaryAnswer | InteractionClientCapabilityAnswer; @@ -155,6 +245,19 @@ export interface InteractionCanonicalQuestionOutcome { readonly committedAt: number; } +export type InteractionCanonicalFormOutcome = + | { + readonly kind: 'form_answer'; + readonly action: 'accept'; + readonly values: Readonly>; + readonly committedAt: number; + } + | { + readonly kind: 'form_answer'; + readonly action: 'decline' | 'cancel'; + readonly committedAt: number; + }; + export interface InteractionCanonicalSandboxBoundaryOutcome { readonly kind: 'sandbox_boundary_decision'; readonly decision: 'allow' | 'deny'; @@ -177,6 +280,7 @@ export interface InteractionCanonicalClosureOutcome { export type InteractionCanonicalOutcome = | InteractionCanonicalPermissionOutcome | InteractionCanonicalQuestionOutcome + | InteractionCanonicalFormOutcome | InteractionCanonicalSandboxBoundaryOutcome | InteractionCanonicalClientCapabilityOutcome | InteractionCanonicalClosureOutcome; @@ -186,6 +290,12 @@ export type InteractionQuestionProjectionInput = Pick< 'toolUseId' | 'questions' >; +export type InteractionFormInput = Pick; + +export type InteractionFormProjectionInput = InteractionFormInput & { + readonly toolUseId: string; +}; + const PERMISSION_REQUEST_SHAPE = defineObjectShape()( ['kind', 'toolUseId', 'prompt'], [], @@ -194,6 +304,10 @@ const QUESTION_REQUEST_SHAPE = defineObjectShape()( ['kind', 'toolUseId', 'questions'], [], ); +const FORM_REQUEST_SHAPE = defineObjectShape()( + ['kind', 'toolUseId', 'message', 'requester', 'fields'], + [], +); const SANDBOX_BOUNDARY_REQUEST_SHAPE = defineObjectShape()( ['kind', 'expansion', 'justification'], [], @@ -210,6 +324,18 @@ const QUESTION_ANSWER_SHAPE = defineObjectShape()( ['kind', 'answers'], [], ); +const FORM_ACCEPT_ANSWER_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action', 'values'], []); +const FORM_EMPTY_ANSWER_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action'], []); +const FORM_ACCEPT_RESPONSE_SHAPE = defineObjectShape< + Extract +>()(['requestId', 'action', 'values'], []); +const FORM_EMPTY_RESPONSE_SHAPE = defineObjectShape< + Extract +>()(['requestId', 'action'], []); const SANDBOX_BOUNDARY_ANSWER_SHAPE = defineObjectShape()( ['kind', 'decision'], [], @@ -226,6 +352,12 @@ const QUESTION_OUTCOME_SHAPE = defineObjectShape +>()(['kind', 'action', 'values', 'committedAt'], []); +const FORM_EMPTY_OUTCOME_SHAPE = defineObjectShape< + Extract +>()(['kind', 'action', 'committedAt'], []); const SANDBOX_BOUNDARY_OUTCOME_SHAPE = defineObjectShape()( ['kind', 'decision', 'status', 'committedAt'], @@ -242,6 +374,32 @@ const CLOSURE_OUTCOME_SHAPE = defineObjectShape()(['question', 'options'], []); const OPTION_SHAPE = defineObjectShape()(['label'], ['description']); +const FORM_REQUESTER_SHAPE = defineObjectShape()( + ['name'], + ['source'], +); +const FORM_OPTION_SHAPE = defineObjectShape()(['value', 'label'], []); +const FORM_STRING_FIELD_SHAPE = defineObjectShape< + Extract +>()( + ['kind', 'name', 'label', 'required'], + ['description', 'default', 'minLength', 'maxLength', 'format'], +); +const FORM_NUMBER_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required'], ['description', 'default', 'minimum', 'maximum']); +const FORM_BOOLEAN_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required'], ['description', 'default']); +const FORM_SINGLE_SELECT_FIELD_SHAPE = defineObjectShape< + Extract +>()(['kind', 'name', 'label', 'required', 'options'], ['description', 'default']); +const FORM_MULTI_SELECT_FIELD_SHAPE = defineObjectShape< + Extract +>()( + ['kind', 'name', 'label', 'required', 'options'], + ['description', 'default', 'minItems', 'maxItems'], +); export function decodeInteractionRequest(value: unknown): InteractionRequest { const record = plainRecord(value, 'Interaction request'); @@ -265,6 +423,21 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest { INTERACTION_MAX_QUESTIONS, ).map(decodeQuestion), }; + } else if (record.kind === 'form') { + exact(record, FORM_REQUEST_SHAPE, 'form request'); + const fields = plainArray(record.fields, 'form fields', 0, INTERACTION_FORM_MAX_FIELDS).map( + decodeFormField, + ); + if (new Set(fields.map((field) => field.name)).size !== fields.length) { + throw new Error('Duplicate form field name'); + } + request = { + kind: 'form', + toolUseId: boundedString(record.toolUseId, 'toolUseId', INTERACTION_ID_MAX_BYTES), + message: boundedString(record.message, 'form message', INTERACTION_FORM_MESSAGE_MAX_BYTES), + requester: decodeFormRequester(record.requester), + fields, + }; } else if (record.kind === 'sandbox_boundary') { exact(record, SANDBOX_BOUNDARY_REQUEST_SHAPE, 'sandbox boundary request'); const expansion = validateSandboxBoundaryExpansion(record.expansion); @@ -288,6 +461,7 @@ export function decodeInteractionRequest(value: unknown): InteractionRequest { } else { throw new Error('Invalid Interaction request kind'); } + if (request.kind === 'form') assertFormHasAcceptedAnswer(request); if (request.kind !== 'sandbox_boundary') { serializedLimit(request, INTERACTION_REQUEST_MAX_BYTES, 'Interaction request'); } @@ -310,6 +484,15 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { } else if (record.kind === 'question') { exact(record, QUESTION_ANSWER_SHAPE, 'question answer'); answer = { kind: 'question', answers: decodeAnswers(record.answers) }; + } else if (record.kind === 'form') { + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + if (action === 'accept') { + exact(record, FORM_ACCEPT_ANSWER_SHAPE, 'accepted form answer'); + answer = { kind: 'form', action, values: decodeFormValues(record.values) }; + } else { + exact(record, FORM_EMPTY_ANSWER_SHAPE, 'empty form answer'); + answer = { kind: 'form', action }; + } } else if (record.kind === 'sandbox_boundary') { exact(record, SANDBOX_BOUNDARY_ANSWER_SHAPE, 'sandbox boundary answer'); answer = { @@ -326,9 +509,32 @@ export function decodeInteractionAnswer(value: unknown): InteractionAnswer { throw new Error('Invalid Interaction answer kind'); } serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction answer'); + if (answer.kind === 'form' && answer.action === 'accept') { + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); + } return deepFreeze(answer); } +export function decodeInteractionFormResponse(value: unknown): InteractionFormResponse { + const record = plainRecord(value, 'Interaction form response'); + const requestId = boundedString(record.requestId, 'requestId', INTERACTION_ID_MAX_BYTES); + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + if (action === 'accept') { + exact(record, FORM_ACCEPT_RESPONSE_SHAPE, 'accepted form response'); + const answer = decodeInteractionAnswer({ + kind: 'form', + action, + values: record.values, + }); + if (answer.kind !== 'form' || answer.action !== 'accept') { + throw new Error('Invalid accepted form response'); + } + return deepFreeze({ requestId, action, values: answer.values }); + } + exact(record, FORM_EMPTY_RESPONSE_SHAPE, 'empty form response'); + return deepFreeze({ requestId, action }); +} + export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCanonicalOutcome { const record = plainRecord(value, 'Interaction canonical outcome'); let outcome: InteractionCanonicalOutcome; @@ -372,6 +578,21 @@ export function decodeInteractionCanonicalOutcome(value: unknown): InteractionCa answers: decodeAnswers(record.answers), committedAt: safeInteger(record.committedAt, 'committedAt', false), }; + } else if (record.kind === 'form_answer') { + const action = oneOf(record.action, ['accept', 'decline', 'cancel'] as const, 'form action'); + const committedAt = safeInteger(record.committedAt, 'committedAt', false); + if (action === 'accept') { + exact(record, FORM_ACCEPT_OUTCOME_SHAPE, 'accepted form outcome'); + outcome = { + kind: 'form_answer', + action, + values: decodeFormValues(record.values), + committedAt, + }; + } else { + exact(record, FORM_EMPTY_OUTCOME_SHAPE, 'empty form outcome'); + outcome = { kind: 'form_answer', action, committedAt }; + } } else if (record.kind === 'sandbox_boundary_decision') { exact(record, SANDBOX_BOUNDARY_OUTCOME_SHAPE, 'sandbox boundary outcome'); const status = oneOf( @@ -470,6 +691,75 @@ export function projectInteractionQuestionRequest( return decodeInteractionRequest(projected) as InteractionQuestionRequest; } +export function projectInteractionFormRequest( + input: InteractionFormProjectionInput, +): InteractionFormRequest { + const decoded = decodeInteractionRequest({ kind: 'form', ...input }) as InteractionFormRequest; + return decodeInteractionRequest({ + ...decoded, + message: projectInteractionReviewText(decoded.message, INTERACTION_FORM_MESSAGE_MAX_BYTES), + requester: { + name: projectInteractionReviewText( + decoded.requester.name, + INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES, + ), + ...(decoded.requester.source === undefined + ? {} + : { + source: projectInteractionReviewText( + decoded.requester.source, + INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES, + true, + ), + }), + }, + fields: decoded.fields.map(projectInteractionFormField), + }) as InteractionFormRequest; +} + +function projectInteractionFormField(field: InteractionFormField): InteractionFormField { + const display = { + label: projectInteractionReviewText(field.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + ...(field.description === undefined + ? {} + : { + description: projectInteractionReviewText( + field.description, + INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES, + true, + ), + }), + }; + if (field.kind !== 'single_select' && field.kind !== 'multi_select') { + // `name` is a protocol identity returned to the tool. A string default is + // both display text and a canonical answer value, so a safety rewrite must + // not silently change its semantics under the original constraints. + if (field.kind === 'string' && field.default !== undefined) { + const { default: canonicalDefault, ...fieldWithoutDefault } = field; + const projectedDefault = projectInteractionReviewText( + canonicalDefault, + INTERACTION_FORM_VALUE_MAX_BYTES, + true, + ); + return { + ...fieldWithoutDefault, + ...display, + ...(projectedDefault === canonicalDefault ? { default: canonicalDefault } : {}), + }; + } + return { ...field, ...display }; + } + // Select values are protocol identities; labels are their display text. + const options = field.options.map((option) => ({ + ...option, + label: projectInteractionReviewText(option.label, INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + })); + if (new Set(options.map((option) => option.label)).size !== options.length) { + throw new Error('Form option labels collide after safe projection'); + } + return { ...field, ...display, options }; +} + export function projectInteractionSandboxBoundaryRequest(input: { readonly expansion: SandboxBoundaryExpansion; readonly justification: string; @@ -509,9 +799,11 @@ export function interactionOutcomeMatchesRequestKind( ? outcome.kind === 'permission_answer' : request.kind === 'question' ? outcome.kind === 'question_answer' - : request.kind === 'sandbox_boundary' - ? outcome.kind === 'sandbox_boundary_decision' - : outcome.kind === 'client_capability_decision') + : request.kind === 'form' + ? outcome.kind === 'form_answer' + : request.kind === 'sandbox_boundary' + ? outcome.kind === 'sandbox_boundary_decision' + : outcome.kind === 'client_capability_decision') ); } @@ -546,6 +838,9 @@ export function isInteractionAnswerValidForRequest( interactionQuestionAnswerCountMatchesRequest(request, answer.answers) ); } + if (answer.kind === 'form') { + return request.kind === 'form' && interactionFormAnswerMatchesRequest(request, answer); + } if (answer.kind === 'sandbox_boundary') return request.kind === 'sandbox_boundary'; if (answer.kind === 'client_capability') return request.kind === 'client_capability'; return interactionRememberForTurnIsEligible(request, answer); @@ -571,6 +866,17 @@ export function isInteractionCanonicalOutcomeValidForRequest( interactionQuestionAnswerCountMatchesRequest(request, outcome.answers) ); } + if (outcome.kind === 'form_answer') { + return ( + request.kind === 'form' && + interactionFormAnswerMatchesRequest( + request, + outcome.action === 'accept' + ? { kind: 'form', action: 'accept', values: outcome.values } + : { kind: 'form', action: outcome.action }, + ) + ); + } if (outcome.kind === 'client_capability_decision') { return request.kind === 'client_capability'; } @@ -586,6 +892,13 @@ export function interactionCanonicalOutcomesEquivalent( return left.decision === right.decision && left.rememberForTurn === right.rememberForTurn; if (left.kind === 'question_answer' && right.kind === 'question_answer') return equalAnswers(left.answers, right.answers); + if (left.kind === 'form_answer' && right.kind === 'form_answer') { + return ( + left.action === right.action && + (left.action !== 'accept' || + (right.action === 'accept' && equalFormValues(left.values, right.values))) + ); + } if (left.kind === 'sandbox_boundary_decision' && right.kind === 'sandbox_boundary_decision') { return left.decision === right.decision && left.status === right.status; } @@ -629,6 +942,447 @@ function decodeOption(value: unknown): InteractionQuestionOption { }); } +function decodeFormRequester(value: unknown): InteractionRequesterProjection { + const record = plainRecord(value, 'Interaction form requester'); + exact(record, FORM_REQUESTER_SHAPE, 'form requester'); + return Object.freeze({ + name: boundedString( + record.name, + 'form requester name', + INTERACTION_FORM_REQUESTER_NAME_MAX_BYTES, + ), + ...(record.source === undefined + ? {} + : { + source: boundedText( + record.source, + 'form requester source', + INTERACTION_FORM_REQUESTER_SOURCE_MAX_BYTES, + ), + }), + }); +} + +function decodeFormField(value: unknown): InteractionFormField { + const record = plainRecord(value, 'Interaction form field'); + const common = decodeFormFieldBase(record); + if (record.kind === 'string') { + exact(record, FORM_STRING_FIELD_SHAPE, 'string form field'); + const minLength = optionalBoundedCount(record.minLength, 'minLength'); + const maxLength = optionalBoundedCount(record.maxLength, 'maxLength'); + if (minLength !== undefined && maxLength !== undefined && minLength > maxLength) { + throw new Error('Invalid form string length range'); + } + const field: Extract = { + ...common, + kind: 'string', + ...(record.default === undefined + ? {} + : { + default: boundedText( + record.default, + 'form string default', + INTERACTION_FORM_VALUE_MAX_BYTES, + ), + }), + ...(minLength === undefined ? {} : { minLength }), + ...(maxLength === undefined ? {} : { maxLength }), + ...(record.format === undefined + ? {} + : { + format: oneOf( + record.format, + ['email', 'uri', 'date', 'date-time'] as const, + 'form string format', + ), + }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid form string default'); + } + return deepFreeze(field); + } + if (record.kind === 'number' || record.kind === 'integer') { + exact(record, FORM_NUMBER_FIELD_SHAPE, 'number form field'); + const minimum = optionalFiniteNumber(record.minimum, 'minimum'); + const maximum = optionalFiniteNumber(record.maximum, 'maximum'); + if (minimum !== undefined && maximum !== undefined && minimum > maximum) { + throw new Error('Invalid form number range'); + } + const field: Extract = { + ...common, + kind: record.kind, + ...(record.default === undefined + ? {} + : { default: finiteNumber(record.default, 'form number default') }), + ...(minimum === undefined ? {} : { minimum }), + ...(maximum === undefined ? {} : { maximum }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid form number default'); + } + return deepFreeze(field); + } + if (record.kind === 'boolean') { + exact(record, FORM_BOOLEAN_FIELD_SHAPE, 'boolean form field'); + return Object.freeze({ + ...common, + kind: 'boolean', + ...(record.default === undefined + ? {} + : { default: boolean(record.default, 'form boolean default') }), + }); + } + if (record.kind === 'single_select') { + exact(record, FORM_SINGLE_SELECT_FIELD_SHAPE, 'single-select form field'); + const options = decodeFormOptions(record.options); + const field: Extract = { + ...common, + kind: 'single_select', + options, + ...(record.default === undefined + ? {} + : { + default: boundedText( + record.default, + 'single-select default', + INTERACTION_FORM_VALUE_MAX_BYTES, + ), + }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid single-select default'); + } + return deepFreeze(field); + } + if (record.kind === 'multi_select') { + exact(record, FORM_MULTI_SELECT_FIELD_SHAPE, 'multi-select form field'); + const options = decodeFormOptions(record.options); + const minItems = optionalBoundedCount(record.minItems, 'minItems', options.length); + const maxItems = optionalBoundedCount(record.maxItems, 'maxItems', options.length); + if (minItems !== undefined && maxItems !== undefined && minItems > maxItems) { + throw new Error('Invalid multi-select item range'); + } + const field: Extract = { + ...common, + kind: 'multi_select', + options, + ...(record.default === undefined + ? {} + : { + default: decodeFormStringArray(record.default, 'multi-select default'), + }), + ...(minItems === undefined ? {} : { minItems }), + ...(maxItems === undefined ? {} : { maxItems }), + }; + if (field.default !== undefined && !isInteractionFormFieldValueValid(field, field.default)) { + throw new Error('Invalid multi-select default'); + } + return deepFreeze(field); + } + throw new Error('Invalid form field kind'); +} + +function decodeFormFieldBase(record: Record): InteractionFormFieldBase { + return { + name: boundedString(record.name, 'form field name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES), + label: boundedString(record.label, 'form field label', INTERACTION_FORM_FIELD_LABEL_MAX_BYTES), + required: boolean(record.required, 'form field required'), + ...(record.description === undefined + ? {} + : { + description: boundedText( + record.description, + 'form field description', + INTERACTION_FORM_FIELD_DESCRIPTION_MAX_BYTES, + ), + }), + }; +} + +function decodeFormOptions(value: unknown): readonly InteractionFormOption[] { + const options = plainArray(value, 'form options', 1, INTERACTION_FORM_MAX_OPTIONS).map( + (candidate) => { + const record = plainRecord(candidate, 'Interaction form option'); + exact(record, FORM_OPTION_SHAPE, 'form option'); + return Object.freeze({ + value: boundedText(record.value, 'form option value', INTERACTION_FORM_VALUE_MAX_BYTES), + label: boundedString( + record.label, + 'form option label', + INTERACTION_FORM_FIELD_LABEL_MAX_BYTES, + ), + }); + }, + ); + if (new Set(options.map((option) => option.value)).size !== options.length) { + throw new Error('Duplicate form option value'); + } + if (new Set(options.map((option) => option.label)).size !== options.length) { + throw new Error('Duplicate form option label'); + } + return Object.freeze(options); +} + +function decodeFormValues(value: unknown): Readonly> { + const record = plainRecord(value, 'Interaction form values'); + const entries = Object.entries(record); + if (entries.length > INTERACTION_FORM_MAX_FIELDS) throw new Error('Too many form values'); + return deepFreeze( + Object.fromEntries( + entries.map(([name, candidate]) => [ + boundedString(name, 'form value name', INTERACTION_FORM_FIELD_NAME_MAX_BYTES), + decodeFormValue(candidate), + ]), + ), + ); +} + +function decodeFormValue(value: unknown): InteractionFormValue { + if (typeof value === 'string') { + return boundedText(value, 'form string value', INTERACTION_FORM_VALUE_MAX_BYTES); + } + if (typeof value === 'number') return finiteNumber(value, 'form number value'); + if (typeof value === 'boolean') return value; + return decodeFormStringArray(value, 'form multi-select value'); +} + +function decodeFormStringArray(value: unknown, label: string): readonly string[] { + const values = plainArray(value, label, 0, INTERACTION_FORM_MAX_OPTIONS).map((candidate) => + boundedText(candidate, label, INTERACTION_FORM_VALUE_MAX_BYTES), + ); + if (new Set(values).size !== values.length) throw new Error(`Duplicate ${label}`); + return Object.freeze(values); +} + +export function interactionFormAnswerMatchesRequest( + request: InteractionFormRequest, + answer: InteractionFormAnswer, +): boolean { + if (answer.action !== 'accept') return true; + const fields = new Map(request.fields.map((field) => [field.name, field])); + const names = Object.keys(answer.values); + if (names.some((name) => !fields.has(name))) return false; + for (const field of request.fields) { + if (!Object.hasOwn(answer.values, field.name)) { + if (field.required) return false; + continue; + } + if (!isInteractionFormFieldValueValid(field, answer.values[field.name])) return false; + } + return true; +} + +export function isInteractionFormFieldValueValid( + field: InteractionFormField, + value: InteractionFormValue | undefined, +): boolean { + if (value === undefined) return false; + if (field.kind === 'string') { + if (typeof value !== 'string') return false; + const length = [...value].length; + return ( + (field.minLength === undefined || length >= field.minLength) && + (field.maxLength === undefined || length <= field.maxLength) && + matchesStringFormat(value, field.format) + ); + } + if (field.kind === 'number' || field.kind === 'integer') { + return ( + typeof value === 'number' && + Number.isFinite(value) && + (field.kind !== 'integer' || Number.isSafeInteger(value)) && + (field.minimum === undefined || value >= field.minimum) && + (field.maximum === undefined || value <= field.maximum) + ); + } + if (field.kind === 'boolean') return typeof value === 'boolean'; + if (field.kind === 'single_select') { + return typeof value === 'string' && field.options.some((option) => option.value === value); + } + if (!Array.isArray(value) || value.some((candidate) => typeof candidate !== 'string')) { + return false; + } + return ( + new Set(value).size === value.length && + (field.minItems === undefined || value.length >= field.minItems) && + (field.maxItems === undefined || value.length <= field.maxItems) && + value.every((candidate) => field.options.some((option) => option.value === candidate)) + ); +} + +function matchesStringFormat( + value: string, + format: Extract['format'], +): boolean { + if (format === undefined) return true; + if (format === 'email') return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); + if (format === 'uri') { + try { + return new URL(value).protocol.length > 1; + } catch { + return false; + } + } + if (format === 'date') { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return false; + return isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])); + } + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/.exec( + value, + ); + if (!match) return false; + return ( + isValidCalendarDate(Number(match[1]), Number(match[2]), Number(match[3])) && + Number(match[4]) <= 23 && + Number(match[5]) <= 59 && + Number(match[6]) <= 59 && + (match[7] === undefined || Number(match[7]) <= 23) && + (match[8] === undefined || Number(match[8]) <= 59) && + Number.isFinite(Date.parse(value)) + ); +} + +function isValidCalendarDate(year: number, month: number, day: number): boolean { + if (month < 1 || month > 12 || day < 1) return false; + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]!; +} + +/** Reject a form that can be admitted but can never produce a bounded accepted answer. */ +function assertFormHasAcceptedAnswer(request: InteractionFormRequest): void { + const values: Record = {}; + for (const field of request.fields) { + if (!field.required) continue; + values[field.name] = formFieldWitness(field); + } + const answer = { kind: 'form' as const, action: 'accept' as const, values }; + if (!interactionFormAnswerMatchesRequest(request, answer)) { + throw new Error('Interaction form has no valid accepted answer'); + } + serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer'); + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); + assertEveryFormAnswerFitsCanonicalOutcome(request); +} + +/** + * Admission must reserve the whole legal answer envelope, not only a smallest + * witness. This intentionally over-approximates format-constrained strings: + * rejecting an over-large form is safe, whereas accepting one that can later + * reject a valid user answer strands the interaction. + */ +function assertEveryFormAnswerFitsCanonicalOutcome(request: InteractionFormRequest): void { + const values = Object.fromEntries( + request.fields.map((field) => [field.name, formFieldMaximumEnvelope(field)]), + ); + const answer = { kind: 'form' as const, action: 'accept' as const, values }; + serializedLimit(answer, INTERACTION_ANSWER_SERIALIZED_MAX_BYTES, 'Interaction form answer'); + assertAcceptedFormAnswerFitsCanonicalOutcome(answer); +} + +function formFieldMaximumEnvelope(field: InteractionFormField): InteractionFormValue { + if (field.kind === 'string') { + // Admission must prove every legal answer serializes, so reserve the worst + // value inside the format's legal language, measured post-serialization. + // - date is a fixed-length language over [0-9-]; nothing JSON-escapes. + // - date-time adds only digits and `.:TZ+-`; nothing JSON-escapes, and the + // fractional seconds leave the length unbounded up to the field caps. + // - every other string may legally hold control characters, which + // JSON-escape to six bytes per code point (one raw byte each). + if (field.format === 'date') return '0000-01-01'; + const maximumCodePoints = Math.min( + field.maxLength ?? INTERACTION_FORM_VALUE_MAX_BYTES, + INTERACTION_FORM_VALUE_MAX_BYTES, + ); + if (field.format === 'date-time') return '0'.repeat(maximumCodePoints); + return '\u0001'.repeat(maximumCodePoints); + } + if (field.kind === 'number' || field.kind === 'integer') return -1.7976931348623157e308; + if (field.kind === 'boolean') return false; + if (field.kind === 'single_select') { + return field.options.reduce( + (longest, option) => + serializedByteLength(option.value) > serializedByteLength(longest) ? option.value : longest, + field.options[0]!.value, + ); + } + return [...field.options] + .sort((left, right) => serializedByteLength(right.value) - serializedByteLength(left.value)) + .slice(0, field.maxItems ?? field.options.length) + .map((option) => option.value); +} + +function serializedByteLength(value: string): number { + return UTF8.encode(JSON.stringify(value)).byteLength; +} + +function assertAcceptedFormAnswerFitsCanonicalOutcome( + answer: Extract, +): void { + serializedLimit( + { + kind: 'form_answer', + action: 'accept', + values: answer.values, + committedAt: Number.MAX_SAFE_INTEGER, + } satisfies Extract, + INTERACTION_OUTCOME_SERIALIZED_MAX_BYTES, + 'Interaction form outcome', + ); +} + +function formFieldWitness(field: InteractionFormField): InteractionFormValue { + if (field.default !== undefined) return field.default; + if (field.kind === 'string') { + const minLength = field.minLength ?? 0; + let value: string; + if (field.format === 'email') { + value = `${'a'.repeat(Math.max(1, minLength - 5))}@b.co`; + } else if (field.format === 'uri') { + const base = 'https://a.co/'; + value = `${base}${'a'.repeat(Math.max(0, minLength - base.length))}`; + } else if (field.format === 'date') { + value = '2000-01-01'; + } else if (field.format === 'date-time') { + const base = '2000-01-01T00:00:00'; + const fractionalLength = minLength <= 20 ? 0 : Math.max(1, minLength - 21); + value = fractionalLength === 0 ? `${base}Z` : `${base}.${'0'.repeat(fractionalLength)}Z`; + } else { + value = 'a'.repeat(minLength); + } + if (!isInteractionFormFieldValueValid(field, value)) { + throw new Error(`Interaction form field ${field.name} has no valid bounded value`); + } + return value; + } + if (field.kind === 'number' || field.kind === 'integer') { + const lower = field.minimum ?? Number.NEGATIVE_INFINITY; + const upper = field.maximum ?? Number.POSITIVE_INFINITY; + const value = + field.kind === 'integer' + ? lower > 0 + ? Math.ceil(lower) + : upper < 0 + ? Math.floor(upper) + : 0 + : lower > 0 + ? lower + : upper < 0 + ? upper + : 0; + if (!isInteractionFormFieldValueValid(field, value)) { + throw new Error(`Interaction form field ${field.name} has no valid bounded value`); + } + return value; + } + if (field.kind === 'boolean') return false; + if (field.kind === 'single_select') return field.options[0].value; + return field.options.slice(0, field.minItems ?? 0).map((option) => option.value); +} + function decodeAnswers(value: unknown): readonly (string | null)[] { return Object.freeze( plainArray(value, 'answers', 1, INTERACTION_MAX_QUESTIONS).map((answer) => @@ -687,6 +1441,13 @@ function boundedString(value: unknown, label: string, maxBytes: number): string return value; } +function boundedText(value: unknown, label: string, maxBytes: number): string { + if (typeof value !== 'string' || UTF8.encode(value).byteLength > maxBytes) { + throw new Error(`Invalid ${label}`); + } + return value; +} + function boundedCharacterString(value: unknown, label: string, maxChars: number): string { if (typeof value !== 'string' || value.length === 0 || value.length > maxChars) throw new Error(`Invalid ${label}`); @@ -704,6 +1465,26 @@ function safeInteger(value: unknown, label: string, positive: boolean): number { return value; } +function optionalBoundedCount( + value: unknown, + label: string, + max = INTERACTION_FORM_VALUE_MAX_BYTES, +): number | undefined { + if (value === undefined) return undefined; + const count = safeInteger(value, label, false); + if (count > max) throw new Error(`Invalid ${label}`); + return count; +} + +function finiteNumber(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`Invalid ${label}`); + return Object.is(value, -0) ? 0 : value; +} + +function optionalFiniteNumber(value: unknown, label: string): number | undefined { + return value === undefined ? undefined : finiteNumber(value, label); +} + function oneOf( value: unknown, values: T, @@ -731,6 +1512,26 @@ function equalAnswers( return left.length === right.length && left.every((answer, index) => answer === right[index]); } +function equalFormValues( + left: Readonly>, + right: Readonly>, +): boolean { + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key, index) => { + if (key !== rightKeys[index]) return false; + const leftValue = left[key]; + const rightValue = right[key]; + return Array.isArray(leftValue) && Array.isArray(rightValue) + ? leftValue.length === rightValue.length && + leftValue.every((candidate, valueIndex) => candidate === rightValue[valueIndex]) + : leftValue === rightValue; + }) + ); +} + function deepFreeze(value: T): T { if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { for (const nested of Object.values(value as Record)) deepFreeze(nested); diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index 09738b35ad..4365735f99 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -39,7 +39,12 @@ import { type MessageContent, type PermissionClosureReason, } from './events.js'; -import { INTERACTION_ID_MAX_BYTES, INTERACTION_TOOL_NAME_MAX_BYTES } from './interaction.js'; +import { + INTERACTION_ID_MAX_BYTES, + INTERACTION_TOOL_NAME_MAX_BYTES, + decodeInteractionRequest, + type InteractionFormInput, +} from './interaction.js'; import type { PermissionRequestPayload, PermissionResponse } from './permission.js'; import { decodeTurnOrigin, type TurnOrigin } from './turn-origin.js'; import type { UserQuestionRequest } from './user-question.js'; @@ -347,6 +352,13 @@ interface RuntimeEventAnswerAcceptedIdentity { export interface RuntimeEventUserQuestionAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} +export interface RuntimeEventFormRequest extends InteractionFormInput { + requestId: string; + toolUseId: string; +} + +export interface RuntimeEventFormAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} + export interface RuntimeEventPermissionAnswerAccepted extends RuntimeEventAnswerAcceptedIdentity {} export interface RuntimeEventPermissionClosureAccepted { @@ -376,6 +388,10 @@ export interface RuntimeEventActions { userQuestionRequest?: UserQuestionRequest; /** Audit fact only; the canonical answer remains in InteractionStore. */ userQuestionAnswerAccepted?: RuntimeEventUserQuestionAnswerAccepted; + /** A provider-neutral structured form raised by a tool call. */ + formRequest?: RuntimeEventFormRequest; + /** Audit fact only; the canonical form result remains in InteractionStore. */ + formAnswerAccepted?: RuntimeEventFormAnswerAccepted; /** Hand off the invocation to another agent (multi-agent transfer). */ transferToAgent?: string; /** Marks the event that closes the invocation. */ @@ -574,6 +590,8 @@ const RUNTIME_ACTIONS_SHAPE = defineObjectShape()( 'permissionClosureAccepted', 'userQuestionRequest', 'userQuestionAnswerAccepted', + 'formRequest', + 'formAnswerAccepted', 'transferToAgent', 'endInvocation', 'tokenUsage', @@ -861,6 +879,9 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { (value.userQuestionRequest === undefined || isUserQuestionRequest(value.userQuestionRequest)) && (value.userQuestionAnswerAccepted === undefined || isRuntimeEventAnswerAcceptedIdentity(value.userQuestionAnswerAccepted)) && + (value.formRequest === undefined || isRuntimeEventFormRequest(value.formRequest)) && + (value.formAnswerAccepted === undefined || + isRuntimeEventAnswerAcceptedIdentity(value.formAnswerAccepted)) && isOptionalString(value.transferToAgent) && (value.endInvocation === undefined || typeof value.endInvocation === 'boolean') && (value.tokenUsage === undefined || isRuntimeTokenUsage(value.tokenUsage)) && @@ -876,6 +897,24 @@ function isRuntimeEventActions(value: unknown): value is RuntimeEventActions { ); } +function isRuntimeEventFormRequest(value: unknown): value is RuntimeEventFormRequest { + if (!isRecord(value)) return false; + const { requestId, ...request } = value; + if ( + typeof requestId !== 'string' || + requestId.length === 0 || + UTF8.encode(requestId).byteLength > INTERACTION_ID_MAX_BYTES + ) { + return false; + } + try { + decodeInteractionRequest({ kind: 'form', ...request }); + return true; + } catch { + return false; + } +} + function isRuntimeManagedMutationTerminal( value: unknown, ): value is RuntimeEventManagedMutationTerminalV1 { diff --git a/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts b/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts index a8fc42cd59..a66a4bbfbf 100644 --- a/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/agent-graph-protocol.test.ts @@ -94,6 +94,16 @@ describe('Agent Graph Client protocol', () => { const inspection = operatorInspection(snapshot); assert.deepEqual(decodeAgentGraphClientSnapshot(snapshot), snapshot); assert.deepEqual(decodeAgentGraphOperatorInspection(inspection), inspection); + const formActivity = { + ...activity(), + facets: ['form_request'] as const, + signals: [{ kind: 'attention' as const, reason: 'form_request' as const }], + }; + assert.deepEqual( + decodeAgentGraphClientSnapshot({ ...snapshot, recentActivity: [formActivity] }) + .recentActivity, + [formActivity], + ); AGENT_GRAPH_OPERATION_SPECS['agent.graph.query'].assertOutputForInput?.( { rootSessionId: 'root-1' }, snapshot, diff --git a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts index bae9e4b7fc..3b978c79bd 100644 --- a/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-coordinator.test.ts @@ -24,12 +24,17 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, test } from 'node:test'; import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; -import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events'; +import type { + FormRequestEvent, + SandboxBoundaryRequestEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; import { bindRuntimeInteractionRun, RuntimeInteractionAdmissionRejectedError, RuntimeInteractionFailStopError, type RuntimeInteractionRunIdentity, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '@maka/runtime/interaction-authority'; @@ -163,6 +168,80 @@ describe('HostInteractionCoordinator', () => { }); }); + test('validates and commits one canonical form answer before resuming its exact continuation', async () => { + await withStore(async ({ store }) => { + const order: string[] = []; + const coordinator = createCoordinator(store, { + refreshCanonicalContinuity: async () => { + const record = await store.readInteraction('form_1'); + order.push(record?.outcome ? 'refresh:answered' : 'refresh:pending'); + }, + }); + const owner = coordinator.bindRun(RUN); + assert.ok(owner.acceptFormRequest); + await owner.acceptFormRequest({ + request: formEvent('form_1', 10), + continuation: formContinuation('form_1', { + answer: (answer) => order.push(`apply:${answer.action}`), + }), + }); + assert.deepEqual(order, ['refresh:pending']); + + const invalid = await coordinator.handlers['interaction.answer']( + { + sessionId: RUN.sessionId, + interactionId: 'form_1', + answer: { kind: 'form', action: 'accept', values: { replicas: 0 } }, + }, + connection(), + ); + assert.equal(invalid.ok, false); + if (!invalid.ok) assert.equal(invalid.error.code, 'operation_conflict'); + assert.deepEqual(order, ['refresh:pending']); + + const answer = { + sessionId: RUN.sessionId, + interactionId: 'form_1', + answer: { + kind: 'form', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }, + } as const; + const [first, second] = await Promise.all([ + coordinator.handlers['interaction.answer'](answer, connection()), + coordinator.handlers['interaction.answer'](answer, connection()), + ]); + assert.equal(first.ok, true); + assert.equal(second.ok, true); + assert.deepEqual(order, ['refresh:pending', 'refresh:answered', 'apply:accept']); + const record = await store.readInteraction('form_1'); + assert.deepEqual(record?.outcome?.outcome, { + kind: 'form_answer', + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + committedAt: 101, + }); + + const closures: string[] = []; + await owner.acceptFormRequest({ + request: formEvent('form_2', 11), + continuation: formContinuation('form_2', { + closure: (reason) => closures.push(reason), + }), + }); + await owner.close('turn_terminal'); + assert.deepEqual(closures, ['turn_terminal']); + assert.deepEqual((await store.readInteraction('form_2'))?.outcome?.outcome, { + kind: 'closure', + reason: 'turn_terminal', + committedAt: 102, + }); + owner.release(); + await coordinator.close(); + }); + }); + test('settles a sandbox boundary once through the canonical boundary Store and wakes its graph', async () => { await withStore(async ({ owner, store, stores }) => { const workspace = join(owner.capability.canonicalPath, 'workspace'); @@ -774,6 +853,59 @@ function questionEvent(requestId: string, ts: number): UserQuestionRequestEvent }; } +function formEvent(requestId: string, ts: number): FormRequestEvent { + return { + id: `event_${requestId}`, + type: 'form_request', + turnId: RUN.turnId, + ts, + requestId, + toolUseId: `tool_${requestId}`, + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Synthetic provider' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + }, + ], + }; +} + +function formContinuation( + requestId: string, + callbacks: { + answer?: (answer: Parameters[0]) => unknown; + closure?: (reason: Parameters[0]) => unknown; + } = {}, +): RuntimeFormContinuation { + return { + ...RUN, + requestId, + waitForPublication: async () => {}, + applyAnswer: async (answer) => { + await callbacks.answer?.(answer); + }, + applyClosure: async (reason) => { + await callbacks.closure?.(reason); + }, + }; +} + function questionContinuation( requestId: string, callbacks: { diff --git a/packages/runtime-host/src/__tests__/interaction-protocol.test.ts b/packages/runtime-host/src/__tests__/interaction-protocol.test.ts index ba0e1ee51a..ee60f66272 100644 --- a/packages/runtime-host/src/__tests__/interaction-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/interaction-protocol.test.ts @@ -169,6 +169,56 @@ describe('Runtime Host Interaction protocol', () => { ); }); + test('decodes a form snapshot and exact form answer without widening the wire', () => { + const snapshot = { + schemaVersion: 1, + interactionId: 'form-1', + sessionId: 'session-1', + turnId: 'turn-1', + runId: 'run-1', + revision: 1, + status: 'pending', + outcome: null, + request: { + kind: 'form', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy', source: 'Example server' }, + fields: [ + { + kind: 'boolean', + name: 'confirm', + label: 'Confirm', + required: true, + }, + ], + }, + } as const; + assert.deepEqual(decodeInteractionSnapshot(snapshot), snapshot); + + const frame = { + requestId: 'answer-form', + operation: 'interaction.answer', + input: { + sessionId: 'session-1', + interactionId: 'form-1', + answer: { kind: 'form', action: 'accept', values: { confirm: true } }, + }, + } as const; + assert.deepEqual(decodeClientFrame(frame), frame); + assert.throws( + () => + decodeClientFrame({ + ...frame, + input: { + ...frame.input, + answer: { ...frame.input.answer, requestState: 'must-not-cross-host-wire' }, + }, + }), + isInvalidFrame, + ); + }); + test('returns the canonical winner only for an equivalent normalized answer retry', () => { const request = storedRequest('interaction-1', 10); const answered: InteractionRecord & { outcome: NonNullable } = { diff --git a/packages/runtime-host/src/adapter/session-projector.ts b/packages/runtime-host/src/adapter/session-projector.ts index 5e85869232..3687f6d389 100644 --- a/packages/runtime-host/src/adapter/session-projector.ts +++ b/packages/runtime-host/src/adapter/session-projector.ts @@ -567,6 +567,17 @@ export function projectRuntimeHostInteractionRequest( }, ]; } + if (interaction.request.kind === 'form') { + return [ + { + type: 'form_request', + ...base, + message: interaction.request.message, + requester: structuredClone(interaction.request.requester), + fields: structuredClone(interaction.request.fields), + }, + ]; + } if (interaction.request.kind === 'sandbox_boundary') { return [ { diff --git a/packages/runtime-host/src/protocol/agent-graph.ts b/packages/runtime-host/src/protocol/agent-graph.ts index faa0c7d246..02c9842c00 100644 --- a/packages/runtime-host/src/protocol/agent-graph.ts +++ b/packages/runtime-host/src/protocol/agent-graph.ts @@ -112,6 +112,7 @@ export type AgentGraphRecordFacet = | 'permission_request' | 'permission_decision' | 'user_question_request' + | 'form_request' | 'transfer' | 'usage' | 'completed' @@ -121,7 +122,10 @@ export type AgentGraphRecordFacet = | 'runtime_fact'; export type AgentGraphSupervisorSignal = - | { readonly kind: 'attention'; readonly reason: 'permission_request' | 'user_question_request' } + | { + readonly kind: 'attention'; + readonly reason: 'permission_request' | 'user_question_request' | 'form_request'; + } | { readonly kind: 'terminal'; readonly status: 'completed' | 'failed' | 'aborted' | 'cancelled'; @@ -1194,7 +1198,11 @@ function decodeSignal(value: unknown): AgentGraphSupervisorSignal { const record = requireShapedRecord(value, 'agent graph signal', ['kind'], ['reason', 'status']); if (record.kind === 'attention') { requireExactRecord(record, 'agent graph attention signal', ['kind', 'reason']); - if (record.reason !== 'permission_request' && record.reason !== 'user_question_request') { + if ( + record.reason !== 'permission_request' && + record.reason !== 'user_question_request' && + record.reason !== 'form_request' + ) { throw invalidProtocolFrame('Invalid agent graph attention reason'); } return { kind: record.kind, reason: record.reason }; @@ -1429,6 +1437,7 @@ function requireFacet(value: unknown): AgentGraphRecordFacet { value === 'permission_request' || value === 'permission_decision' || value === 'user_question_request' || + value === 'form_request' || value === 'transfer' || value === 'usage' || value === 'completed' || diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 077a0d58c6..1226da4808 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 106 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 107 as const; +// 107: Session Interaction snapshots, forwarded Runtime events, and Agent Graph +// activity may carry the provider-neutral `form` request/answer contract. // 106: Session transcripts gain five `system_note` kinds // (`context_provider_dropping`, `context_window_suggestion`, // `context_window_overrun`, `context_reported_window_exceeded`, diff --git a/packages/runtime-host/src/protocol/interaction.ts b/packages/runtime-host/src/protocol/interaction.ts index 3fe0907d9d..c1115ac447 100644 --- a/packages/runtime-host/src/protocol/interaction.ts +++ b/packages/runtime-host/src/protocol/interaction.ts @@ -25,6 +25,11 @@ import { type InteractionAnswer, type InteractionCanonicalOutcome, type InteractionClosureReason, + type InteractionFormAnswer, + type InteractionFormField, + type InteractionFormRequest, + type InteractionFormResult, + type InteractionFormValue, type InteractionPermissionAnswer, type InteractionPermissionDecisionFields, type InteractionPermissionPrompt, @@ -42,6 +47,11 @@ export type { InteractionAnswer, InteractionCanonicalOutcome, InteractionClosureReason, + InteractionFormAnswer, + InteractionFormField, + InteractionFormRequest, + InteractionFormResult, + InteractionFormValue, InteractionPermissionAnswer, InteractionPermissionDecisionFields, InteractionPermissionPrompt, diff --git a/packages/runtime-host/src/server/interaction-coordinator.ts b/packages/runtime-host/src/server/interaction-coordinator.ts index 68b36e0dc5..589517d04c 100644 --- a/packages/runtime-host/src/server/interaction-coordinator.ts +++ b/packages/runtime-host/src/server/interaction-coordinator.ts @@ -23,11 +23,16 @@ import type { ClientCapabilityGrantTarget, ClientCapabilitySessionGrant, } from '@maka/core/client-capability-grant'; -import type { SandboxBoundaryRequestEvent, UserQuestionRequestEvent } from '@maka/core/events'; +import type { + FormRequestEvent, + SandboxBoundaryRequestEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; import { isInteractionAnswerValidForRequest, projectInteractionClientCapabilityRequest, projectInteractionSandboxBoundaryRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, type InteractionCanonicalOutcome, type InteractionClosureReason, @@ -45,6 +50,7 @@ import { type RuntimeInteractionRunClosureReason, type RuntimeInteractionRunIdentity, type RuntimeInteractionRunOwner, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '@maka/runtime/interaction-authority'; @@ -70,8 +76,10 @@ import { projectInteractionRecord, projectSandboxBoundaryInteraction, projectSessionInteractions, + formCanonicalOutcome, questionCanonicalOutcome, runtimeQuestionOutcome, + runtimeFormOutcome, } from './interaction-projection.js'; import type { InteractionOperationHandlerMap } from './operation-dispatcher.js'; import { type SessionAdmissionLease, SessionAdmissionGate } from './session-admission-gate.js'; @@ -128,6 +136,12 @@ interface LiveQuestionEntry extends LiveEntryBase { readonly continuation: RuntimeUserQuestionContinuation; } +interface LiveFormEntry extends LiveEntryBase { + readonly kind: 'form'; + readonly request: StoredInteractionRequest; + readonly continuation: RuntimeFormContinuation; +} + interface LiveSandboxBoundaryEntry extends LiveEntryBase { readonly kind: 'sandbox_boundary'; readonly boundaryRequest: SandboxBoundaryRequest; @@ -142,10 +156,11 @@ interface LiveClientCapabilityEntry extends LiveEntryBase { readonly reject: (error: unknown) => void; } -type LiveStoredEntry = LiveQuestionEntry | LiveClientCapabilityEntry; +type LiveStoredEntry = LiveQuestionEntry | LiveFormEntry | LiveClientCapabilityEntry; type LiveEntry = LiveStoredEntry | LiveSandboxBoundaryEntry; type LiveStoredCandidate = | Omit + | Omit | Omit; interface CommittedEntry { @@ -237,6 +252,8 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { acceptUserQuestionRequest: ( input: Parameters[0], ) => this.#acceptUserQuestionRequest(run, input), + acceptFormRequest: (input: Parameters[0]) => + this.#acceptFormRequest(run, input), acceptSandboxBoundaryRequest: ( input: Parameters[0], ) => this.#acceptSandboxBoundaryRequest(run, input), @@ -481,6 +498,45 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } + #acceptFormRequest( + run: BoundRun, + input: Parameters[0], + ): Promise { + try { + this.#assertAcceptable(run, input.request, input.continuation); + let request: ReturnType; + try { + request = projectInteractionFormRequest({ + toolUseId: input.request.toolUseId, + message: input.request.message, + requester: input.request.requester, + fields: input.request.fields, + }); + } catch { + return rejected( + new RuntimeInteractionAdmissionRejectedError( + input.continuation.requestId, + 'invalid_request', + ), + ); + } + return observed( + this.#accept(run, { + kind: 'form', + request: { + ...runIdentity(run), + requestId: input.continuation.requestId, + createdAt: input.request.ts, + request, + }, + continuation: input.continuation, + }).then(() => undefined), + ); + } catch (error) { + return rejected(error); + } + } + #acceptSandboxBoundaryRequest( run: BoundRun, input: Parameters[0], @@ -645,9 +701,12 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ), ); } - const questions = await this.#readPending({ sessionId: run.sessionId }); + const storedInteractions = await this.#readPending({ sessionId: run.sessionId }); const sandboxBoundaries = await this.#readPendingSandboxBoundaries(run.sessionId); - if (questions.length + sandboxBoundaries.length >= INTERACTION_MAX_PENDING_PER_SESSION) { + if ( + storedInteractions.length + sandboxBoundaries.length >= + INTERACTION_MAX_PENDING_PER_SESSION + ) { throw new RuntimeInteractionAdmissionRejectedError( input.request.requestId, 'capacity_exceeded', @@ -664,7 +723,10 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { turnId: run.turnId, runId: run.runId, }; - const projection = projectSessionInteractions(questions, [...sandboxBoundaries, candidate]); + const projection = projectSessionInteractions(storedInteractions, [ + ...sandboxBoundaries, + candidate, + ]); if (!(await this.#preflightSessionSnapshot(run.sessionId, projection, admission))) { throw new RuntimeInteractionAdmissionRejectedError( input.request.requestId, @@ -743,7 +805,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { if (record.request.sessionId !== input.sessionId) return interactionNotFound(); return record.request.request.kind === 'client_capability' ? this.#answerClientCapability(record, input.answer, admission) - : this.#answerQuestion(record, input.answer, admission); + : this.#answerStoredInteraction(record, input.answer, admission); } const sandboxBoundary = await this.#readSandboxBoundary( input.sessionId, @@ -760,7 +822,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ); } - async #answerQuestion( + async #answerStoredInteraction( record: InteractionRecord, answer: InteractionAnswerInput['answer'], admission: SessionAdmissionLease, @@ -771,18 +833,26 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { : operationConflict('Sandbox boundary authority is not stored in InteractionStore'); } if (record.outcome) return answerOutcome(recordWithOutcome(record), answer); - if (record.request.request.kind !== 'question' || answer.kind !== 'question') { + if ( + (record.request.request.kind !== 'question' && record.request.request.kind !== 'form') || + record.request.request.kind !== answer.kind + ) { return operationConflict('Interaction answer does not match the pending request'); } if (!isInteractionAnswerValidForRequest(record.request.request, answer)) { return operationConflict('Interaction answer does not match the pending request'); } - const entry = this.#requireLiveQuestion(record.request); - const outcome = await this.#commitAnswer( - entry, - questionCanonicalOutcome(answer, this.#now()), - admission, - ); + const entry = this.#requireLiveStored(record.request); + const candidate = + answer.kind === 'question' + ? questionCanonicalOutcome(answer, this.#now()) + : answer.kind === 'form' + ? formCanonicalOutcome(answer, this.#now()) + : undefined; + if (!candidate) { + return operationConflict('Interaction answer does not match the pending request'); + } + const outcome = await this.#commitAnswer(entry, candidate, admission); return answerOutcome({ request: record.request, outcome }, answer); } @@ -861,8 +931,8 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } async #commitAnswer( - entry: LiveQuestionEntry, - candidate: Extract, + entry: LiveStoredEntry, + candidate: Extract, admission: SessionAdmissionLease, ): Promise { const target = await this.#commitOutcome(entry.request, candidate); @@ -950,10 +1020,7 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { const pending = await this.#readPending(runIdentity(run)); const committed: CommittedEntry[] = []; for (const request of pending.sort(compareStoredInteractionRequests)) { - const entry = - request.request.kind === 'client_capability' - ? this.#requireLiveClientCapability(request) - : this.#requireLiveQuestion(request); + const entry = this.#requireLiveStored(request); const closureOutcome = { kind: 'closure' as const, reason: closure.reason, @@ -1057,25 +1124,35 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { const sessions = new Map< string, { - questions: StoredInteractionRequest[]; + storedInteractions: StoredInteractionRequest[]; sandboxBoundaries: SandboxBoundaryRequest[]; } >(); - const pendingQuestions = await this.#readPending(); + const pendingInteractions = await this.#readPending(); const pendingSandboxBoundaries = await this.#readAllPendingSandboxBoundaries(); - for (const request of pendingQuestions) { + for (const request of pendingInteractions) { const requests = sessions.get(request.sessionId); - if (requests) requests.questions.push(request); - else sessions.set(request.sessionId, { questions: [request], sandboxBoundaries: [] }); + if (requests) requests.storedInteractions.push(request); + else { + sessions.set(request.sessionId, { + storedInteractions: [request], + sandboxBoundaries: [], + }); + } } for (const request of pendingSandboxBoundaries) { const requests = sessions.get(request.sessionId); if (requests) requests.sandboxBoundaries.push(request); - else sessions.set(request.sessionId, { questions: [], sandboxBoundaries: [request] }); + else { + sessions.set(request.sessionId, { + storedInteractions: [], + sandboxBoundaries: [request], + }); + } } for (const [sessionId, requests] of sessions) { if ( - requests.questions.length + requests.sandboxBoundaries.length > + requests.storedInteractions.length + requests.sandboxBoundaries.length > INTERACTION_MAX_PENDING_PER_SESSION ) { throw new RuntimeInteractionInvariantError( @@ -1083,7 +1160,9 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { ); } await this.#sessionAdmission.run(sessionId, async (admission) => { - for (const request of requests.questions.sort(compareStoredInteractionRequests)) { + for (const request of requests.storedInteractions.sort( + compareStoredInteractionRequests, + )) { const closure = { kind: 'closure' as const, reason: 'host_restarted' as const, @@ -1288,11 +1367,20 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { return; } try { - const projected = runtimeQuestionOutcome(outcome.outcome); + const projected = + entry.kind === 'question' + ? runtimeQuestionOutcome(outcome.outcome) + : runtimeFormOutcome(outcome.outcome); if (projected.kind === 'closure') { await entry.continuation.applyClosure(projected.reason); - } else { + } else if (entry.kind === 'question' && projected.kind === 'question_answer') { await entry.continuation.applyAnswer(projected.answer); + } else if (entry.kind === 'form' && projected.kind === 'form_answer') { + await entry.continuation.applyAnswer(projected.answer); + } else { + throw new RuntimeInteractionInvariantError( + `Stored Interaction ${entry.request.requestId} projected the wrong answer kind`, + ); } } catch (error) { throw this.#poison(error); @@ -1352,17 +1440,18 @@ export class HostInteractionCoordinator implements RuntimeInteractionAuthority { } } - #requireLiveQuestion(request: StoredInteractionRequest): LiveQuestionEntry { + #requireLiveStored(request: StoredInteractionRequest): LiveStoredEntry { const entry = this.#live.get(request.requestId); if ( !entry || - entry.kind !== 'question' || + entry.kind === 'sandbox_boundary' || + entry.kind !== request.request.kind || entry.phase !== 'live' || !isDeepStrictEqual(entry.request, request) ) { throw this.#poison( new RuntimeInteractionInvariantError( - `Pending question Interaction ${request.requestId} has no exact live continuation`, + `Pending stored Interaction ${request.requestId} has no exact live continuation`, ), ); } diff --git a/packages/runtime-host/src/server/interaction-projection.ts b/packages/runtime-host/src/server/interaction-projection.ts index fe4e60a4ab..98e614e510 100644 --- a/packages/runtime-host/src/server/interaction-projection.ts +++ b/packages/runtime-host/src/server/interaction-projection.ts @@ -27,6 +27,7 @@ import { import type { SandboxBoundaryRequest } from '@maka/core/sandbox-boundary'; import { RuntimeInteractionInvariantError, + type RuntimeFormOutcome, type RuntimeUserQuestionOutcome, } from '@maka/runtime/interaction-authority'; import type { @@ -176,6 +177,20 @@ export function clientCapabilityCanonicalOutcome( return { kind: 'client_capability_decision', decision: answer.decision, committedAt }; } +export function formCanonicalOutcome( + answer: Extract, + committedAt: number, +): Extract { + return answer.action === 'accept' + ? { + kind: 'form_answer', + action: 'accept', + values: structuredClone(answer.values), + committedAt, + } + : { kind: 'form_answer', action: answer.action, committedAt }; +} + export function runtimeQuestionOutcome( outcome: InteractionCanonicalOutcome, ): RuntimeUserQuestionOutcome { @@ -186,13 +201,30 @@ export function runtimeQuestionOutcome( return { kind: 'closure', reason: outcome.reason }; } if (outcome.kind !== 'question_answer') { - throw new RuntimeInteractionInvariantError( - 'Question Interaction resolved with a permission answer', - ); + throw new RuntimeInteractionInvariantError('Question Interaction resolved with another answer'); } return { kind: 'question_answer', answer: { answers: [...outcome.answers] } }; } +export function runtimeFormOutcome(outcome: InteractionCanonicalOutcome): RuntimeFormOutcome { + if (outcome.kind === 'closure') { + if (outcome.reason === 'timed_out') { + throw new RuntimeInteractionInvariantError('Form Interaction resolved with a timeout'); + } + return { kind: 'closure', reason: outcome.reason }; + } + if (outcome.kind !== 'form_answer') { + throw new RuntimeInteractionInvariantError('Form Interaction resolved with another answer'); + } + return { + kind: 'form_answer', + answer: + outcome.action === 'accept' + ? { action: 'accept', values: structuredClone(outcome.values) } + : { action: outcome.action }, + }; +} + export function answerOutcome( record: InteractionRecord & { outcome: StoredInteractionOutcome }, candidate: InteractionAnswer, @@ -234,6 +266,7 @@ function canonicalOutcomeForHistoricalAnswer( if (answer.kind === 'question') { return questionCanonicalOutcome(answer, committedAt); } + if (answer.kind === 'form') return formCanonicalOutcome(answer, committedAt); if (answer.kind === 'sandbox_boundary') { throw new RuntimeInteractionInvariantError( 'Sandbox boundary answers require their canonical boundary settlement', diff --git a/packages/runtime-host/src/server/root-turn-coordinator.ts b/packages/runtime-host/src/server/root-turn-coordinator.ts index f7442e572e..f9c5f1278f 100644 --- a/packages/runtime-host/src/server/root-turn-coordinator.ts +++ b/packages/runtime-host/src/server/root-turn-coordinator.ts @@ -2412,7 +2412,7 @@ export class RootTurnCoordinator implements HostedExecutionAuthority { await this.continuity.acceptRuntimeEvent(input.sessionId, active.runId, event); } else if (isInteractionAnswerAck(event)) { await this.continuity.refreshCanonical(input.sessionId); - } else if (event.type === 'user_question_request') { + } else if (event.type === 'user_question_request' || event.type === 'form_request') { this.continuity.enqueueCanonicalRefresh(input.sessionId); } } @@ -3128,7 +3128,7 @@ function isRuntimeSessionForwardedEvent( } function isInteractionAnswerAck(event: SessionEvent): boolean { - return event.type === 'user_question_answer_ack'; + return event.type === 'user_question_answer_ack' || event.type === 'form_answer_ack'; } function completedStart(outcome: RootMessageStartOutcome): TurnStartDisposition { diff --git a/packages/runtime/src/__tests__/fake-backend.test.ts b/packages/runtime/src/__tests__/fake-backend.test.ts index b7ed06ceca..0673427f59 100644 --- a/packages/runtime/src/__tests__/fake-backend.test.ts +++ b/packages/runtime/src/__tests__/fake-backend.test.ts @@ -44,6 +44,7 @@ test('Fake question publication waits for exact hosted admission', async () => { admissionStarted.resolve(); await allowAdmission.promise; }, + acceptFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/interaction-authority.test.ts b/packages/runtime/src/__tests__/interaction-authority.test.ts index c0417c4cc3..25cbb72508 100644 --- a/packages/runtime/src/__tests__/interaction-authority.test.ts +++ b/packages/runtime/src/__tests__/interaction-authority.test.ts @@ -36,6 +36,7 @@ import { bindRuntimeInteractionRun, type RuntimeInteractionAuthority, type RuntimeInteractionRunOwner, + type RuntimeFormContinuation, type RuntimeSandboxBoundaryContinuation, type RuntimeUserQuestionContinuation, } from '../interaction-authority.js'; @@ -89,6 +90,7 @@ describe('Runtime Interaction authority seam', () => { runId: 'wrong-run', acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async (reason) => { log.push(`close:${reason}`); }, @@ -204,6 +206,72 @@ describe('Runtime Interaction authority seam', () => { binding.release(); }); + test('publishes a hosted form and resumes only after its exact canonical continuation settles', async () => { + let form: RuntimeFormContinuation | undefined; + const events: SessionEvent[] = []; + const binding = await bindRuntimeInteractionRun( + authority({ + acceptFormRequest: async ({ continuation }) => { + form = continuation; + }, + }), + RUN, + ); + const runtime = toolRuntime(events, binding); + const tool: MakaTool> = { + name: 'SyntheticForm', + description: 'Exercise the form Interaction seam.', + parameters: {}, + nesting: 'direct_only', + impl: (_input, context) => + context.requestUserForm!({ + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + }, + ], + }), + }; + const pending = settleTool( + runtime, + tool, + RUN.turnId, + durableEventSink(events), + )({}, { toolCallId: 'tool-form', abortSignal: new AbortController().signal }); + + await waitFor(() => events.some((event) => event.type === 'form_request')); + assert.ok(form); + assert.throws( + () => + runtime.respondToUserForm({ + requestId: form!.requestId, + action: 'accept', + values: { replicas: 2 }, + }), + RuntimeInteractionInvariantError, + ); + await form!.applyAnswer({ action: 'accept', values: { replicas: 2 } }); + assert.deepEqual(await pending, { action: 'accept', values: { replicas: 2 } }); + await waitFor(() => events.some((event) => event.type === 'form_answer_ack')); + assert.equal( + await binding.canResumeAfterSettlementAck( + events.find((event) => event.type === 'form_answer_ack')!, + ), + true, + ); + + await runtime.endTurn(); + await binding.close('turn_terminal'); + await binding.settleLocalClosures(); + binding.release(); + }); + test('matches a hosted sandbox boundary acknowledgement to its exact durable settlement', async () => { let continuation: RuntimeSandboxBoundaryContinuation | undefined; let applied = false; @@ -534,6 +602,7 @@ function authority( close: async () => {}, release: () => {}, ...overrides, + acceptFormRequest: overrides.acceptFormRequest ?? (async () => {}), }), }; } diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index 9ffea826b8..c914c32745 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -1894,6 +1894,20 @@ const ACTION_COVERAGE_SAMPLES: ActionCoverageSamples = { action: { requestId: 'coverage-question' }, event: { author: 'user', refs: { toolCallId: 'coverage-question-tool' } }, }, + formRequest: { + action: { + requestId: 'coverage-form', + toolUseId: 'coverage-form-tool', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + event: { refs: { toolCallId: 'coverage-form-tool' } }, + }, + formAnswerAccepted: { + action: { requestId: 'coverage-form' }, + event: { author: 'user', refs: { toolCallId: 'coverage-form-tool' } }, + }, transferToAgent: { action: 'agent-b' }, // The terminal fact is one of the actions that does own a row. endInvocation: { action: true }, diff --git a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts index f15caa6568..8f3ce30567 100644 --- a/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts +++ b/packages/runtime/src/__tests__/runtime-kernel-interaction.test.ts @@ -100,6 +100,7 @@ describe('RuntimeKernel Interaction close cleanup', () => { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => { closeCalls += 1; closeStarted.resolve(); @@ -483,6 +484,7 @@ function runtimeFixture(options: RuntimeFixtureOptions = {}): { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => { markCloseStarted(); if (options.deferredClose) await closeReleased; diff --git a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts index 99d8ebd938..e46de3ae4a 100644 --- a/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts +++ b/packages/runtime/src/__tests__/session-event-runtime-mapper.test.ts @@ -323,6 +323,46 @@ describe('mapSessionEventToRuntimeEvent (pure)', () => { assert.equal(mapped.refs?.toolCallId, 'tool-1'); }); + test('form_request maps to one system-authored runtime action', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'form_request', + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'system'); + assert.deepEqual(mapped.actions?.formRequest, { + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }); + }); + + test('form_answer_ack maps without duplicating the canonical result', () => { + const mapped = mapSessionEventToRuntimeEvent( + ev({ + type: 'form_answer_ack', + requestId: 'form-1', + toolUseId: 'tool-1', + }), + ctx, + ); + + assert.equal(mapped.role, 'system'); + assert.equal(mapped.author, 'user'); + assert.deepEqual(mapped.actions?.formAnswerAccepted, { requestId: 'form-1' }); + assert.equal(mapped.refs?.toolCallId, 'tool-1'); + }); + test('tool_result without a prior tool_start still maps (name falls back to empty)', () => { const a = mapSessionEventToRuntimeEvent( ev({ @@ -529,6 +569,29 @@ const PROJECTION_SAMPLES: ProjectionSamples = { toolUseId: 'tool-1', }, }, + form_request: { + subject: { + type: 'form_request', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }, + form_answer_ack: { + subject: { + type: 'form_answer_ack', + id: 'e', + turnId: 'turn-1', + ts: 1, + requestId: 'form-1', + toolUseId: 'tool-1', + }, + }, plan_submitted: { subject: { type: 'plan_submitted', diff --git a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts index f1c59932fb..6da3e1c27b 100644 --- a/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts +++ b/packages/runtime/src/__tests__/session-manager-terminal-ledger.test.ts @@ -2925,6 +2925,7 @@ function hostedInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/session-manager.test.ts b/packages/runtime/src/__tests__/session-manager.test.ts index 9abce3a7ae..cc9d56622f 100644 --- a/packages/runtime/src/__tests__/session-manager.test.ts +++ b/packages/runtime/src/__tests__/session-manager.test.ts @@ -14762,6 +14762,7 @@ function testInteractionAuthority(): RuntimeInteractionAuthority { ...identity, acceptSandboxBoundaryRequest: async () => {}, acceptUserQuestionRequest: async () => {}, + acceptFormRequest: async () => {}, close: async () => {}, release: () => {}, }), diff --git a/packages/runtime/src/__tests__/session-projection-helpers.test.ts b/packages/runtime/src/__tests__/session-projection-helpers.test.ts index bacf2c9971..cda0243994 100644 --- a/packages/runtime/src/__tests__/session-projection-helpers.test.ts +++ b/packages/runtime/src/__tests__/session-projection-helpers.test.ts @@ -151,12 +151,18 @@ describe('session projection helpers', () => { assert.deepStrictEqual(statusFromEvent({ type: 'user_question_request', ts: 1 } as never), { status: 'waiting_for_user', }); + assert.deepStrictEqual(statusFromEvent({ type: 'form_request', ts: 1 } as never), { + status: 'waiting_for_user', + }); assert.deepStrictEqual( statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never), { status: 'running', }, ); + assert.deepStrictEqual(statusFromEvent({ type: 'form_answer_ack', ts: 1 } as never), { + status: 'running', + }); assert.strictEqual( statusFromEvent({ type: 'sandbox_boundary_decision_ack', ts: 1 } as never, { allowInteractionResume: false, diff --git a/packages/runtime/src/__tests__/stream-graph-projection.test.ts b/packages/runtime/src/__tests__/stream-graph-projection.test.ts index 102de1fd19..81a3d10369 100644 --- a/packages/runtime/src/__tests__/stream-graph-projection.test.ts +++ b/packages/runtime/src/__tests__/stream-graph-projection.test.ts @@ -566,6 +566,19 @@ describe('committed stream graph projection', () => { userQuestionAnswerAccepted: { requestId: 'question-1' }, }, }), + runtimeEvent(run, { + id: 'form-request', + ts: baseTs + 4, + actions: { + formRequest: { + requestId: 'form-1', + toolUseId: 'tool-3', + message: 'Choose settings', + requester: { name: 'fixture' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }, + }, + }), ], }, ], @@ -577,9 +590,11 @@ describe('committed stream graph projection', () => { [{ kind: 'attention', reason: 'permission_request' }], [{ kind: 'attention', reason: 'user_question_request' }], [], + [{ kind: 'attention', reason: 'form_request' }], ], ); assert.deepEqual(records[2]?.facets, ['runtime_fact']); + assert.deepEqual(records[3]?.facets, ['form_request']); assert.equal(replayAgentGraphRecords(records).operators.research?.status, 'running'); }); diff --git a/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts new file mode 100644 index 0000000000..af70508124 --- /dev/null +++ b/packages/runtime/src/__tests__/tool-runtime-form-interaction.test.ts @@ -0,0 +1,200 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import type { SessionEvent } from '@maka/core/events'; +import type { SessionHeader } from '@maka/core/session'; +import { z } from 'zod'; + +import { createTestToolRuntime } from './execution-boundary-test-helpers.js'; +import type { MakaTool } from '../tool-runtime.js'; + +function header(): SessionHeader { + return { + id: 'session-1', + workspaceRoot: '/tmp/maka', + cwd: '/tmp/maka', + createdAt: 1, + name: 'Test', + titleIsManual: true, + isFlagged: false, + labels: [], + isArchived: false, + status: 'active', + statusUpdatedAt: 1, + hasUnread: false, + backend: 'ai-sdk', + llmConnectionSlug: 'c', + connectionLocked: true, + model: 'm', + permissionMode: 'ask', + schemaVersion: 1, + }; +} + +function formTool(): MakaTool> { + return { + name: 'SyntheticForm', + description: 'Exercise the provider-neutral form seam.', + parameters: z.object({}), + nesting: 'direct_only', + impl: (_input, context) => { + if (!context.requestUserForm) throw new Error('Form Interaction is unavailable'); + return context.requestUserForm({ + message: 'Choose deployment settings', + requester: { name: 'deploy', source: 'Synthetic provider' }, + fields: [ + { + kind: 'integer', + name: 'replicas', + label: 'Replicas', + required: true, + minimum: 1, + maximum: 10, + }, + { + kind: 'multi_select', + name: 'regions', + label: 'Regions', + required: false, + options: [ + { value: 'us', label: 'US' }, + { value: 'eu', label: 'EU' }, + ], + }, + ], + }); + }, + }; +} + +function runtime(events: SessionEvent[]) { + let id = 0; + return createTestToolRuntime({ + sessionId: 'session-1', + header: header(), + connection: { providerType: 'openai', slug: 'c' } as never, + modelId: 'm', + appendMessage: async () => {}, + newId: () => `id-${++id}`, + now: () => 1, + getPermissionPauseTarget: () => null, + }); +} + +function sink(events: SessionEvent[]) { + return { + push: (event: SessionEvent) => events.push(event), + pushAndWaitUntilConsumed: async (event: SessionEvent) => { + events.push(event); + }, + }; +} + +describe('ToolRuntime form Interaction', () => { + test('parks one Tool call and resumes only after a schema-valid answer', async () => { + const events: SessionEvent[] = []; + const toolRuntime = runtime(events); + const pending = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + + await new Promise((resolve) => setImmediate(resolve)); + const request = events.find((event) => event.type === 'form_request'); + assert.ok(request); + assert.equal(toolRuntime.pendingUserFormCount(), 1); + assert.throws(() => + toolRuntime.respondToUserForm({ + requestId: request.requestId, + action: 'accept', + values: { replicas: 1.5 }, + }), + ); + assert.equal(toolRuntime.pendingUserFormCount(), 1); + + assert.equal( + toolRuntime.respondToUserForm({ + requestId: request.requestId, + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }), + true, + ); + assert.deepEqual(await pending, { + action: 'accept', + values: { replicas: 3, regions: ['us', 'eu'] }, + }); + assert.equal(events.filter((event) => event.type === 'form_answer_ack').length, 1); + }); + + test('keeps decline distinct and rejects a late answer after Turn closure', async () => { + const events: SessionEvent[] = []; + const toolRuntime = runtime(events); + const first = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-1', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + await new Promise((resolve) => setImmediate(resolve)); + const firstRequest = events.find((event) => event.type === 'form_request'); + assert.ok(firstRequest); + assert.equal( + toolRuntime.respondToUserForm({ requestId: firstRequest.requestId, action: 'decline' }), + true, + ); + assert.deepEqual(await first, { action: 'decline' }); + + const second = toolRuntime + .settleToolCall({ + tool: formTool(), + turnId: 'turn-1', + toolCallId: 'tool-2', + input: {}, + abortSignal: new AbortController().signal, + eventSink: sink(events), + }) + .then((settlement) => settlement.result); + while (events.filter((event) => event.type === 'form_request').length < 2) { + await new Promise((resolve) => setImmediate(resolve)); + } + const secondRequest = events.filter((event) => event.type === 'form_request')[1]; + assert.ok(secondRequest); + await toolRuntime.endTurn('aborted'); + assert.deepEqual(await second, { + error: `Turn turn-1 aborted before user form ${secondRequest.requestId} was answered`, + }); + assert.equal( + toolRuntime.respondToUserForm({ requestId: secondRequest.requestId, action: 'cancel' }), + false, + ); + }); +}); diff --git a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts index fb240bb362..acab112c5b 100644 --- a/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts +++ b/packages/runtime/src/__tests__/tool-runtime-sandbox-boundary.test.ts @@ -219,6 +219,9 @@ describe('ToolRuntime session sandbox boundary', () => { admitUserQuestionRequest: async () => { throw new Error('Unexpected user question'); }, + admitFormRequest: async () => { + throw new Error('Unexpected user form'); + }, admitSandboxBoundaryRequest: async ({ request, settlement }) => { admittedRequest = request; captured = settlement; diff --git a/packages/runtime/src/agent-run.ts b/packages/runtime/src/agent-run.ts index a507862d0e..03213161d3 100644 --- a/packages/runtime/src/agent-run.ts +++ b/packages/runtime/src/agent-run.ts @@ -1904,7 +1904,9 @@ async function appendUserMessageOnce( function isInteractionResumeAck(event: SessionEvent): boolean { return ( - event.type === 'sandbox_boundary_decision_ack' || event.type === 'user_question_answer_ack' + event.type === 'sandbox_boundary_decision_ack' || + event.type === 'user_question_answer_ack' || + event.type === 'form_answer_ack' ); } diff --git a/packages/runtime/src/interaction-authority.ts b/packages/runtime/src/interaction-authority.ts index 2f13d36ee9..374c132705 100644 --- a/packages/runtime/src/interaction-authority.ts +++ b/packages/runtime/src/interaction-authority.ts @@ -20,19 +20,24 @@ import { isDeepStrictEqual } from 'node:util'; import type { + FormAnswerAckEvent, + FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, + SessionEvent, UserQuestionAnswerAckEvent, UserQuestionRequestEvent, } from '@maka/core/events'; import type { InteractionCanonicalPermissionOutcome, InteractionClosureReason, + InteractionFormResult, InteractionPermissionRequest, } from '@maka/core/interaction'; import type { SandboxBoundarySettlement } from '@maka/core/sandbox-boundary'; import type { HostedInteractionBridge, + HostedFormSettlement, HostedSandboxBoundarySettlement, HostedUserQuestionAnswer, HostedUserQuestionSettlement, @@ -65,6 +70,10 @@ export type RuntimeUserQuestionOutcome = | { kind: 'question_answer'; answer: RuntimeUserQuestionAnswer } | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; +export type RuntimeFormOutcome = + | { kind: 'form_answer'; answer: InteractionFormResult } + | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; + export type RuntimeSandboxBoundaryOutcome = | { kind: 'sandbox_boundary_decision'; settlement: SandboxBoundarySettlement } | { kind: 'closure'; reason: RuntimeUserQuestionClosureReason }; @@ -79,6 +88,12 @@ export interface RuntimeUserQuestionContinuation waitForPublication(): Promise; } +export interface RuntimeFormContinuation + extends RuntimeInteractionContinuationIdentity, + HostedFormSettlement { + waitForPublication(): Promise; +} + export interface RuntimeSandboxBoundaryContinuation extends RuntimeInteractionContinuationIdentity, HostedSandboxBoundarySettlement { @@ -90,6 +105,10 @@ export interface RuntimeInteractionContinuationAuthority { request: UserQuestionRequestEvent; continuation: RuntimeUserQuestionContinuation; }): Promise; + acceptFormRequest(input: { + request: FormRequestEvent; + continuation: RuntimeFormContinuation; + }): Promise; acceptSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; continuation: RuntimeSandboxBoundaryContinuation; @@ -181,11 +200,38 @@ export class RuntimeInteractionFailStopError extends Error { type LocalClosureFinalizer = () => void; -type HostedInteractionRequestEvent = UserQuestionRequestEvent | SandboxBoundaryRequestEvent; +type HostedInteractionRequestEvent = + | UserQuestionRequestEvent + | FormRequestEvent + | SandboxBoundaryRequestEvent; type HostedInteractionSettlementAckEvent = | UserQuestionAnswerAckEvent + | FormAnswerAckEvent | SandboxBoundaryDecisionAckEvent; -type RuntimeHostedInteractionOutcome = RuntimeUserQuestionOutcome | RuntimeSandboxBoundaryOutcome; + +export function isHostedInteractionRequestEvent( + event: SessionEvent, +): event is HostedInteractionRequestEvent { + return ( + event.type === 'user_question_request' || + event.type === 'form_request' || + event.type === 'sandbox_boundary_request' + ); +} + +export function isHostedInteractionSettlementAckEvent( + event: SessionEvent, +): event is HostedInteractionSettlementAckEvent { + return ( + event.type === 'user_question_answer_ack' || + event.type === 'form_answer_ack' || + event.type === 'sandbox_boundary_decision_ack' + ); +} +type RuntimeHostedInteractionOutcome = + | RuntimeUserQuestionOutcome + | RuntimeFormOutcome + | RuntimeSandboxBoundaryOutcome; interface TrackedContinuationBase { readonly requestId: string; @@ -204,12 +250,20 @@ interface TrackedQuestionContinuation extends TrackedContinuationBase { readonly continuation: RuntimeUserQuestionContinuation; } +interface TrackedFormContinuation extends TrackedContinuationBase { + readonly request: FormRequestEvent; + readonly continuation: RuntimeFormContinuation; +} + interface TrackedSandboxBoundaryContinuation extends TrackedContinuationBase { readonly request: SandboxBoundaryRequestEvent; readonly continuation: RuntimeSandboxBoundaryContinuation; } -type TrackedContinuation = TrackedQuestionContinuation | TrackedSandboxBoundaryContinuation; +type TrackedContinuation = + | TrackedQuestionContinuation + | TrackedFormContinuation + | TrackedSandboxBoundaryContinuation; /** Exact-Run bridge between RuntimeKernel and backend Interaction producers. */ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { @@ -295,6 +349,40 @@ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { } } + async admitFormRequest(input: { + request: FormRequestEvent; + settlement: HostedFormSettlement; + }): Promise { + const tracked = this.trackForm(input.request, input.settlement); + try { + await this.owner.acceptFormRequest({ + request: input.request, + continuation: tracked.continuation, + }); + } catch (error) { + tracked.completePublicationBarrier(); + if (!tracked.settlementStarted) this.continuations.delete(tracked.requestId); + throw error; + } + if (tracked.settlementStarted) { + try { + await tracked.settlementPromise; + throw new RuntimeInteractionInvariantError( + `Form ${tracked.requestId} settled during pending-only admission`, + ); + } finally { + tracked.completePublicationBarrier(); + } + } + tracked.admissionState = 'pending'; + if (this.publicationsSealed) { + tracked.completePublicationBarrier(); + throw new RuntimeInteractionInvariantError( + `Form ${tracked.requestId} completed admission after Interaction publication sealed`, + ); + } + } + async admitSandboxBoundaryRequest(input: { request: SandboxBoundaryRequestEvent; settlement: HostedSandboxBoundarySettlement; @@ -490,6 +578,47 @@ export class RuntimeInteractionRunBinding implements HostedInteractionBridge { return tracked; } + private trackForm( + request: FormRequestEvent, + local: HostedFormSettlement, + ): TrackedFormContinuation { + this.assertNewContinuation(request); + let tracked!: TrackedFormContinuation; + const publication = createInteractionPublicationBarrier(); + const continuation: RuntimeFormContinuation = Object.freeze({ + requestId: request.requestId, + turnId: this.turnId, + runId: this.runId, + waitForPublication: () => publication.publicationBarrier, + applyAnswer: (answer: InteractionFormResult) => + this.settleTracked( + tracked, + () => local.applyAnswer(answer), + { kind: 'form_answer', answer }, + 'form answer', + ), + applyClosure: (reason: RuntimeUserQuestionClosureReason) => + this.settleTracked( + tracked, + () => local.applyClosure(reason), + { kind: 'closure', reason }, + 'form closure', + ), + }); + tracked = { + requestId: request.requestId, + request, + continuation, + ...publication, + admissionState: undefined, + published: false, + settlementStarted: false, + settled: false, + }; + this.continuations.set(request.requestId, tracked); + return tracked; + } + private trackSandboxBoundary( request: SandboxBoundaryRequestEvent, local: HostedSandboxBoundarySettlement, @@ -603,6 +732,7 @@ function settlementMatchesAck( const outcome = tracked.outcome; if (!outcome) return false; if (event.type === 'user_question_answer_ack') return outcome.kind === 'question_answer'; + if (event.type === 'form_answer_ack') return outcome.kind === 'form_answer'; if (outcome.kind !== 'sandbox_boundary_decision') return false; const { request, boundary } = outcome.settlement; return ( diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index 62ab789737..2390b3d528 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -289,6 +289,18 @@ export function projectRuntimeEventsToStoredMessages( projected = true; } + if (event.actions?.formRequest) { + // The matching function_call/function_response own the legacy rows; + // this request is live interaction state only. + projected = true; + } + + if (event.actions?.formAnswerAccepted) { + // InteractionStore owns the canonical result. This Run-local audit fact + // intentionally has no legacy chat row. + projected = true; + } + if (event.actions?.permissionAnswerAccepted) { projectCanonicalPermissionOutcome( event, diff --git a/packages/runtime/src/runtime-kernel.ts b/packages/runtime/src/runtime-kernel.ts index f9974c0a57..2ec0173e0a 100644 --- a/packages/runtime/src/runtime-kernel.ts +++ b/packages/runtime/src/runtime-kernel.ts @@ -146,6 +146,8 @@ import { RuntimeInteractionFailStopError, RuntimeInteractionInvariantError, bindRuntimeInteractionRun, + isHostedInteractionRequestEvent, + isHostedInteractionSettlementAckEvent, type RuntimeInteractionAuthority, type RuntimeInteractionRunBinding, type RuntimeInteractionRunClosureReason, @@ -1590,10 +1592,7 @@ export class RuntimeKernel implements RuntimeKernelLike { binding: RuntimeInteractionRunBinding | undefined, event: SessionEvent, ): void { - if ( - binding && - (event.type === 'user_question_request' || event.type === 'sandbox_boundary_request') - ) { + if (binding && isHostedInteractionRequestEvent(event)) { binding.assertPendingAdmission(event); } } @@ -3337,10 +3336,7 @@ async function interactionResumeAllowed( interactionRun: RuntimeInteractionRunBinding | undefined, event: SessionEvent, ): Promise { - if ( - !interactionRun || - (event.type !== 'user_question_answer_ack' && event.type !== 'sandbox_boundary_decision_ack') - ) { + if (!interactionRun || !isHostedInteractionSettlementAckEvent(event)) { return true; } return await interactionRun.canResumeAfterSettlementAck(event); diff --git a/packages/runtime/src/session-event-runtime-mapper.ts b/packages/runtime/src/session-event-runtime-mapper.ts index 5c53a3a595..d40f028623 100644 --- a/packages/runtime/src/session-event-runtime-mapper.ts +++ b/packages/runtime/src/session-event-runtime-mapper.ts @@ -115,6 +115,8 @@ function resolveBase(event: SessionEvent, ctx: RuntimeEventMapContext) { * - sandbox_boundary_request → role 'system', author 'system' * - sandbox_boundary_decision_ack → role 'system', author 'user' * - user_question_answer_ack → role 'system', author 'user' + * - form_request → role 'system', author 'system' + * - form_answer_ack → role 'system', author 'user' * - plan_submitted → role 'system', author 'agent' * - token_usage → role 'system', author 'system' * - error → role 'system', author 'system' @@ -453,6 +455,30 @@ function mapBackendSessionEvent( }, refs: { toolCallId: event.toolUseId }, }; + case 'form_request': + return { + ...base, + role: 'system', + author: 'system', + actions: { + formRequest: { + requestId: event.requestId, + toolUseId: event.toolUseId, + message: event.message, + requester: event.requester, + fields: event.fields, + }, + }, + refs: { toolCallId: event.toolUseId }, + }; + case 'form_answer_ack': + return { + ...base, + role: 'system', + author: 'user', + actions: { formAnswerAccepted: { requestId: event.requestId } }, + refs: { toolCallId: event.toolUseId }, + }; // ── Steering: a user message injected mid-turn at a step boundary ───── // Persisted as a first-class user event so the ledger, transcript, and diff --git a/packages/runtime/src/session-projection-helpers.ts b/packages/runtime/src/session-projection-helpers.ts index b57598339e..f470699290 100644 --- a/packages/runtime/src/session-projection-helpers.ts +++ b/packages/runtime/src/session-projection-helpers.ts @@ -134,11 +134,13 @@ export function statusFromEvent( case 'sandbox_boundary_request': return { status: 'waiting_for_user', blockedReason: 'permission_required' }; case 'user_question_request': + case 'form_request': return { status: 'waiting_for_user' }; case 'sandbox_boundary_decision_ack': if (options.allowInteractionResume === false) return undefined; return { status: 'running' }; case 'user_question_answer_ack': + case 'form_answer_ack': if (options.allowInteractionResume === false) return undefined; return { status: 'running' }; case 'error': diff --git a/packages/runtime/src/stream-graph-projection.ts b/packages/runtime/src/stream-graph-projection.ts index c99078e02e..3a6068af95 100644 --- a/packages/runtime/src/stream-graph-projection.ts +++ b/packages/runtime/src/stream-graph-projection.ts @@ -37,6 +37,7 @@ export const AGENT_GRAPH_RECORD_FACETS = [ 'permission_request', 'permission_decision', 'user_question_request', + 'form_request', 'transfer', 'usage', 'completed', @@ -55,7 +56,10 @@ export type AgentGraphActivationStatus = | 'aborted' | 'cancelled'; -export type AgentGraphSupervisorAttentionReason = 'permission_request' | 'user_question_request'; +export type AgentGraphSupervisorAttentionReason = + | 'permission_request' + | 'user_question_request' + | 'form_request'; export type AgentGraphSupervisorSignal = | { @@ -521,6 +525,7 @@ function runtimeEventFacets(event: RuntimeEvent, run: AgentRunHeader): AgentGrap if (actions?.permissionRequest) facets.push('permission_request'); if (actions?.permissionDecision) facets.push('permission_decision'); if (actions?.userQuestionRequest) facets.push('user_question_request'); + if (actions?.formRequest) facets.push('form_request'); if (actions?.transferToAgent) facets.push('transfer'); if (actions?.tokenUsage) facets.push('usage'); @@ -541,6 +546,9 @@ function runtimeEventSupervisorSignals( if (event.actions?.userQuestionRequest) { signals.push({ kind: 'attention', reason: 'user_question_request' }); } + if (event.actions?.formRequest) { + signals.push({ kind: 'attention', reason: 'form_request' }); + } const terminalStatus = runtimeEventTerminalStatus(event, run); if (terminalStatus) { signals.push({ kind: 'terminal', status: terminalStatus }); diff --git a/packages/runtime/src/stream-graph-read-model.ts b/packages/runtime/src/stream-graph-read-model.ts index 6233c2ce73..5510f927b7 100644 --- a/packages/runtime/src/stream-graph-read-model.ts +++ b/packages/runtime/src/stream-graph-read-model.ts @@ -941,6 +941,11 @@ function projectClientSessionEvent( facets: ['user_question_request'], signals: [{ kind: 'attention', reason: 'user_question_request' }], }; + case 'form_request': + return { + facets: ['form_request'], + signals: [{ kind: 'attention', reason: 'form_request' }], + }; case 'token_usage': return { facets: ['usage'], signals: [] }; case 'error': diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index cb81443d34..b542c7a6b3 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -32,6 +32,8 @@ import { import { serializedByteLength } from '@maka/core/serialized-byte-length'; import { encodeToolStepProgress, ToolOutcomeUnknownError } from '@maka/core/events'; import type { + FormAnswerAckEvent, + FormRequestEvent, SandboxBoundaryDecisionAckEvent, SandboxBoundaryRequestEvent, SessionEvent, @@ -47,11 +49,20 @@ import type { } from '@maka/core/events'; import type { ToolCallMessage, ToolResultMessage } from '@maka/core/session'; import type { + HostedFormSettlement, HostedInteractionBridge, HostedSandboxBoundarySettlement, HostedUserQuestionAnswer, HostedUserQuestionSettlement, } from '@maka/core/backend-types'; +import { + isInteractionAnswerValidForRequest, + projectInteractionFormRequest, + type InteractionFormInput, + type InteractionFormRequest, + type InteractionFormResponse, + type InteractionFormResult, +} from '@maka/core/interaction'; import type { PermissionMode, ToolCategory, ToolExecutionFacts } from '@maka/core/permission'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import type { OrchestrationMode } from '@maka/core/orchestration'; @@ -299,6 +310,7 @@ export interface MakaToolContext { view?: 'result' | 'events' | 'runtime_events' | 'all'; }) => Promise; askUserQuestion?: (questions: UserQuestion[]) => Promise; + requestUserForm?: (form: InteractionFormInput) => Promise; requestSandboxBoundary?: ( expansion: SandboxBoundaryExpansion, justification: string, @@ -545,10 +557,15 @@ export class ToolRuntime { UserQuestionResponse, { toolUseId: string; questions: UserQuestion[]; hosted: boolean } >(); + private readonly userForms = new AwaitRegistry< + InteractionFormResponse, + { toolUseId: string; request: InteractionFormRequest; hosted: boolean } + >(); private readonly turnId: string; private readonly hostedInteraction: HostedInteractionBridge | undefined; private sandboxBoundaryClosureDeferred = false; private questionClosureDeferred = false; + private formClosureDeferred = false; private activeSubagentToolCount = 0; private childAgentRunLimiter = new AdmissionLimiter(MAX_ACTIVE_CHILD_AGENT_RUNS_PER_TURN); /** @@ -632,6 +649,7 @@ export class ToolRuntime { } const hasHostedPending = this.userQuestions.entries().some(([, question]) => question.hosted); + const hasHostedFormPending = this.userForms.entries().some(([, form]) => form.hosted); if (hasHostedBoundaryPending) { this.sandboxBoundaryClosureDeferred = true; this.finishDeferredSandboxBoundaryTurnClosure(); @@ -652,6 +670,16 @@ export class ToolRuntime { ); this.questionClosureDeferred = false; } + if (hasHostedFormPending) { + this.formClosureDeferred = true; + this.finishDeferredFormTurnClosure(); + } else { + this.userForms.close( + (requestId) => + new Error(`Turn ${turnId} ${reason} before user form ${requestId} was answered`), + ); + this.formClosureDeferred = false; + } this.resetTurnState(); // The stop path settles the run's terminal fact right after the // backend's stop resolves, and that stop awaits this method. Unwinds @@ -686,6 +714,22 @@ export class ToolRuntime { return this.settleUserQuestionAnswer(turnId, response, pending); } + respondToUserForm(response: InteractionFormResponse): boolean { + if (!response || typeof response.requestId !== 'string') { + throw new Error('Invalid user form response'); + } + const pending = this.userForms + .entries() + .find(([requestId]) => requestId === response.requestId)?.[1]; + if (!pending) return false; + if (pending.hosted) { + throw new RuntimeInteractionInvariantError( + `Hosted form ${response.requestId} must settle through its captured continuation`, + ); + } + return this.settleUserFormAnswer(response, pending); + } + async respondToSandboxBoundaryResponse(response: { requestId: string; decision: SandboxBoundaryDecision; @@ -736,6 +780,22 @@ export class ToolRuntime { return resolved; } + private settleUserFormAnswer( + response: InteractionFormResponse, + pending: { toolUseId: string; request: InteractionFormRequest; hosted: boolean }, + ): boolean { + const answer = + response.action === 'accept' + ? { kind: 'form' as const, action: 'accept' as const, values: response.values } + : { kind: 'form' as const, action: response.action }; + if (!isInteractionAnswerValidForRequest(pending.request, answer)) { + throw new Error('Invalid user form response'); + } + const resolved = this.userForms.resolve(response.requestId, response) !== null; + this.finishDeferredFormTurnClosure(); + return resolved; + } + closeUserQuestion( turnId: string, requestId: string, @@ -748,10 +808,22 @@ export class ToolRuntime { return closed; } + closeUserForm(requestId: string, reason: RuntimeInteractionClosureReason): boolean { + const closed = + this.userForms.reject(requestId, new RuntimeInteractionClosedError(requestId, reason)) !== + null; + this.finishDeferredFormTurnClosure(); + return closed; + } + pendingUserQuestionCount(): number { return this.userQuestions.pendingCount(); } + pendingUserFormCount(): number { + return this.userForms.pendingCount(); + } + /** * Settle one resolved Maka tool call. Tool/business failures resolve with a * provider-facing error output; durable runtime commit failures still reject. @@ -1650,6 +1722,8 @@ export class ToolRuntime { }), askUserQuestion: (questions) => this.askUserQuestion(turnId, toolUseId, questions, ctx.abortSignal, queue), + requestUserForm: (form) => + this.requestUserForm(turnId, toolUseId, form, ctx.abortSignal, queue), requestSandboxBoundary: (expansion, justification) => this.requestSandboxBoundary( turnId, @@ -2554,6 +2628,101 @@ export class ToolRuntime { }; } + private async requestUserForm( + turnId: string, + toolUseId: string, + form: InteractionFormInput, + abortSignal: AbortSignal, + queue: DurableSessionEventSink, + ): Promise { + throwIfAborted(abortSignal); + const hostedRun = this.interactionRun(); + const requestId = this.input.newId(); + const request = projectInteractionFormRequest({ toolUseId, ...form }); + const parked = this.userForms.park(requestId, { + toolUseId, + request, + hosted: hostedRun !== undefined, + }); + const onAbort = (): void => { + if (hostedRun) return; + this.userForms.reject(requestId, abortErrorFromSignal(abortSignal)); + this.finishDeferredFormTurnClosure(); + }; + abortSignal.addEventListener('abort', onAbort, { once: true }); + if (hostedRun) void parked.catch(() => undefined); + try { + const requestEvent: FormRequestEvent = { + type: 'form_request', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId, + toolUseId, + message: request.message, + requester: request.requester, + fields: request.fields, + }; + if (hostedRun) { + const settlement = this.createFormSettlement(turnId, requestId); + const admission = hostedRun.admitFormRequest({ request: requestEvent, settlement }); + try { + await racePromiseWithAbort(admission, abortSignal); + } catch (error) { + if (abortSignal.aborted) { + void admission.catch((admissionError) => { + this.userForms.reject( + requestId, + admissionError instanceof Error + ? admissionError + : new RuntimeInteractionFailStopError( + `Could not confirm admission for form ${requestId}`, + admissionError, + ), + ); + this.finishDeferredFormTurnClosure(); + }); + throw abortErrorFromSignal(abortSignal); + } + this.userForms.reject( + requestId, + error instanceof Error + ? error + : new RuntimeInteractionFailStopError( + `Could not confirm admission for form ${requestId}`, + error, + ), + ); + this.finishDeferredFormTurnClosure(); + await parked.catch(() => undefined); + throw interactionAuthorityError( + `Could not confirm admission for form ${requestId}`, + error, + ); + } + } + throwIfAborted(abortSignal); + queue.push(requestEvent); + const response = await racePromiseWithAbort(parked, abortSignal); + throwIfAborted(abortSignal); + const answerAck: FormAnswerAckEvent = { + type: 'form_answer_ack', + id: this.input.newId(), + turnId, + ts: this.input.now(), + requestId, + toolUseId, + }; + if (hostedRun) await this.publishHostedSettlementAck(queue, answerAck); + else queue.push(answerAck); + return response.action === 'accept' + ? { action: 'accept', values: response.values } + : { action: response.action }; + } finally { + abortSignal.removeEventListener('abort', onAbort); + } + } + private async askUserQuestion( turnId: string, toolUseId: string, @@ -2929,6 +3098,18 @@ export class ToolRuntime { ); } + private finishDeferredFormTurnClosure(): void { + const turnId = this.turnId; + if (!this.formClosureDeferred || this.userForms.pendingCount() !== 0) return; + this.formClosureDeferred = false; + this.userForms.close( + (requestId) => + new RuntimeInteractionInvariantError( + `Hosted form ${requestId} escaped exact Run closure for turn ${turnId}`, + ), + ); + } + private finishDeferredSandboxBoundaryTurnClosure(): void { const turnId = this.turnId; if (!this.sandboxBoundaryClosureDeferred || this.sandboxBoundaryRequests.pendingCount() !== 0) { @@ -3016,6 +3197,37 @@ export class ToolRuntime { }, }); } + + private createFormSettlement(turnId: string, requestId: string): HostedFormSettlement { + return Object.freeze({ + applyAnswer: async (answer: InteractionFormResult): Promise => { + if (Object.hasOwn(answer, 'requestId')) { + throw new RuntimeInteractionInvariantError( + `Form settlement ${requestId} received a routed answer`, + ); + } + const pending = this.userForms + .entries() + .find(([candidateId]) => candidateId === requestId)?.[1]; + const response: InteractionFormResponse = + answer.action === 'accept' + ? { requestId, action: 'accept', values: answer.values } + : { requestId, action: answer.action }; + if (!pending || !this.settleUserFormAnswer(response, pending)) { + throw new RuntimeInteractionInvariantError( + `Form settlement did not take ${requestId} from turn ${turnId}`, + ); + } + }, + applyClosure: async (reason: RuntimeUserQuestionClosureReason): Promise => { + if (!this.closeUserForm(requestId, reason)) { + throw new RuntimeInteractionInvariantError( + `Form closure did not take ${requestId} from turn ${turnId}`, + ); + } + }, + }); + } } async function validateDeclaredToolArgs(parameters: unknown, args: unknown): Promise { diff --git a/packages/storage/src/interaction-store.ts b/packages/storage/src/interaction-store.ts index 53b1260793..3aa5b4195c 100644 --- a/packages/storage/src/interaction-store.ts +++ b/packages/storage/src/interaction-store.ts @@ -31,6 +31,7 @@ import { decodeInteractionRequest, interactionCanonicalOutcomesEquivalent, isInteractionCanonicalOutcomeValidForRequest, + projectInteractionFormRequest, projectInteractionQuestionRequest, type InteractionCanonicalOutcome, type InteractionRequest, @@ -681,6 +682,17 @@ function normalizeRequest(value: unknown, source: DecodeSource): StoredInteracti if (!isDeepStrictEqual(request, canonical)) decodeFailure(source, 'Interaction question request is not canonical safe text'); request = canonical; + } else if (request.kind === 'form') { + const canonical = projectInteractionFormRequest({ + toolUseId: request.toolUseId, + message: request.message, + requester: request.requester, + fields: request.fields, + }); + if (!isDeepStrictEqual(request, canonical)) { + decodeFailure(source, 'Interaction form request is not canonical'); + } + request = canonical; } } catch (error) { if (error instanceof InteractionStoreError) throw error; diff --git a/packages/ui/src/__tests__/interaction-queue.test.ts b/packages/ui/src/__tests__/interaction-queue.test.ts index 7a466d4b7f..6d2090c595 100644 --- a/packages/ui/src/__tests__/interaction-queue.test.ts +++ b/packages/ui/src/__tests__/interaction-queue.test.ts @@ -21,6 +21,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import type { + FormRequestEvent, SandboxBoundaryRequestEvent, UserQuestionRequestEvent, } from '@maka/core/events'; @@ -64,6 +65,20 @@ function question(requestId: string): UserQuestionRequestEvent { }; } +function form(requestId: string): FormRequestEvent { + return { + type: 'form_request', + id: `evt_${requestId}`, + turnId: 'turn_1', + ts: 0, + requestId, + toolUseId: `call_${requestId}`, + message: 'Choose settings', + requester: { name: 'deploy' }, + fields: [{ kind: 'boolean', name: 'confirm', label: 'Confirm', required: true }], + }; +} + describe('composer interaction queue', () => { test('boundary and question requests share one FIFO per session', () => { let queues: InteractionQueues = {}; @@ -147,4 +162,10 @@ describe('composer interaction queue', () => { assert.equal(activeInteractionFor(reconciled, 's')?.type, 'user_question_request'); assert.equal(activeInteractionFor(reconciled, 's')?.requestId, 'missed'); }); + + test('does not hide the composer for a form until the form surface is installed', () => { + const queues = reconcileInteractions({}, 's', [form('form-1')]); + + assert.equal(activeInteractionFor(queues, 's'), undefined); + }); }); diff --git a/packages/ui/src/interaction-queue.ts b/packages/ui/src/interaction-queue.ts index 5022da146a..25c101ba55 100644 --- a/packages/ui/src/interaction-queue.ts +++ b/packages/ui/src/interaction-queue.ts @@ -17,11 +17,29 @@ * under the License. */ -import type { ActiveInteractionRequestEvent, SessionEvent } from '@maka/core/events'; +import type { + ActiveInteractionRequestEvent, + ClientCapabilityRequestEvent, + SandboxBoundaryRequestEvent, + SessionEvent, + UserQuestionRequestEvent, +} from '@maka/core/events'; -export type ComposerInteraction = ActiveInteractionRequestEvent; +/** Requests this surface can render and settle itself. */ +export type ComposerInteraction = + | SandboxBoundaryRequestEvent + | ClientCapabilityRequestEvent + | UserQuestionRequestEvent; export type InteractionQueues = Record; +function isComposerInteraction(event: ActiveInteractionRequestEvent): event is ComposerInteraction { + return ( + event.type === 'sandbox_boundary_request' || + event.type === 'client_capability_request' || + event.type === 'user_question_request' + ); +} + export function enqueueInteraction( queues: InteractionQueues, sessionId: string, @@ -88,9 +106,10 @@ export function reduceInteractionQueues( export function reconcileInteractions( queues: InteractionQueues, sessionId: string, - liveRequests: readonly ComposerInteraction[], + liveRequests: readonly ActiveInteractionRequestEvent[], ): InteractionQueues { - const liveById = new Map(liveRequests.map((request) => [request.requestId, request])); + const visibleRequests = liveRequests.filter(isComposerInteraction); + const liveById = new Map(visibleRequests.map((request) => [request.requestId, request])); const seen = new Set(); const reconciled: ComposerInteraction[] = []; for (const interaction of queues[sessionId] ?? []) { @@ -99,7 +118,7 @@ export function reconcileInteractions( seen.add(interaction.requestId); reconciled.push(live); } - for (const request of liveRequests) { + for (const request of visibleRequests) { if (!seen.has(request.requestId)) reconciled.push(request); } return { ...queues, [sessionId]: reconciled };